Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/Builders/MessageBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ public function __construct($input = null, ?MessageRoleEnum $role = null)
}
}

/**
* Creates a deep clone of this builder.
*
* Clones all MessagePart objects in the parts array to ensure
* the cloned builder is independent of the original.
*
* @since 0.4.2
*/
public function __clone()
{
// Deep clone parts array (MessagePart has __clone)
$clonedParts = [];
foreach ($this->parts as $part) {
$clonedParts[] = clone $part;
}
$this->parts = $clonedParts;

// Note: $role is an enum value object and can be safely shared
}

/**
* Sets the role of the message sender.
*
Expand Down
30 changes: 30 additions & 0 deletions src/Builders/PromptBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,36 @@ public function __construct(
$this->messages[] = $userMessage;
}

/**
* Creates a deep clone of this builder.
*
* Clones all mutable state including messages, model configuration, and request options.
* Service objects (registry, model, event dispatcher) are intentionally NOT cloned
* as they are shared dependencies.
*
* @since 0.4.2
*/
public function __clone()
{
// Deep clone messages array (Message has __clone)
$clonedMessages = [];
foreach ($this->messages as $message) {
$clonedMessages[] = clone $message;
}
$this->messages = $clonedMessages;

// Clone model config (ModelConfig has __clone)
$this->modelConfig = clone $this->modelConfig;

// Clone request options if set (contains only primitives)
if ($this->requestOptions !== null) {
$this->requestOptions = clone $this->requestOptions;
}

// Note: $registry, $model, and $eventDispatcher are service objects
// and are intentionally NOT cloned - they should be shared references.
}

/**
* Adds text to the current message.
*
Expand Down
19 changes: 19 additions & 0 deletions src/Operations/DTO/GenerativeAiOperation.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ public function __construct(string $id, OperationStateEnum $state, ?GenerativeAi
$this->result = $result;
}

/**
* Creates a deep clone of this operation.
*
* Clones the result object if present to ensure the cloned
* operation is independent of the original.
* The state enum is immutable and can be safely shared.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone the result if present (GenerativeAiResult has __clone)
if ($this->result !== null) {
$this->result = clone $this->result;
}

// Note: $state is an immutable enum and can be safely shared
}

/**
* {@inheritDoc}
*
Expand Down
21 changes: 21 additions & 0 deletions src/Providers/DTO/ProviderModelsMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,27 @@ public function __construct(ProviderMetadata $provider, array $models)
$this->models = $models;
}

/**
* Creates a deep clone of this metadata.
*
* Clones the provider metadata and all model metadata objects
* to ensure the cloned instance is independent of the original.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone provider metadata
$this->provider = clone $this->provider;

// Deep clone models array (ModelMetadata has __clone)
$clonedModels = [];
foreach ($this->models as $model) {
$clonedModels[] = clone $model;
}
$this->models = $clonedModels;
}

/**
* Gets the provider metadata.
*
Expand Down
22 changes: 22 additions & 0 deletions src/Providers/Http/DTO/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,28 @@ public function __construct(
$this->options = $options;
}

/**
* Creates a deep clone of this request.
*
* Clones the headers collection and request options to ensure
* the cloned request is independent of the original.
* The HTTP method enum is immutable and can be safely shared.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone headers collection
$this->headers = clone $this->headers;

// Clone request options if present (contains only primitives)
if ($this->options !== null) {
$this->options = clone $this->options;
}

// Note: $method is an immutable enum and can be safely shared
}

/**
* Gets the HTTP method.
*
Expand Down
14 changes: 14 additions & 0 deletions src/Providers/Http/DTO/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ public function __construct(int $statusCode, array $headers, ?string $body = nul
$this->body = $body;
}

/**
* Creates a deep clone of this response.
*
* Clones the headers collection to ensure the cloned
* response is independent of the original.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone headers collection
$this->headers = clone $this->headers;
}

/**
* Gets the HTTP status code.
*
Expand Down
30 changes: 30 additions & 0 deletions src/Providers/Models/DTO/ModelConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,36 @@ class ModelConfig extends AbstractDataTransferObject
*/
protected array $customOptions = [];

/**
* Creates a deep clone of this configuration.
*
* Clones nested objects (functionDeclarations, webSearch) to ensure
* the cloned configuration is independent of the original.
* Enum value objects (outputModalities, outputFileType, outputMediaOrientation)
* are intentionally shared as they are immutable.
*
* @since 0.4.2
*/
public function __clone()
{
// Deep clone function declarations if set
if ($this->functionDeclarations !== null) {
$clonedDeclarations = [];
foreach ($this->functionDeclarations as $declaration) {
$clonedDeclarations[] = clone $declaration;
}
$this->functionDeclarations = $clonedDeclarations;
}

// Clone web search if set
if ($this->webSearch !== null) {
$this->webSearch = clone $this->webSearch;
}

// Note: Enum value objects (outputModalities, outputFileType, outputMediaOrientation)
// are immutable and can be safely shared.
}

