﻿id	summary	reporter	owner	description	type	status	priority	milestone	component	version	severity	resolution	keywords	cc	focuses
65782	AI Client: the wp_ai_client_prevent_prompt filter gets a shallow clone and can change the prompt	bejignesh	gziolo	"`WP_AI_Client_Prompt_Builder::__call()` hands a clone of itself to the `wp_ai_client_prevent_prompt` filter, and the hook doc calls it read only:

{{{#!php
/**
 * @param bool                        $prevent Whether to prevent the prompt. Default false.
 * @param WP_AI_Client_Prompt_Builder $builder A clone of the prompt builder instance (read-only).
 */
$prevent = (bool) apply_filters( 'wp_ai_client_prevent_prompt', false, clone $this );
}}}

The class does not define `__clone()`, so that is a shallow copy and the clone still points at the same wrapped `PromptBuilder`. The wrapped builder mutates itself rather than returning new instances:

{{{#!php
public function withText(string $text): self
{
	$part = new MessagePart($text);
	$this->appendPartToMessages($part);
	return $this;
}
}}}

So whatever a filter callback does to the clone lands on the original, and the prompt that is then sent to the provider is not the prompt the caller built.

== Steps to reproduce ==

{{{#!php
add_filter(
	'wp_ai_client_prevent_prompt',
	static function ( $prevent, $builder ) {
		$builder->with_text( 'Added by the filter' );
		return $prevent;
	},
	10,
	2
);

$builder = wp_ai_client_prompt( 'Original prompt' );
$builder->is_supported();

// The prompt is now 'Original promptAdded by the filter'.
}}}

This runs on every call that reaches the filter, so support checks and generating methods both.

== Expected ==

A filter receiving the clone cannot change the prompt that gets generated, which is what the hook doc promises.

== Patch ==

Add `__clone()` to `WP_AI_Client_Prompt_Builder` so the wrapped builder is cloned as well. The bundled client already implements `PromptBuilder::__clone()` to deep clone its messages, model config and request options. It was written for exactly this, it just never ran because only the WordPress wrapper was being cloned.

Any stored `WP_Error` is copied too. Nothing today clones the wrapper while it holds an error, since the filter is only reached when the error is still null, but `__clone()` is a magic method any caller can trigger and leaving half the state shared would be the same bug in a different place.

The existing test `test_prevent_prompt_filter_receives_cloned_builder_instance()` only checks that the outer object differs, which is why this went unnoticed. It still passes."	defect (bug)	closed	normal	7.2	AI	7.0	normal	fixed	has-patch has-unit-tests		
