Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.85% covered (warning)
66.85%
238 / 356
45.83% covered (danger)
45.83%
11 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Agents_Manager
67.04% covered (warning)
67.04%
238 / 355
45.83% covered (danger)
45.83%
11 / 24
704.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
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_menu_panel
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_scripts
67.16% covered (warning)
67.16%
45 / 67
0.00% covered (danger)
0.00%
0 / 1
18.98
 get_variant
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
12
 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
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
7
 get_assets_json
59.09% covered (warning)
59.09%
13 / 22
0.00% covered (danger)
0.00%
0 / 1
14.55
 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%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_proxied
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 is_dev_mode
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
13
 register_rest_api
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 should_use_unified_experience
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
8.03
 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_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-mu-wpcom
6 */
7
8namespace A8C\FSE;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11
12/**
13 * Class Agents_Manager
14 */
15class Agents_Manager {
16    /**
17     * Help Center URL for disconnected variants.
18     *
19     * @var string
20     */
21    private const HELP_CENTER_URL = 'https://wordpress.com/help?help-center=home';
22
23    /**
24     * Class instance.
25     *
26     * @var Agents_Manager
27     */
28    private static $instance = null;
29
30    /**
31     * Agents_Manager constructor.
32     */
33    public function __construct() {
34        add_action( 'rest_api_init', array( $this, 'register_rest_api' ) );
35        add_filter( 'calypso_preferences_update', array( $this, 'calypso_preferences_update' ) );
36
37        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
38        add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
39        add_action( 'next_admin_init', array( $this, 'enqueue_scripts' ), 1001 );
40        add_filter( 'agents_manager_use_unified_experience', array( $this, 'should_use_unified_experience' ) );
41    }
42
43    /**
44     * Check if the agents manager menu panel should be displayed.
45     *
46     * @return bool True if the menu panel should be displayed.
47     */
48    public function should_display_menu_panel() {
49        return apply_filters( 'agents_manager_use_unified_experience', false );
50    }
51
52    /**
53     * Get the SVG icon markup for a given icon name.
54     *
55     * @param string $icon_name The name of the icon to retrieve.
56     * @return string The SVG markup.
57     */
58    private function get_icon( $icon_name ) {
59        $icons = array(
60            '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>',
61            '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>',
62            '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>',
63            '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>',
64            '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>',
65        );
66
67        return $icons[ $icon_name ] ?? '';
68    }
69
70    /**
71     * Add the agents manager menu panel to the admin bar.
72     *
73     * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
74     */
75    public function add_menu_panel( $wp_admin_bar ) {
76        // Add chat support group
77        $wp_admin_bar->add_group(
78            array(
79                'parent' => 'agents-manager',
80                'id'     => 'agents-manager-menu-panel-chat',
81                'meta'   => array(
82                    'class' => 'ab-sub-secondary',
83                ),
84            )
85        );
86
87        // Add chat support menu item
88        $wp_admin_bar->add_node(
89            array(
90                'parent' => 'agents-manager-menu-panel-chat',
91                'id'     => 'agents-manager-chat-support',
92                'title'  => $this->get_icon( 'comment' ) . '<span>' . __( 'Chat support', 'jetpack-mu-wpcom' ) . '</span>',
93            )
94        );
95
96        // Add chat history menu item
97        $wp_admin_bar->add_node(
98            array(
99                'parent' => 'agents-manager-menu-panel-chat',
100                'id'     => 'agents-manager-chat-history',
101                'title'  => $this->get_icon( 'backup' ) . '<span>' . __( 'Chat history', 'jetpack-mu-wpcom' ) . '</span>',
102            )
103        );
104
105        // Add links group
106        $wp_admin_bar->add_group(
107            array(
108                'parent' => 'agents-manager',
109                'id'     => 'agents-manager-menu-panel-links',
110                'meta'   => array(
111                    'class' => 'ab-sub-secondary',
112                ),
113            )
114        );
115
116        // Add support guides menu item
117        $wp_admin_bar->add_node(
118            array(
119                'parent' => 'agents-manager-menu-panel-links',
120                'id'     => 'agents-manager-support-guides',
121                'title'  => $this->get_icon( 'page' ) . '<span>' . __( 'Support guides', 'jetpack-mu-wpcom' ) . '</span>',
122            )
123        );
124
125        // Add courses menu item
126        $wp_admin_bar->add_node(
127            array(
128                'parent' => 'agents-manager-menu-panel-links',
129                'id'     => 'agents-manager-courses',
130                'title'  => $this->get_icon( 'video' ) . '<span>' . __( 'Courses', 'jetpack-mu-wpcom' ) . '</span>',
131                'href'   => 'https://wordpress.com/support/courses/',
132                'meta'   => array(
133                    'target' => '_blank',
134                    'rel'    => 'noopener noreferrer',
135                ),
136            )
137        );
138
139        // Add product updates menu item
140        $wp_admin_bar->add_node(
141            array(
142                'parent' => 'agents-manager-menu-panel-links',
143                'id'     => 'agents-manager-product-updates',
144                'title'  => $this->get_icon( 'rss' ) . '<span>' . __( 'Product updates', 'jetpack-mu-wpcom' ) . '</span>',
145                'href'   => 'https://wordpress.com/blog/category/product-features/',
146                'meta'   => array(
147                    'target' => '_blank',
148                    'rel'    => 'noopener noreferrer',
149                ),
150            )
151        );
152    }
153
154    /**
155     * Enqueue Agents Manager scripts and add inline script data.
156     */
157    public function enqueue_scripts() {
158        // Early return for P2 frontend - don't add admin bar or enqueue scripts.
159        $stylesheet = get_stylesheet();
160        $is_p2      = str_contains( $stylesheet, 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() );
161
162        if ( ! is_admin() && $is_p2 ) {
163            return;
164        }
165
166        // Determine which variant to load (null = don't load).
167        $variant = $this->get_variant();
168        if ( null === $variant ) {
169            return;
170        }
171        $use_disconnected = str_contains( $variant, 'disconnected' );
172        $is_gutenberg     = $this->is_block_editor();
173
174        // In Gutenberg, dequeue Help Center so we don't end up with two buttons.
175        // Agents Manager fires at priority 101, after Help Center at 100, so HC is already enqueued.
176        if ( $is_gutenberg ) {
177            wp_dequeue_script( 'help-center' );
178            wp_dequeue_style( 'help-center-style' );
179        }
180
181        // For non-Gutenberg, non-CIAB environments, add to admin bar.
182        // Gutenberg doesn't have an admin bar, so JS will handle UI insertion.
183        // CIAB hides the classic admin bar and uses its own Site Hub â€” the JS variant handles UI there.
184        $is_ciab = $this->is_ciab_environment();
185        if ( ! $is_gutenberg && ! $is_ciab ) {
186            add_action(
187                'admin_bar_menu',
188                function ( $wp_admin_bar ) use ( $use_disconnected ) {
189                    // Remove the help-center menu item
190                    $wp_admin_bar->remove_node( 'help-center' );
191
192                    $menu_args = array(
193                        'id'     => 'agents-manager',
194                        'title'  => '<span title="' . __( 'Help Center', 'jetpack-mu-wpcom' ) . '"><svg id="agents-manager-icon" class="ab-icon" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
195                                        <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" />
196                                    </svg></span>',
197                        'parent' => 'top-secondary',
198                    );
199
200                    // For disconnected variants, link directly to help center instead of showing dropdown
201                    if ( $use_disconnected ) {
202                        $menu_args['href'] = self::HELP_CENTER_URL;
203                        $menu_args['meta'] = array(
204                            'target' => '_blank',
205                            'rel'    => 'noopener noreferrer',
206                        );
207                    } else {
208                        // For full variants, show the dropdown menu panel
209                        $menu_args['meta'] = array(
210                            'html'   => '<div id="agents-manager-masterbar" />',
211                            'class'  => 'menupop',
212                            'target' => '_blank',
213                            'rel'    => 'noopener noreferrer',
214                        );
215                    }
216
217                    // Add the main agents manager menu node
218                    $wp_admin_bar->add_menu( $menu_args );
219                },
220                // Add the agents manager icon to the admin bar after the help center is added, so we can remove it.
221                100
222            );
223
224            // Initialize the agents manager menu panel (only for full variants, not disconnected)
225            if ( ! $use_disconnected ) {
226                add_action( 'admin_bar_menu', array( $this, 'add_menu_panel' ), 100 );
227            }
228        }
229
230        /**
231         * Filter to register agent provider modules for the Agents Manager.
232         *
233         * Plugins can hook into this filter to register script module IDs that export
234         * toolProvider and/or contextProvider. The Agents Manager JS will dynamically
235         * import these modules and merge their providers.
236         *
237         * @param array $providers Array of provider script module IDs.
238         */
239        $agent_providers = apply_filters( 'agents_manager_agent_providers', array() );
240
241        /**
242         * Filter to determine if user should see the unified chat experience.
243         *
244         * When true, Help Center will render UnifiedAIAgent instead of traditional UI.
245         * The filter is hooked by should_use_unified_experience() in this class.
246         *
247         * @param bool $use_unified_experience Whether to use unified experience. Default false.
248         */
249        $use_unified_experience = apply_filters( 'agents_manager_use_unified_experience', false );
250
251        /**
252         * Filter the default agent ID for the Agents Manager.
253         *
254         * Allows host applications (e.g., CIAB, WooCommerce AI) to specify a custom
255         * workflow agent instead of the default orchestrator. The value is passed to
256         * the frontend as `agentsManagerData.agentId` and consumed by `useAgentConfig()`.
257         *
258         * @param string|null $agent_id The agent ID to use, or null for default behavior.
259         */
260        $agent_id = apply_filters( 'agents_manager_agent_id', null );
261
262        $this->enqueue_script( $variant );
263
264        $inline_data = array(
265            'agentProviders'       => $agent_providers,
266            'useUnifiedExperience' => $use_unified_experience,
267            'isDevMode'            => self::is_dev_mode(),
268            'sectionName'          => $variant,
269            'currentUser'          => $this->get_current_user_data(),
270            'site'                 => $this->get_current_site(),
271            'helpCenterUrl'        => self::HELP_CENTER_URL,
272        );
273
274        if ( $agent_id ) {
275            $inline_data['agentId'] = $agent_id;
276        }
277
278        /**
279         * Filter the data exposed to the Agents Manager frontend.
280         *
281         * @param array $inline_data Data encoded into `agentsManagerData`.
282         */
283        $filtered    = apply_filters( 'jetpack_ai_sidebar_agents_manager_data', $inline_data );
284        $inline_data = is_array( $filtered ) ? $filtered : $inline_data;
285
286        wp_add_inline_script(
287            'agents-manager',
288            'const agentsManagerData = ' . wp_json_encode(
289                $inline_data,
290                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
291            ) . ';',
292            'before'
293        );
294    }
295
296    /**
297     * Determine which script variant to load, or null if none should be loaded.
298     *
299     * Combines the gating logic (should we load at all?) with variant selection
300     * (which build to use?) into a single method so the two cannot get out of sync.
301     *
302     * @return string|null The variant name, or null if scripts should not be loaded.
303     */
304    private function get_variant() {
305        // CIAB: Load either the connected or disconnected variants if enabled.
306        if ( $this->is_ciab_environment() && self::is_enabled() ) {
307            return $this->is_jetpack_disconnected() ? 'ciab-disconnected' : 'ciab';
308        }
309
310        // Frontend: load disconnected variant for eligible logged-in editors.
311        if ( ! is_admin() ) {
312            if ( $this->is_loading_on_frontend() && self::is_enabled() ) {
313                return 'wp-admin-disconnected';
314            }
315            return null;
316        }
317
318        // Apply wp-admin exclusions (WooCommerce, customizer, preview contexts).
319        if ( ! $this->passes_admin_checks() ) {
320            return null;
321        }
322
323        if ( ! self::is_enabled() ) {
324            return null;
325        }
326
327        $disconnected = $this->is_jetpack_disconnected();
328
329        if ( $this->is_block_editor() ) {
330            return $disconnected ? 'gutenberg-disconnected' : 'gutenberg';
331        }
332
333        return $disconnected ? 'wp-admin-disconnected' : 'wp-admin';
334    }
335
336    /**
337     * Returns true if the Agents Manager should be loaded in the current context.
338     *
339     * @return bool
340     */
341    public static function is_enabled() {
342        // CIAB: Agents Manager is the default AI experience â€” enabled unless explicitly
343        // disabled via filter (e.g. for debugging or gradual rollout).
344        if ( self::is_ciab_environment() ) {
345            /**
346             * Filter whether Agents Manager is enabled in CIAB (Next Admin) environments.
347             *
348             * @param bool $enabled Whether Agents Manager should load. Default true.
349             */
350            return apply_filters( 'agents_manager_enabled_in_ciab', true );
351        }
352
353        // Full unified experience: Agents Manager with support guides, Help Center takeover, etc.
354        if ( apply_filters( 'agents_manager_use_unified_experience', false ) ) {
355            return true;
356        }
357
358        // Block editor only: Agents Manager replaces Big Sky's native UI. Hooked by Big Sky.
359        if ( self::is_block_editor() && apply_filters( 'agents_manager_enabled_in_block_editor', false ) ) {
360            return true;
361        }
362
363        return false;
364    }
365
366    /**
367     * Returns true if the current wp-admin context passes all exclusion checks.
368     *
369     * Excludes WooCommerce Admin home, customizer preview, Gutenberg asset requests,
370     * and preview query param contexts.
371     *
372     * @return bool
373     */
374    private function passes_admin_checks() {
375        // Don't load on WooCommerce Admin home page to avoid UI conflicts.
376        global $current_screen;
377        if ( $current_screen && $current_screen->id === 'woocommerce_page_wc-admin' ) {
378            return false;
379        }
380
381        // Don't load in customizer preview iframe.
382        if ( is_customize_preview() ) {
383            return false;
384        }
385
386        // Don't load during Gutenberg asset requests or preview contexts.
387        $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
388        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
389        $is_preview = isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) );
390        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
391        $is_preview_overlay = isset( $_GET['preview_overlay'] );
392        if ( str_contains( $request_uri, 'wp-content/plugins/gutenberg-core' ) || $is_preview || $is_preview_overlay ) {
393            return false;
394        }
395
396        return true;
397    }
398
399    /**
400     * Enqueue Agents Manager script based on context.
401     *
402     * @param string $variant The variant of the asset file to get.
403     */
404    private function enqueue_script( $variant ) {
405        $cache_key  = 'agents-manager-asset-' . $variant . '.asset.json';
406        $asset_file = get_transient( $cache_key );
407
408        if ( ! $asset_file ) {
409            $asset_file = self::get_assets_json( 'widgets.wp.com/agents-manager/agents-manager-' . $variant . '.asset.json' );
410            if ( ! $asset_file ) {
411                return;
412            }
413            set_transient( $cache_key, $asset_file, HOUR_IN_SECONDS );
414        }
415
416        // When the request is dev mode, use a random cache buster as the version for easier debugging.
417        $version = self::is_dev_mode() ? wp_rand() : $asset_file['version'];
418
419        $script_dependencies = $asset_file['dependencies'] ?? array();
420
421        wp_enqueue_script(
422            'agents-manager',
423            'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . '.min.js',
424            $script_dependencies,
425            $version,
426            true
427        );
428
429        if ( 'gutenberg-disconnected' !== $variant && 'ciab-disconnected' !== $variant ) {
430            wp_enqueue_style(
431                'agents-manager-style',
432                'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . ( is_rtl() ? '.rtl.css' : '.css' ),
433                array(),
434                $version
435            );
436        }
437    }
438
439    /**
440     * Get the asset via file-system on wpcom and via network on Atomic sites.
441     *
442     * @param string $filepath The URL to download the asset file from.
443     * @return array|null The asset file data or null on failure.
444     */
445    private static function get_assets_json( $filepath ) {
446        $accessible_directly = file_exists( ABSPATH . $filepath );
447
448        if ( $accessible_directly ) {
449            $file_contents = file_get_contents( ABSPATH . $filepath );
450
451            if ( false === $file_contents ) {
452                return null;
453            }
454
455            return json_decode( $file_contents, true );
456        }
457
458        $request = wp_remote_get( 'https://' . $filepath );
459
460        if ( is_wp_error( $request ) ) {
461            return null;
462        }
463
464        $response_code = wp_remote_retrieve_response_code( $request );
465        if ( 200 !== $response_code ) {
466            return null;
467        }
468
469        $content_type = wp_remote_retrieve_header( $request, 'content-type' );
470        if ( is_string( $content_type ) && false === strpos( $content_type, 'json' ) ) {
471            return null;
472        }
473
474        $body = wp_remote_retrieve_body( $request );
475        if ( '' === $body ) {
476            return null;
477        }
478
479        $decoded = json_decode( $body, true );
480        if ( json_last_error() !== JSON_ERROR_NONE ) {
481            return null;
482        }
483
484        return $decoded;
485    }
486
487    /**
488     * Update the calypso preferences.
489     *
490     * @param \stdClass $preferences The preferences.
491     *
492     * @return \stdClass The preferences.
493     */
494    public function calypso_preferences_update( $preferences ) {
495        // Check if agents_manager_router_history exists and is a valid array structure
496        if ( ! isset( $preferences->agents_manager_router_history ) ||
497            ! is_array( $preferences->agents_manager_router_history ) ) {
498            return $preferences;
499        }
500
501        $router_history = $preferences->agents_manager_router_history;
502
503        // Check if entries exist and is an array
504        if ( ! isset( $router_history['entries'] ) ||
505            ! is_array( $router_history['entries'] ) ) {
506            return $preferences;
507        }
508
509        $entries = $router_history['entries'];
510
511        // Limit entries to 50 to prevent spamming entries in the router history.
512        if ( count( $entries ) > 50 ) {
513            // Keep only the last 49 entries and add the root entry at the beginning.
514            $entries = array_slice( $entries, -49 );
515            // Keep the start at root so the back button always works.
516            array_unshift(
517                $entries,
518                array(
519                    'pathname' => '/',
520                    'search'   => '',
521                    'hash'     => '',
522                    'key'      => 'default',
523                    'state'    => null,
524                )
525            );
526
527            // Update the preferences object directly
528            $preferences->agents_manager_router_history['entries'] = $entries;
529            $preferences->agents_manager_router_history['index']   = 49;
530        }
531
532        return $preferences;
533    }
534
535    /**
536     * Creates instance.
537     *
538     * @return void
539     */
540    public static function init() {
541        if ( self::$instance === null ) {
542            self::$instance = new self();
543        }
544    }
545
546    /**
547     * Returns whether the current request is coming from the A8C proxy.
548     *
549     * @return bool
550     */
551    private static function is_proxied() {
552        // On Simple sites, use the wpcom function if available.
553        if ( function_exists( 'wpcom_is_proxied_request' ) ) {
554            return wpcom_is_proxied_request();
555        }
556
557        // On WoA/Garden sites, check server variable or constant.
558        return isset( $_SERVER['A8C_PROXIED_REQUEST'] )
559            ? (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) )
560            : defined( 'A8C_PROXIED_REQUEST' ) && A8C_PROXIED_REQUEST;
561    }
562
563    /**
564     * Enables "Development" features that should be accessible only for admins.
565     */
566    private static function is_dev_mode() {
567        // Known local environments.
568        $domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
569        if (
570            $domain === 'localhost' ||
571            '.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
572            '.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
573        ) {
574            return true;
575        }
576
577        // A8C development.
578        if ( self::is_proxied() ) {
579            return true;
580        }
581
582        if ( defined( 'AT_PROXIED_REQUEST' ) && AT_PROXIED_REQUEST && defined( 'ATOMIC_CLIENT_ID' ) ) {
583            switch ( ATOMIC_CLIENT_ID ) {
584                case 1:
585                case 2:
586                case 3: // Pressable
587                case 32:
588                case 118: // Commerce garden client (ciab)
589                    return true;
590            }
591        }
592
593        return false;
594    }
595
596    /**
597     * Register the Agents Manager endpoints.
598     */
599    public function register_rest_api() {
600        require_once __DIR__ . '/class-wp-rest-agents-manager-persisted-open-state.php';
601        $controller = new WP_REST_Agents_Manager_Persisted_Open_State();
602        $controller->register_rest_route();
603    }
604
605    /**
606     * Determine if user should see unified experience.
607     *
608     * @param bool $use_unified_experience Whether to use unified experience.
609     * @return bool
610     */
611    public function should_use_unified_experience( $use_unified_experience = false ) {
612        // Early return for non-proxied/dev mode requests.
613        // This feature is currently only available to Automattic employees testing via proxy.
614        if ( ! self::is_dev_mode() ) {
615            return false;
616        }
617
618        $user_id = get_current_user_id();
619
620        if ( ! $user_id ) {
621            return false;
622        }
623
624        $is_simple_site = ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple();
625        if ( $is_simple_site ) {
626            // On Simple sites, evaluate locally.
627            // Check Automattician and opt-in setting.
628            $is_automattician = function_exists( '\is_automattician' ) && \is_automattician( $user_id );
629            if ( $is_automattician && $this->has_unified_chat_opt_in_enabled( $user_id ) ) {
630                return true;
631            }
632        }
633
634        // On WoA and Garden sites, delegate to wpcom via the /agents-manager/state endpoint.
635        // This avoids duplicating rollout logic and handles cases where
636        // wpcom-specific functions (like get_user_attribute) aren't available.
637        if ( $this->fetch_unified_experience_preference() ) {
638            return true;
639        }
640
641        // Default to false, for now.
642        // 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.
643        return $use_unified_experience;
644    }
645
646    /**
647     * Check if user has enabled unified chat opt-in in their Automattician options.
648     *
649     * This checks the unified_ai_chat calypso preference set via the wpcom profile settings.
650     * Only used on Simple sites where get_user_attribute is available.
651     *
652     * @param int $user_id User ID.
653     *
654     * @return bool
655     */
656    private function has_unified_chat_opt_in_enabled( $user_id ) {
657        if ( ! function_exists( '\get_user_attribute' ) ) {
658            return false;
659        }
660
661        $calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
662        return ! empty( $calypso_prefs['unified_ai_chat'] );
663    }
664
665    /**
666     * Fetch unified experience preference from wpcom via Jetpack Connection.
667     *
668     * Used on Atomic sites to delegate the decision to wpcom, which has
669     * access to user attributes and can evaluate the rollout logic.
670     *
671     * Calls /agents-manager/state endpoint which is accessible via Jetpack user tokens.
672     *
673     * @return bool Whether user should see unified experience.
674     */
675    private function fetch_unified_experience_preference() {
676        $user_id = get_current_user_id();
677        if ( ! $user_id ) {
678            return false;
679        }
680
681        // Check transient cache first (per-user cache).
682        $cache_key     = 'unified-experience-' . $user_id;
683        $cached_result = get_transient( $cache_key );
684        if ( false !== $cached_result ) {
685            return (bool) $cached_result;
686        }
687
688        // Check if user is connected before making API call.
689        if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
690            return false;
691        }
692
693        // Call dedicated agents-manager/state endpoint.
694        $wpcom_request = \Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user(
695            '/agents-manager/state?key=unified_ai_chat',
696            '2',
697            array( 'method' => 'GET' )
698        );
699
700        if ( is_wp_error( $wpcom_request ) ) {
701            // Cache failures too to avoid hammering the API.
702            set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
703            return false;
704        }
705
706        $response_code = wp_remote_retrieve_response_code( $wpcom_request );
707        if ( 200 !== $response_code ) {
708            set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
709            return false;
710        }
711
712        $body         = wp_remote_retrieve_body( $wpcom_request );
713        $decoded_body = json_decode( $body, true );
714
715        // The response is { "unified_ai_chat": true/false } when using key param.
716        $result = is_array( $decoded_body ) && ! empty( $decoded_body['unified_ai_chat'] );
717
718        // Cache for 1 minute.
719        set_transient( $cache_key, $result ? 1 : 0, MINUTE_IN_SECONDS );
720
721        return $result;
722    }
723
724    /**
725     * Returns true if the current request is on the frontend and the user can edit posts.
726     *
727     * Mirrors Help_Center::is_loading_on_frontend().
728     *
729     * @return bool True if loading on the frontend for an eligible user.
730     */
731    private function is_loading_on_frontend() {
732        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
733        if ( isset( $_GET['na_site_preview'] ) || isset( $_GET['preview_overlay'] ) ) {
734            return false;
735        }
736
737        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
738        if ( isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) ) ) {
739            return false;
740        }
741
742        $can_edit_posts = current_user_can( 'edit_posts' ) && is_user_member_of_blog();
743        return ! is_admin() && ! $this->is_block_editor() && $can_edit_posts;
744    }
745
746    /**
747     * Returns true if the current screen is the block editor.
748     *
749     * @return bool True if the current screen is the block editor.
750     */
751    private static function is_block_editor() {
752        if ( ! function_exists( 'get_current_screen' ) ) {
753            return false;
754        }
755
756        $current_screen = get_current_screen();
757        // The widgets screen has the block editor but no Gutenberg top bar.
758        return $current_screen && $current_screen->is_block_editor() && $current_screen->id !== 'widgets';
759    }
760
761    /**
762     * Check if current environment is CIAB (Commerce in a Box) / Next Admin.
763     *
764     * Uses the same detection method as Help Center: checks if next_admin_init has fired.
765     *
766     * @return bool True if CIAB/Next Admin environment.
767     */
768    private static function is_ciab_environment() {
769        return (bool) did_action( 'next_admin_init' );
770    }
771
772    /**
773     * Returns true if the current user is NOT connected through Jetpack.
774     *
775     * Mirrors the logic from Help_Center::is_jetpack_disconnected().
776     *
777     * @return bool True if the site uses Jetpack but the current user is not connected.
778     */
779    private function is_jetpack_disconnected() {
780        $user_id = get_current_user_id();
781        $blog_id = get_current_blog_id();
782
783        if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
784            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
785        }
786
787        if ( true === apply_filters( 'is_jetpack_site', false, $blog_id ) ) {
788            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
789        }
790
791        return false;
792    }
793
794    /**
795     * Get current user data for the agents manager.
796     *
797     * Mirrors the user data structure from Help Center's helpCenterData.
798     *
799     * @return array|null User data array or null if not logged in.
800     */
801    private function get_current_user_data() {
802        $user_id = get_current_user_id();
803        if ( ! $user_id ) {
804            return null;
805        }
806
807        $user_data = get_userdata( $user_id );
808        if ( ! $user_data ) {
809            return null;
810        }
811
812        $user_email = $user_data->user_email;
813
814        // Use wpcom_get_avatar_url on Simple sites, fall back to get_avatar_url elsewhere.
815        if ( function_exists( 'wpcom_get_avatar_url' ) ) {
816            $avatar_url = wpcom_get_avatar_url( $user_email, 64, '', true )[0];
817        } else {
818            $avatar_url = get_avatar_url( $user_id );
819        }
820
821        return array(
822            'ID'           => $user_id,
823            'username'     => $user_data->user_login,
824            'display_name' => $user_data->display_name,
825            'avatar_URL'   => $avatar_url,
826            'email'        => $user_email,
827        );
828    }
829
830    /**
831     * Get current site data for the agents manager.
832     *
833     * Returns minimal site data needed by AgentsManager (ID and domain only).
834     * Uses jetpack_options['id'] on Atomic sites for the wpcom blog ID.
835     *
836     * @return array Site data with ID and domain.
837     */
838    private function get_current_site() {
839        /*
840         * Atomic sites have the WP.com blog ID stored as a Jetpack option.
841         * This code deliberately doesn't use `Jetpack_Options::get_option`
842         * so it works even when Jetpack has not been loaded.
843         */
844        $jetpack_options = get_option( 'jetpack_options' );
845        if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
846            $site_id = (int) $jetpack_options['id'];
847        } else {
848            $site_id = get_current_blog_id();
849        }
850
851        return array(
852            'ID'     => $site_id,
853            'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
854        );
855    }
856}
857
858add_action( 'init', array( __NAMESPACE__ . '\Agents_Manager', 'init' ) );