Make WordPress Core

Changeset 63299


Ignore:
Timestamp:
08/14/2026 11:17:51 AM (4 hours ago)
Author:
gziolo
Message:

Clone the wrapped builder when cloning WP_AI_Client_Prompt_Builder

WP_AI_Client_Prompt_Builder had no __clone(), so cloning it copied the reference to the wrapped PromptBuilder instead of the builder itself. That builder mutates its own state, so the clone handed to the wp_ai_client_prevent_prompt filter shared its messages and configuration with the original, and a filter could change the prompt that was then sent to the provider, despite the clone being documented as read-only.

Add __clone() so the wrapped builder, and any stored error, are copied too. The bundled client already implements PromptBuilder::__clone() to deep clone messages, model config and request options, it just never ran because only the wrapper was being cloned.

Props bejignesh, gziolo.
Fixes #65782.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php

    r63298 r63299  
    227227
    228228        /**
     229         * Clones the wrapped prompt builder alongside this instance.
     230         *
     231         * The wrapped builder mutates its own state, so without this a clone would
     232         * share that state with the original and any change made to one would be
     233         * visible in the other.
     234         *
     235         * @since 7.2.0
     236         */
     237        public function __clone() {
     238                $this->builder = clone $this->builder;
     239
     240                if ( null !== $this->error ) {
     241                        $this->error = clone $this->error;
     242                }
     243        }
     244
     245        /**
    229246         * Registers WordPress abilities as function declarations for the AI model.
    230247         *
  • trunk/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php

    r63298 r63299  
    26152615
    26162616        /**
     2617         * Returns the text of every message part held by a prompt builder.
     2618         *
     2619         * @param WP_AI_Client_Prompt_Builder $builder The prompt builder to read.
     2620         * @return string[] The text of each message part, in order.
     2621         */
     2622        private function get_prompt_parts( WP_AI_Client_Prompt_Builder $builder ): array {
     2623                $wrapped = new ReflectionProperty( WP_AI_Client_Prompt_Builder::class, 'builder' );
     2624                self::set_accessible( $wrapped );
     2625                $inner = $wrapped->getValue( $builder );
     2626
     2627                $messages = new ReflectionProperty( $inner, 'messages' );
     2628                self::set_accessible( $messages );
     2629
     2630                $parts = array();
     2631                foreach ( $messages->getValue( $inner ) as $message ) {
     2632                        foreach ( $message->getParts() as $part ) {
     2633                                $parts[] = (string) $part->getText();
     2634                        }
     2635                }
     2636
     2637                return $parts;
     2638        }
     2639
     2640        /**
     2641         * Tests that a clone does not share the wrapped builder with the original.
     2642         *
     2643         * @ticket 65782
     2644         */
     2645        public function test_clone_does_not_share_the_wrapped_builder() {
     2646                $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Original prompt' );
     2647                $clone   = clone $builder;
     2648
     2649                $wrapped = new ReflectionProperty( WP_AI_Client_Prompt_Builder::class, 'builder' );
     2650                self::set_accessible( $wrapped );
     2651
     2652                $this->assertNotSame(
     2653                        $wrapped->getValue( $builder ),
     2654                        $wrapped->getValue( $clone ),
     2655                        'A clone should wrap its own builder instance'
     2656                );
     2657
     2658                $clone->with_text( 'Added to the clone' );
     2659
     2660                $this->assertSame( array( 'Original prompt' ), $this->get_prompt_parts( $builder ), 'Changing the clone should not change the original' );
     2661        }
     2662
     2663        /**
     2664         * Tests that the clone passed to the prevent prompt filter cannot change the prompt.
     2665         *
     2666         * @ticket 65782
     2667         */
     2668        public function test_prevent_prompt_filter_cannot_mutate_the_original_prompt() {
     2669                add_filter(
     2670                        'wp_ai_client_prevent_prompt',
     2671                        static function ( $prevent, $builder ) {
     2672                                $builder->with_text( 'Added by the filter' );
     2673                                return $prevent;
     2674                        },
     2675                        10,
     2676                        2
     2677                );
     2678
     2679                $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), 'Original prompt' );
     2680                $builder->is_supported();
     2681
     2682                $this->assertSame( array( 'Original prompt' ), $this->get_prompt_parts( $builder ), 'A filter should not be able to change the prompt' );
     2683        }
     2684
     2685        /**
    26172686         * Tests that once in error state, subsequent fluent calls return the same instance.
    26182687         *
Note: See TracChangeset for help on using the changeset viewer.