diff --git a/src/Builders/MessageBuilder.php b/src/Builders/MessageBuilder.php index b27dec16..6840c203 100644 --- a/src/Builders/MessageBuilder.php +++ b/src/Builders/MessageBuilder.php @@ -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. * diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index f912d029..3b8e47d7 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -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. * diff --git a/src/Operations/DTO/GenerativeAiOperation.php b/src/Operations/DTO/GenerativeAiOperation.php index a82dc268..78249823 100644 --- a/src/Operations/DTO/GenerativeAiOperation.php +++ b/src/Operations/DTO/GenerativeAiOperation.php @@ -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} * diff --git a/src/Providers/DTO/ProviderModelsMetadata.php b/src/Providers/DTO/ProviderModelsMetadata.php index 4d67b962..9e3b0f9c 100644 --- a/src/Providers/DTO/ProviderModelsMetadata.php +++ b/src/Providers/DTO/ProviderModelsMetadata.php @@ -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. * diff --git a/src/Providers/Http/DTO/Request.php b/src/Providers/Http/DTO/Request.php index e656991a..4dafd3de 100644 --- a/src/Providers/Http/DTO/Request.php +++ b/src/Providers/Http/DTO/Request.php @@ -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. * diff --git a/src/Providers/Http/DTO/Response.php b/src/Providers/Http/DTO/Response.php index c51c9de6..623ab770 100644 --- a/src/Providers/Http/DTO/Response.php +++ b/src/Providers/Http/DTO/Response.php @@ -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. * diff --git a/src/Providers/Models/DTO/ModelConfig.php b/src/Providers/Models/DTO/ModelConfig.php index 0c556095..ce45993c 100644 --- a/src/Providers/Models/DTO/ModelConfig.php +++ b/src/Providers/Models/DTO/ModelConfig.php @@ -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. * diff --git a/tests/unit/Builders/MessageBuilderTest.php b/tests/unit/Builders/MessageBuilderTest.php index 18617729..fcdfc065 100644 --- a/tests/unit/Builders/MessageBuilderTest.php +++ b/tests/unit/Builders/MessageBuilderTest.php @@ -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()); + } } diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index ac839857..47950ccd 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -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)); + } } diff --git a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php index 6b281ccf..25a28f36 100644 --- a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php +++ b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php @@ -544,4 +544,65 @@ public function testImplementsWithArrayTransformationInterface(): void ); $this->assertImplementsArrayTransformation($operation); } + + /** + * Tests that cloning creates an independent result reference. + * + * @return void + */ + public function testCloneCreatesDifferentResultReference(): void + { + $modelMessage = new ModelMessage([ + new MessagePart('Test content') + ]); + $candidate = new Candidate( + $modelMessage, + FinishReasonEnum::stop(), + 25 + ); + $tokenUsage = new TokenUsage(10, 25, 35); + $result = new GenerativeAiResult( + 'result_clone_test', + [$candidate], + $tokenUsage, + $this->createTestProviderMetadata(), + $this->createTestModelMetadata() + ); + + $original = new GenerativeAiOperation( + 'op_clone_test', + OperationStateEnum::succeeded(), + $result + ); + + $cloned = clone $original; + + // Result should be different instances + $this->assertNotSame($original->getResult(), $cloned->getResult()); + + // But values should be equivalent + $this->assertEquals( + $original->getResult()->getId(), + $cloned->getResult()->getId() + ); + } + + /** + * Tests that cloning works correctly when result is null. + * + * @return void + */ + public function testCloneWorksWithNullResult(): void + { + $original = new GenerativeAiOperation( + 'op_no_result', + OperationStateEnum::processing() + ); + + $cloned = clone $original; + + $this->assertNull($cloned->getResult()); + $this->assertEquals($original->getId(), $cloned->getId()); + $this->assertEquals($original->getState()->value, $cloned->getState()->value); + } } diff --git a/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php b/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php index cdbd5bdc..e95196fd 100644 --- a/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php +++ b/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php @@ -371,4 +371,78 @@ public function testImplementsCorrectInterfaces(): void $metadata ); } + + /** + * Tests that cloning creates independent provider reference. + * + * @return void + */ + public function testCloneCreatesDifferentProviderReference(): void + { + $original = new ProviderModelsMetadata( + $this->createProviderMetadata(), + [] + ); + + $cloned = clone $original; + + // Should be different instances + $this->assertNotSame($original->getProvider(), $cloned->getProvider()); + + // But values should be equivalent + $this->assertEquals( + $original->getProvider()->getId(), + $cloned->getProvider()->getId() + ); + } + + /** + * Tests that cloning creates independent models array references. + * + * @return void + */ + public function testCloneCreatesDifferentModelsReferences(): void + { + $original = new ProviderModelsMetadata( + $this->createProviderMetadata(), + [ + $this->createModelMetadata('model-1', 'Model 1'), + $this->createModelMetadata('model-2', 'Model 2'), + ] + ); + + $cloned = clone $original; + + $originalModels = $original->getModels(); + $clonedModels = $cloned->getModels(); + + // Should have same count + $this->assertCount(2, $clonedModels); + + // Model instances should be different + $this->assertNotSame($originalModels[0], $clonedModels[0]); + $this->assertNotSame($originalModels[1], $clonedModels[1]); + + // But values should be equivalent + $this->assertEquals($originalModels[0]->getId(), $clonedModels[0]->getId()); + $this->assertEquals($originalModels[1]->getId(), $clonedModels[1]->getId()); + } + + /** + * Tests that cloning works correctly with empty models array. + * + * @return void + */ + public function testCloneWorksWithEmptyModels(): void + { + $original = new ProviderModelsMetadata( + $this->createProviderMetadata(), + [] + ); + + $cloned = clone $original; + + $this->assertCount(0, $cloned->getModels()); + $this->assertNotSame($original->getProvider(), $cloned->getProvider()); + } } diff --git a/tests/unit/Providers/Http/DTO/RequestTest.php b/tests/unit/Providers/Http/DTO/RequestTest.php index d004678c..d682327f 100644 --- a/tests/unit/Providers/Http/DTO/RequestTest.php +++ b/tests/unit/Providers/Http/DTO/RequestTest.php @@ -277,4 +277,79 @@ public function testConstructorThrowsWhenUriIsEmpty(): void $this->expectException(AiInvalidArgumentException::class); new Request(HttpMethodEnum::get(), ''); } + + /** + * Tests that cloning creates independent headers reference. + * + * @return void + */ + public function testCloneCreatesDifferentHeadersReference(): void + { + $original = new Request( + HttpMethodEnum::get(), + 'https://example.com', + ['X-Test' => 'value'] + ); + + $cloned = clone $original; + + // Modify cloned headers + $clonedWithNewHeader = $cloned->withHeader('X-New', 'new-value'); + + // Original should not have the new header + $this->assertFalse($original->hasHeader('X-New')); + $this->assertTrue($clonedWithNewHeader->hasHeader('X-New')); + + // Both should have original header + $this->assertTrue($original->hasHeader('X-Test')); + $this->assertTrue($cloned->hasHeader('X-Test')); + } + + /** + * Tests that cloning creates independent request options reference. + * + * @return void + */ + public function testCloneCreatesDifferentOptionsReference(): void + { + $options = new RequestOptions(); + $options->setTimeout(5.0); + + $original = new Request( + HttpMethodEnum::post(), + 'https://example.com', + [], + null, + $options + ); + + $cloned = clone $original; + + // Should be different instances + $this->assertNotSame($original->getOptions(), $cloned->getOptions()); + + // But values should be equivalent + $this->assertEquals( + $original->getOptions()->getTimeout(), + $cloned->getOptions()->getTimeout() + ); + } + + /** + * Tests that cloning works correctly when options are null. + * + * @return void + */ + public function testCloneWorksWithNullOptions(): void + { + $original = new Request( + HttpMethodEnum::get(), + 'https://example.com' + ); + + $cloned = clone $original; + + $this->assertNull($cloned->getOptions()); + $this->assertEquals($original->getUri(), $cloned->getUri()); + } } diff --git a/tests/unit/Providers/Http/DTO/ResponseTest.php b/tests/unit/Providers/Http/DTO/ResponseTest.php new file mode 100644 index 00000000..c5dd7815 --- /dev/null +++ b/tests/unit/Providers/Http/DTO/ResponseTest.php @@ -0,0 +1,79 @@ + 'application/json', 'X-Test' => 'value'], + '{"key": "value"}' + ); + + $cloned = clone $original; + + // Headers should be equivalent but independent + $this->assertEquals($original->getHeaders(), $cloned->getHeaders()); + $this->assertTrue($cloned->hasHeader('Content-Type')); + $this->assertTrue($cloned->hasHeader('X-Test')); + + // Verify other properties are preserved + $this->assertEquals($original->getStatusCode(), $cloned->getStatusCode()); + $this->assertEquals($original->getBody(), $cloned->getBody()); + } + + /** + * Tests that cloning preserves all response data. + * + * @return void + */ + public function testClonePreservesResponseData(): void + { + $original = new Response( + 201, + ['Location' => 'https://example.com/resource/1'], + 'Created' + ); + + $cloned = clone $original; + + $this->assertEquals(201, $cloned->getStatusCode()); + $this->assertEquals(['https://example.com/resource/1'], $cloned->getHeader('Location')); + $this->assertEquals('Created', $cloned->getBody()); + $this->assertTrue($cloned->isSuccessful()); + } + + /** + * Tests that cloning works correctly with null body. + * + * @return void + */ + public function testCloneWorksWithNullBody(): void + { + $original = new Response( + 204, + ['X-Request-Id' => 'abc123'] + ); + + $cloned = clone $original; + + $this->assertNull($cloned->getBody()); + $this->assertEquals(204, $cloned->getStatusCode()); + $this->assertTrue($cloned->hasHeader('X-Request-Id')); + } +} diff --git a/tests/unit/Providers/Models/DTO/ModelConfigTest.php b/tests/unit/Providers/Models/DTO/ModelConfigTest.php index 961556a8..dd0c31c3 100644 --- a/tests/unit/Providers/Models/DTO/ModelConfigTest.php +++ b/tests/unit/Providers/Models/DTO/ModelConfigTest.php @@ -734,4 +734,74 @@ public function testMediaOrientationAspectRatioCompatibilityPortrait(): void $this->expectExceptionMessage('The aspect ratio "3:2" is not compatible with the portrait orientation.'); $config->setOutputMediaAspectRatio('3:2'); } + + /** + * Tests that cloning creates independent function declarations array. + * + * @return void + */ + public function testCloneCreatesDifferentFunctionDeclarationsReferences(): void + { + $original = new ModelConfig(); + $declaration = $this->createSampleFunctionDeclaration(); + $original->setFunctionDeclarations([$declaration]); + + $cloned = clone $original; + + // The cloned config should have different FunctionDeclaration instances + $originalDeclarations = $original->getFunctionDeclarations(); + $clonedDeclarations = $cloned->getFunctionDeclarations(); + + $this->assertNotNull($originalDeclarations); + $this->assertNotNull($clonedDeclarations); + $this->assertCount(1, $clonedDeclarations); + $this->assertNotSame($originalDeclarations[0], $clonedDeclarations[0]); + + // But the values should be equivalent + $this->assertEquals( + $originalDeclarations[0]->getName(), + $clonedDeclarations[0]->getName() + ); + } + + /** + * Tests that cloning creates an independent web search reference. + * + * @return void + */ + public function testCloneCreatesDifferentWebSearchReference(): void + { + $original = new ModelConfig(); + $webSearch = $this->createSampleWebSearch(); + $original->setWebSearch($webSearch); + + $cloned = clone $original; + + // The cloned config should have a different WebSearch instance + $this->assertNotSame($original->getWebSearch(), $cloned->getWebSearch()); + + // But the values should be equivalent + $this->assertEquals( + $original->getWebSearch()->getAllowedDomains(), + $cloned->getWebSearch()->getAllowedDomains() + ); + } + + /** + * Tests that cloning works correctly when optional objects are null. + * + * @return void + */ + public function testCloneWorksWithNullOptionalObjects(): void + { + $original = new ModelConfig(); + $original->setTemperature(0.7); + // Don't set functionDeclarations or webSearch + + $cloned = clone $original; + + $this->assertNull($cloned->getFunctionDeclarations()); + $this->assertNull($cloned->getWebSearch()); + $this->assertEquals(0.7, $cloned->getTemperature()); + } }