/**
* Sets the output modalities.
*
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/Builders/MessageBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -527,4 +527,70 @@ public function testConstructorWithNullInputCreatesEmptyBuilder(): void
$this->assertCount(1, $parts);
$this->assertEquals('Added later', $parts[0]->getText());
}

/**
* Tests that cloning creates independent message part references.
*
* @return void
*/
public function testCloneCreatesDifferentPartsReferences(): void
{
$original = new MessageBuilder();
$original->withText('Hello')
->withText('World')
->usingUserRole();

$cloned = clone $original;

// Build both to compare the parts
$originalMessage = $original->get();
$clonedMessage = $cloned->get();

$originalParts = $originalMessage->getParts();
$clonedParts = $clonedMessage->getParts();

// Should have same count and equivalent content
$this->assertCount(2, $clonedParts);
$this->assertEquals($originalParts[0]->getText(), $clonedParts[0]->getText());
$this->assertEquals($originalParts[1]->getText(), $clonedParts[1]->getText());

// But parts should be different instances
$this->assertNotSame($originalParts[0], $clonedParts[0]);
$this->assertNotSame($originalParts[1], $clonedParts[1]);
}

/**
* Tests that cloning works correctly with empty parts.
*
* @return void
*/
public function testCloneWorksWithEmptyParts(): void
{
$original = new MessageBuilder(null, MessageRoleEnum::user());

$cloned = clone $original;

// Add content to cloned builder and verify it works
$message = $cloned->withText('Cloned content')->get();
$this->assertEquals('Cloned content', $message->getParts()[0]->getText());
}

/**
* Tests that modifications to cloned builder don't affect original.
*
* @return void
*/
public function testClonedBuilderIsIndependent(): void
{
$original = new MessageBuilder();
$original->withText('Original text')->usingUserRole();

$cloned = clone $original;
$cloned->withText('Additional cloned text');

// Original should still only have one part
$originalMessage = $original->get();
$this->assertCount(1, $originalMessage->getParts());
$this->assertEquals('Original text', $originalMessage->getParts()[0]->getText());
}
}
110 changes: 110 additions & 0 deletions tests/unit/Builders/PromptBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3495,4 +3495,114 @@ public function testIsSupportedWithNoSupport(): void

$this->assertFalse($builder->isSupported(CapabilityEnum::textGeneration()));
}

/**
* Tests that cloning creates independent message references.
*
* @return void
*/
public function testCloneCreatesDifferentMessagesReferences(): void
{
$original = new PromptBuilder($this->registry, 'First message');
$original->withText(' continued');

$cloned = clone $original;

// Add more content to the cloned builder
$cloned->withText(' and more');

// Use reflection to access the protected messages property
$originalReflection = new \ReflectionClass($original);
$messagesProperty = $originalReflection->getProperty('messages');
$messagesProperty->setAccessible(true);

$originalMessages = $messagesProperty->getValue($original);
$clonedMessages = $messagesProperty->getValue($cloned);

// Original should have 1 message, cloned should have different instances
$this->assertCount(1, $originalMessages);
$this->assertNotSame($originalMessages[0], $clonedMessages[0]);
}

/**
* Tests that cloning creates an independent model config reference.
*
* @return void
*/
public function testCloneCreatesDifferentModelConfigReference(): void
{
$original = new PromptBuilder($this->registry, 'Test prompt');
$original->usingTemperature(0.7);
$original->usingMaxTokens(100);

$cloned = clone $original;

// Modify the cloned builder's config
$cloned->usingTemperature(0.9);

// Use reflection to access the protected modelConfig property
$originalReflection = new \ReflectionClass($original);
$configProperty = $originalReflection->getProperty('modelConfig');
$configProperty->setAccessible(true);

$originalConfig = $configProperty->getValue($original);
$clonedConfig = $configProperty->getValue($cloned);

// Should be different instances
$this->assertNotSame($originalConfig, $clonedConfig);

// Original should still have 0.7, cloned should have 0.9
$this->assertEquals(0.7, $originalConfig->getTemperature());
$this->assertEquals(0.9, $clonedConfig->getTemperature());
}

/**
* Tests that cloning creates an independent request options reference.
*
* @return void
*/
public function testCloneCreatesDifferentRequestOptionsReference(): void
{
$requestOptions = new \WordPress\AiClient\Providers\Http\DTO\RequestOptions();
$requestOptions->setTimeout(30.0);

$original = new PromptBuilder($this->registry, 'Test prompt');
$original->usingRequestOptions($requestOptions);

$cloned = clone $original;

// Use reflection to access the protected requestOptions property
$originalReflection = new \ReflectionClass($original);
$optionsProperty = $originalReflection->getProperty('requestOptions');
$optionsProperty->setAccessible(true);

$originalOptions = $optionsProperty->getValue($original);
$clonedOptions = $optionsProperty->getValue($cloned);

// Should be different instances
$this->assertNotSame($originalOptions, $clonedOptions);

// But values should be equivalent
$this->assertEquals($originalOptions->getTimeout(), $clonedOptions->getTimeout());
}

/**
* Tests that cloning works correctly when request options are null.
*
* @return void
*/
public function testCloneWorksWithNullRequestOptions(): void
{
$original = new PromptBuilder($this->registry, 'Test prompt');
// Don't set request options

$cloned = clone $original;

// Use reflection to verify null request options
$originalReflection = new \ReflectionClass($cloned);
$optionsProperty = $originalReflection->getProperty('requestOptions');
$optionsProperty->setAccessible(true);

$this->assertNull($optionsProperty->getValue($cloned));
}
}
Loading