Skip to content

Commit 57b8740

Browse files
committed
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. git-svn-id: https://develop.svn.wordpress.org/trunk@63299 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 9120bb6 commit 57b8740

2 files changed

Lines changed: 86 additions & 0 deletions

File tree

‎src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,23 @@ public function __construct( ProviderRegistry $registry, $prompt = null ) {
225225
);
226226
}
227227

228+
/**
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+
228245
/**
229246
* Registers WordPress abilities as function declarations for the AI model.
230247
*

‎tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2613,6 +2613,75 @@ static function ( $prevent, $builder ) use ( &$captured_builder ) {
26132613
$this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $captured_builder );
26142614
}
26152615

2616+
/**
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+
26162685
/**
26172686
* Tests that once in error state, subsequent fluent calls return the same instance.
26182687
*

0 commit comments

Comments
 (0)