Skip to content

AI: Clone the wrapped builder when cloning WP_AI_Client_Prompt_Builder - #12801

Closed
jigneshbhavani wants to merge 4 commits into
WordPress:trunkfrom
jigneshbhavani:fix/ai-prevent-prompt-clone-shares-state
Closed

AI: Clone the wrapped builder when cloning WP_AI_Client_Prompt_Builder#12801
jigneshbhavani wants to merge 4 commits into
WordPress:trunkfrom
jigneshbhavani:fix/ai-prevent-prompt-clone-shares-state

Conversation

@jigneshbhavani

Copy link
Copy Markdown

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:

/**
 * @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 has no __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:

public function withText(string $text): self
{
	$part = new MessagePart($text);
	$this->appendPartToMessages($part);
	return $this;
}

So whatever a filter does to the clone lands on the original, and the prompt sent to the provider is not the one the caller built.

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.

Fix

Add __clone() so the wrapped builder is cloned too. The bundled client already implements PromptBuilder::__clone() to deep clone messages, model config and request options. It was written for this, it just never ran because only the wrapper was being cloned.

A stored WP_Error is copied as well. 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.

Testing

Two tests, both failing on trunk:

1) test_clone_does_not_share_the_wrapped_builder
A clone should wrap its own builder instance
Failed asserting that two variables don't reference the same object.

2) test_prevent_prompt_filter_cannot_mutate_the_original_prompt
A filter should not be able to change the prompt
-'Original prompt'
+'Original promptAdded by the filter'

Both pass with the patch. The existing test_prevent_prompt_filter_receives_cloned_builder_instance() only asserted the outer objects differ, which is why this went unnoticed, and it still passes. The full ai-client group is green, 259 tests. phpcs passes on both changed files.

Trac ticket: https://core.trac.wordpress.org/ticket/65782

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Reading through the AI client for places where the code disagrees with its own documented behaviour, which is how this turned up, and drafting this description. I confirmed the mutation by hand, checked that the bundled client already has a deep __clone(), wrote and ran the tests, checked they fail without the patch, ran phpcs, and I take responsibility for the change.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

WP_AI_Client_Prompt_Builder had no __clone(), so cloning it copied the
reference to the wrapped PromptBuilder rather than the builder itself. The
wrapped 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.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props bejignesh, gziolo.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

…lder tests.

The two tests added for #65782 called ReflectionProperty::setAccessible()
directly. PHP 8.5 deprecates that method because it has had no effect since
PHP 8.1, so both tests errored on every PHP 8.5 job.

This file already has a set_accessible() helper that only calls setAccessible()
below PHP 8.1. Route the three new call sites through it, as the rest of the
file does.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds deep-clone behavior to WP_AI_Client_Prompt_Builder so cloned instances don’t share mutable internal state, and introduces PHPUnit tests to prevent regressions around cloning and filter immutability.

Changes:

  • Implement __clone() to clone the wrapped builder (and error object when present).
  • Add tests verifying clones don’t share the wrapped builder and that prevent-prompt filters can’t mutate the original prompt.
  • Add a small test helper to extract prompt text via reflection for assertions.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php Adds clone-related regression tests and a helper to read prompt text from internal messages.
src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php Implements deep cloning of wrapped builder (and $error) to prevent shared state between clones.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php Outdated
Comment thread tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php Outdated
…hem.

Keeps the parts separate so a failure shows which part was added rather than
one run together string.

See #65782.

@gziolo gziolo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks good. I applied the patch locally: the full ai-client group passes (259 tests), both new tests fail with only the src/ change reverted, and phpcs and phpstan are clean.

One thing to update before commit: the new method says @since 7.1.0, but trunk moved to 7.2-alpha after you opened this. It should be @since 7.2.0.

…_clone()`.

Trunk moved to 7.2-alpha after the patch was written.

See #65782.
@jigneshbhavani

Copy link
Copy Markdown
Author

Good catch, corrected to @since 7.2.0 in 5c4c3bc. Confirmed trunk is on 7.2-alpha-63166-src.

ai-client group still passes at 259 tests and phpcs is clean. The sibling PR #12800 adds no @since, so nothing to change there.

@github-actions

Copy link
Copy Markdown

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 63299
GitHub commit: 57b8740

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

@github-actions github-actions Bot closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants