diff --git a/.gitignore b/.gitignore index 82c305d0..32a2e42a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,13 @@ vendor/ *.DS_store .DS_store? +############ +## IDEs +############ + +.idea/ +.vscode/ + ############ ## AI Tools ############ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03654156..66e810d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -296,22 +296,20 @@ direction LR namespace AiClientNamespace { class AiClient { +prompt(string|Message|null $text = null) PromptBuilder$ - +message(?string $text) MessageBuilder$ + +message($input = null) MessageBuilder$ } } namespace AiClientNamespace.Builders { class PromptBuilder { +withText(string $text) self - +withInlineImage(string $base64Blob, string $mimeType) - +withRemoteImage(string $uri, string $mimeType) - +withImageFile(File $file) self - +withAudioFile(File $file) self - +withVideoFile(File $file) self + +withFile($file, ?string $mimeType) self +withFunctionResponse(FunctionResponse $functionResponse) self - +withMessageParts(...MessagePart $part) self + +withMessageParts(...MessagePart $parts) self +withHistory(...Message $messages) self +usingModel(ModelInterface $model) self + +usingModelConfig(ModelConfig $config) self + +usingProvider(string $providerIdOrClassName) self +usingSystemInstruction(string $systemInstruction) self +usingMaxTokens(int $maxTokens) self +usingTemperature(float $temperature) self @@ -319,46 +317,47 @@ direction LR +usingTopK(int $topK) self +usingStopSequences(...string $stopSequences) self +usingCandidateCount(int $candidateCount) self - +usingOutputMime(string $mimeType) self - +usingOutputSchema(array< string, mixed > $schema) self - +usingOutputModalities(...ModalityEnum $modalities) self + +usingFunctionDeclarations(...FunctionDeclaration $functionDeclarations) self + +usingPresencePenalty(float $presencePenalty) self + +usingFrequencyPenalty(float $frequencyPenalty) self + +usingWebSearch(WebSearch $webSearch) self + +usingTopLogprobs(?int $topLogprobs) self + +asOutputMimeType(string $mimeType) self + +asOutputSchema(array< string, mixed > $schema) self + +asOutputModalities(...ModalityEnum $modalities) self + +asOutputFileType(FileTypeEnum $fileType) self +asJsonResponse(?array< string, mixed > $schema) self - +generateResult() GenerativeAiResult - +generateOperation() GenerativeAiOperation + +generateResult(?CapabilityEnum $capability) GenerativeAiResult +generateTextResult() GenerativeAiResult - +streamGenerateTextResult() Generator< GenerativeAiResult > +generateImageResult() GenerativeAiResult - +convertTextToSpeechResult() GenerativeAiResult +generateSpeechResult() GenerativeAiResult - +generateEmbeddingsResult() EmbeddingResult - +generateTextOperation() GenerativeAiOperation - +generateImageOperation() GenerativeAiOperation - +convertTextToSpeechOperation() GenerativeAiOperation - +generateSpeechOperation() GenerativeAiOperation - +generateEmbeddingsOperation() EmbeddingOperation + +convertTextToSpeechResult() GenerativeAiResult +generateText() string +generateTexts(?int $candidateCount) string[] - +streamGenerateText() Generator< string > +generateImage() File +generateImages(?int $candidateCount) File[] +convertTextToSpeech() File +convertTextToSpeeches(?int $candidateCount) File[] +generateSpeech() File +generateSpeeches(?int $candidateCount) File[] - +generateEmbeddings() Embedding[] - +getModelRequirements() ModelRequirements - +isSupported() bool + +isSupportedForTextGeneration() bool + +isSupportedForImageGeneration() bool + +isSupportedForTextToSpeechConversion() bool + +isSupportedForVideoGeneration() bool + +isSupportedForSpeechGeneration() bool + +isSupportedForMusicGeneration() bool + +isSupportedForEmbeddingGeneration() bool } class MessageBuilder { - +usingRole(MessageRole $role) self + +usingRole(MessageRoleEnum $role) self + +usingUserRole() self + +usingModelRole() self +withText(string $text) self - +withImageFile(File $file) self - +withAudioFile(File $file) self - +withVideoFile(File $file) self + +withFile($file, ?string $mimeType) self +withFunctionCall(FunctionCall $functionCall) self +withFunctionResponse(FunctionResponse $functionResponse) self - +withMessageParts(...MessagePart $part) self + +withMessageParts(...MessagePart $parts) self +get() Message } } @@ -444,7 +443,7 @@ direction LR namespace AiClientNamespace { class AiClient { +prompt(string|Message|null $text = null) PromptBuilder$ - +message(?string $text) MessageBuilder$ + +message($input = null) MessageBuilder$ +defaultRegistry() ProviderRegistry$ +isConfigured(ProviderAvailabilityInterface $availability) bool$ +generateResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$ @@ -466,15 +465,13 @@ direction LR namespace AiClientNamespace.Builders { class PromptBuilder { +withText(string $text) self - +withInlineImage(string $base64Blob, string $mimeType) - +withRemoteImage(string $uri, string $mimeType) - +withImageFile(File $file) self - +withAudioFile(File $file) self - +withVideoFile(File $file) self + +withFile($file, ?string $mimeType) self +withFunctionResponse(FunctionResponse $functionResponse) self - +withMessageParts(...MessagePart $part) self + +withMessageParts(...MessagePart $parts) self +withHistory(...Message $messages) self +usingModel(ModelInterface $model) self + +usingModelConfig(ModelConfig $config) self + +usingProvider(string $providerIdOrClassName) self +usingSystemInstruction(string $systemInstruction) self +usingMaxTokens(int $maxTokens) self +usingTemperature(float $temperature) self @@ -482,46 +479,47 @@ direction LR +usingTopK(int $topK) self +usingStopSequences(...string $stopSequences) self +usingCandidateCount(int $candidateCount) self - +usingOutputMime(string $mimeType) self - +usingOutputSchema(array< string, mixed > $schema) self - +usingOutputModalities(...ModalityEnum $modalities) self + +usingFunctionDeclarations(...FunctionDeclaration $functionDeclarations) self + +usingPresencePenalty(float $presencePenalty) self + +usingFrequencyPenalty(float $frequencyPenalty) self + +usingWebSearch(WebSearch $webSearch) self + +usingTopLogprobs(?int $topLogprobs) self + +asOutputMimeType(string $mimeType) self + +asOutputSchema(array< string, mixed > $schema) self + +asOutputModalities(...ModalityEnum $modalities) self + +asOutputFileType(FileTypeEnum $fileType) self +asJsonResponse(?array< string, mixed > $schema) self - +generateResult() GenerativeAiResult - +generateOperation() GenerativeAiOperation + +generateResult(?CapabilityEnum $capability) GenerativeAiResult +generateTextResult() GenerativeAiResult - +streamGenerateTextResult() Generator< GenerativeAiResult > +generateImageResult() GenerativeAiResult - +convertTextToSpeechResult() GenerativeAiResult +generateSpeechResult() GenerativeAiResult - +generateEmbeddingsResult() EmbeddingResult - +generateTextOperation() GenerativeAiOperation - +generateImageOperation() GenerativeAiOperation - +convertTextToSpeechOperation() GenerativeAiOperation - +generateSpeechOperation() GenerativeAiOperation - +generateEmbeddingsOperation() EmbeddingOperation + +convertTextToSpeechResult() GenerativeAiResult +generateText() string +generateTexts(?int $candidateCount) string[] - +streamGenerateText() Generator< string > +generateImage() File +generateImages(?int $candidateCount) File[] +convertTextToSpeech() File +convertTextToSpeeches(?int $candidateCount) File[] +generateSpeech() File +generateSpeeches(?int $candidateCount) File[] - +generateEmbeddings() Embedding[] - +getModelRequirements() ModelRequirements - +isSupported() bool + +isSupportedForTextGeneration() bool + +isSupportedForImageGeneration() bool + +isSupportedForTextToSpeechConversion() bool + +isSupportedForVideoGeneration() bool + +isSupportedForSpeechGeneration() bool + +isSupportedForMusicGeneration() bool + +isSupportedForEmbeddingGeneration() bool } class MessageBuilder { - +usingRole(MessageRole $role) self + +usingRole(MessageRoleEnum $role) self + +usingUserRole() self + +usingModelRole() self +withText(string $text) self - +withImageFile(File $file) self - +withAudioFile(File $file) self - +withVideoFile(File $file) self + +withFile($file, ?string $mimeType) self +withFunctionCall(FunctionCall $functionCall) self +withFunctionResponse(FunctionResponse $functionResponse) self - +withMessageParts(...MessagePart $part) self + +withMessageParts(...MessagePart $parts) self +get() Message } } diff --git a/src/Builders/MessageBuilder.php b/src/Builders/MessageBuilder.php new file mode 100644 index 00000000..66dd1236 --- /dev/null +++ b/src/Builders/MessageBuilder.php @@ -0,0 +1,229 @@ + The parts that make up the message. + */ + protected array $parts = []; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param Input $input Optional initial content. + * @param MessageRoleEnum|null $role Optional role. + */ + public function __construct($input = null, ?MessageRoleEnum $role = null) + { + $this->role = $role; + + if ($input === null) { + return; + } + + // Handle different input types + if ($input instanceof MessagePart) { + $this->parts[] = $input; + } elseif (is_string($input)) { + $this->withText($input); + } elseif ($input instanceof File) { + $this->withFile($input); + } elseif ($input instanceof FunctionCall) { + $this->withFunctionCall($input); + } elseif ($input instanceof FunctionResponse) { + $this->withFunctionResponse($input); + } elseif (is_array($input) && MessagePart::isArrayShape($input)) { + $this->parts[] = MessagePart::fromArray($input); + } else { + throw new InvalidArgumentException( + 'Input must be a string, MessagePart, MessagePartArrayShape, File, FunctionCall, or FunctionResponse.' + ); + } + } + + /** + * Sets the role of the message sender. + * + * @since n.e.x.t + * + * @param MessageRoleEnum $role The role to set. + * @return self + */ + public function usingRole(MessageRoleEnum $role): self + { + $this->role = $role; + return $this; + } + + /** + * Sets the role to user. + * + * @since n.e.x.t + * + * @return self + */ + public function usingUserRole(): self + { + return $this->usingRole(MessageRoleEnum::user()); + } + + /** + * Sets the role to model. + * + * @since n.e.x.t + * + * @return self + */ + public function usingModelRole(): self + { + return $this->usingRole(MessageRoleEnum::model()); + } + + /** + * Adds text content to the message. + * + * @since n.e.x.t + * + * @param string $text The text to add. + * @return self + * @throws InvalidArgumentException If the text is empty. + */ + public function withText(string $text): self + { + if (trim($text) === '') { + throw new InvalidArgumentException('Text content cannot be empty.'); + } + + $this->parts[] = new MessagePart($text); + return $this; + } + + /** + * Adds a file to the message. + * + * Accepts: + * - File object + * - URL string (remote file) + * - Base64-encoded data string + * - Data URI string (data:mime/type;base64,data) + * - Local file path string + * + * @since n.e.x.t + * + * @param string|File $file The file to add. + * @param string|null $mimeType Optional MIME type (ignored if File object provided). + * @return self + * @throws InvalidArgumentException If the file is invalid. + */ + public function withFile($file, ?string $mimeType = null): self + { + $file = $file instanceof File ? $file : new File($file, $mimeType); + $this->parts[] = new MessagePart($file); + return $this; + } + + /** + * Adds a function call to the message. + * + * @since n.e.x.t + * + * @param FunctionCall $functionCall The function call to add. + * @return self + */ + public function withFunctionCall(FunctionCall $functionCall): self + { + $this->parts[] = new MessagePart($functionCall); + return $this; + } + + /** + * Adds a function response to the message. + * + * @since n.e.x.t + * + * @param FunctionResponse $functionResponse The function response to add. + * @return self + */ + public function withFunctionResponse(FunctionResponse $functionResponse): self + { + $this->parts[] = new MessagePart($functionResponse); + return $this; + } + + /** + * Adds multiple message parts to the message. + * + * @since n.e.x.t + * + * @param MessagePart ...$parts The message parts to add. + * @return self + */ + public function withMessageParts(MessagePart ...$parts): self + { + foreach ($parts as $part) { + $this->parts[] = $part; + } + + return $this; + } + + /** + * Builds and returns the Message object. + * + * @since n.e.x.t + * + * @return Message The built message. + * @throws InvalidArgumentException If the message validation fails. + */ + public function get(): Message + { + if (empty($this->parts)) { + throw new InvalidArgumentException( + 'Cannot build an empty message. Add content using withText() or similar methods.' + ); + } + + if ($this->role === null) { + throw new InvalidArgumentException( + 'Cannot build a message with no role. Set a role using usingRole() or similar methods.' + ); + } + + // At this point, we've validated that $this->role is not null + /** @var MessageRoleEnum $role */ + $role = $this->role; + + return new Message($role, $this->parts); + } +} diff --git a/tests/unit/Builders/MessageBuilderTest.php b/tests/unit/Builders/MessageBuilderTest.php new file mode 100644 index 00000000..7ec01e54 --- /dev/null +++ b/tests/unit/Builders/MessageBuilderTest.php @@ -0,0 +1,529 @@ +usingUserRole()->get(); + + $this->assertInstanceOf(Message::class, $message); + $this->assertTrue($message->getRole()->isUser()); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isText()); + $this->assertEquals('Hello, AI!', $parts[0]->getText()); + } + + /** + * Tests that text can be added with the withText method. + * + * @return void + */ + public function testWithTextAddsTextPart(): void + { + $builder = new MessageBuilder(); + $message = $builder + ->withText('First text') + ->withText('Second text') + ->usingModelRole() + ->get(); + + $parts = $message->getParts(); + $this->assertCount(2, $parts); + $this->assertEquals('First text', $parts[0]->getText()); + $this->assertEquals('Second text', $parts[1]->getText()); + } + + /** + * Tests that empty text throws an exception. + * + * @return void + */ + public function testWithTextThrowsExceptionForEmptyText(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Text content cannot be empty.'); + + $builder = new MessageBuilder(); + $builder->withText(' '); + } + + /** + * Tests that a file can be added to the message. + * + * @return void + */ + public function testWithFileAddsFilePart(): void + { + $builder = new MessageBuilder(); + $message = $builder + ->withFile('data:image/png;base64,iVBORw0KGgo=', 'image/png') + ->usingUserRole() + ->get(); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFile()); + + $file = $parts[0]->getFile(); + $this->assertInstanceOf(File::class, $file); + $this->assertEquals('image/png', $file->getMimeType()); + } + + /** + * Tests that a File object can be passed directly. + * + * @return void + */ + public function testWithFileAcceptsFileObject(): void + { + $file = new File('data:image/png;base64,iVBORw0KGgo=', 'image/png'); + + $builder = new MessageBuilder(); + $message = $builder + ->withFile($file) + ->usingUserRole() + ->get(); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFile()); + $this->assertSame($file, $parts[0]->getFile()); + } + + /** + * Tests that function calls can be added to model messages. + * + * @return void + */ + public function testWithFunctionCallAddsToModelMessage(): void + { + $functionCall = new FunctionCall('call_id', 'test_function', ['arg' => 'value']); + + $builder = new MessageBuilder(); + $message = $builder + ->usingModelRole() + ->withFunctionCall($functionCall) + ->get(); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionCall()); + $this->assertSame($functionCall, $parts[0]->getFunctionCall()); + } + + /** + * Tests that function responses can be added to user messages. + * + * @return void + */ + public function testWithFunctionResponseAddsToUserMessage(): void + { + $functionResponse = new FunctionResponse('response_id', 'test_function', ['result' => 'success']); + + $builder = new MessageBuilder(); + $message = $builder + ->usingUserRole() + ->withFunctionResponse($functionResponse) + ->get(); + + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionResponse()); + $this->assertSame($functionResponse, $parts[0]->getFunctionResponse()); + } + + /** + * Tests that multiple message parts can be added at once. + * + * @return void + */ + public function testWithMessagePartsAddsMultipleParts(): void + { + $part1 = new MessagePart('Text 1'); + $part2 = new MessagePart('Text 2'); + $part3 = new MessagePart(new File('data:image/png;base64,test', 'image/png')); + + $builder = new MessageBuilder(); + $message = $builder + ->usingUserRole() + ->withMessageParts($part1, $part2, $part3) + ->get(); + + $parts = $message->getParts(); + $this->assertCount(3, $parts); + $this->assertSame($part1, $parts[0]); + $this->assertSame($part2, $parts[1]); + $this->assertSame($part3, $parts[2]); + } + + /** + * Tests that roles can be set using usingRole method. + * + * @return void + */ + public function testUsingRoleSetsRole(): void + { + $builder = new MessageBuilder('Test'); + + $userMessage = $builder->usingRole(MessageRoleEnum::user())->get(); + $this->assertTrue($userMessage->getRole()->isUser()); + + $builder = new MessageBuilder('Test'); + $modelMessage = $builder->usingRole(MessageRoleEnum::model())->get(); + $this->assertTrue($modelMessage->getRole()->isModel()); + } + + /** + * Tests that usingUserRole sets the role to user. + * + * @return void + */ + public function testUsingUserRoleSetsUserRole(): void + { + $builder = new MessageBuilder('Test'); + $message = $builder->usingUserRole()->get(); + + $this->assertTrue($message->getRole()->isUser()); + } + + /** + * Tests that usingModelRole sets the role to model. + * + * @return void + */ + public function testUsingModelRoleSetsModelRole(): void + { + $builder = new MessageBuilder('Test'); + $message = $builder->usingModelRole()->get(); + + $this->assertTrue($message->getRole()->isModel()); + } + + /** + * Tests that building without parts throws an exception. + * + * @return void + */ + public function testGetThrowsExceptionForEmptyParts(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Cannot build an empty message. Add content using withText() or similar methods.' + ); + + $builder = new MessageBuilder(); + $builder->usingUserRole()->get(); + } + + /** + * Tests that building without a role throws an exception. + * + * @return void + */ + public function testGetThrowsExceptionForNoRole(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Cannot build a message with no role. Set a role using usingRole() or similar methods.' + ); + + $builder = new MessageBuilder(); + $builder->withText('Test')->get(); + } + + /** + * Tests that function calls in user messages are rejected during validation. + * + * @return void + */ + public function testValidationRejectsFunctionCallsInUserMessages(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('User messages cannot contain function calls.'); + + $functionCall = new FunctionCall(null, 'test', []); + + $builder = new MessageBuilder(); + $builder + ->withFunctionCall($functionCall) + ->usingUserRole() + ->get(); + } + + /** + * Tests that function responses in model messages are rejected during validation. + * + * @return void + */ + public function testValidationRejectsFunctionResponsesInModelMessages(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model messages cannot contain function responses.'); + + $functionResponse = new FunctionResponse('id', 'test', []); + + $builder = new MessageBuilder(); + $builder + ->withFunctionResponse($functionResponse) + ->usingModelRole() + ->get(); + } + + /** + * Tests that role can be set after adding parts. + * + * @return void + */ + public function testRoleCanBeSetAfterAddingParts(): void + { + $builder = new MessageBuilder(); + $message = $builder + ->withText('Hello') + ->withText('World') + ->usingUserRole() + ->get(); + + $this->assertTrue($message->getRole()->isUser()); + $this->assertCount(2, $message->getParts()); + } + + /** + * Tests that the builder is fluent. + * + * @return void + */ + public function testBuilderIsFluent(): void + { + $builder = new MessageBuilder(); + + $result1 = $builder->withText('Test'); + $this->assertSame($builder, $result1); + + $result2 = $builder->usingUserRole(); + $this->assertSame($builder, $result2); + + $result3 = $builder->withFile('data:text/plain;base64,test', 'text/plain'); + $this->assertSame($builder, $result3); + } + + /** + * Tests that mixed content types can be added to a message. + * + * @return void + */ + public function testMixedContentMessage(): void + { + $file = new File('data:image/png;base64,test', 'image/png'); + + $builder = new MessageBuilder(); + $message = $builder + ->withText('Analyze this image:') + ->withFile($file) + ->withText('What do you see?') + ->usingUserRole() + ->get(); + + $parts = $message->getParts(); + $this->assertCount(3, $parts); + $this->assertTrue($parts[0]->getType()->isText()); + $this->assertTrue($parts[1]->getType()->isFile()); + $this->assertTrue($parts[2]->getType()->isText()); + } + + /** + * Tests constructor with initial text and role. + * + * @return void + */ + public function testConstructorWithTextAndRole(): void + { + $builder = new MessageBuilder('Initial text', MessageRoleEnum::model()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isModel()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertEquals('Initial text', $parts[0]->getText()); + } + + /** + * Tests that validation allows valid combinations. + * + * @return void + */ + public function testValidationAllowsValidCombinations(): void + { + // User message with function response - should work + $functionResponse = new FunctionResponse('resp_id', 'test', ['result' => 'ok']); + $builder1 = new MessageBuilder(); + $message1 = $builder1 + ->usingUserRole() + ->withText('Here is the result:') + ->withFunctionResponse($functionResponse) + ->get(); + + $this->assertTrue($message1->getRole()->isUser()); + $this->assertCount(2, $message1->getParts()); + + // Model message with function call - should work + $functionCall = new FunctionCall(null, 'test', ['param' => 'value']); + $builder2 = new MessageBuilder(); + $message2 = $builder2 + ->usingModelRole() + ->withText('I will call a function:') + ->withFunctionCall($functionCall) + ->get(); + + $this->assertTrue($message2->getRole()->isModel()); + $this->assertCount(2, $message2->getParts()); + } + + /** + * Tests constructor with MessagePart input. + * + * @return void + */ + public function testConstructorWithMessagePartInput(): void + { + $messagePart = new MessagePart('Test text'); + $builder = new MessageBuilder($messagePart, MessageRoleEnum::user()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertSame($messagePart, $parts[0]); + } + + /** + * Tests constructor with File input. + * + * @return void + */ + public function testConstructorWithFileInput(): void + { + $file = new File('data:image/png;base64,test', 'image/png'); + $builder = new MessageBuilder($file, MessageRoleEnum::user()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFile()); + $this->assertSame($file, $parts[0]->getFile()); + } + + /** + * Tests constructor with FunctionCall input. + * + * @return void + */ + public function testConstructorWithFunctionCallInput(): void + { + $functionCall = new FunctionCall('id', 'test_func', ['arg' => 'val']); + $builder = new MessageBuilder($functionCall, MessageRoleEnum::model()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isModel()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionCall()); + $this->assertSame($functionCall, $parts[0]->getFunctionCall()); + } + + /** + * Tests constructor with FunctionResponse input. + * + * @return void + */ + public function testConstructorWithFunctionResponseInput(): void + { + $functionResponse = new FunctionResponse('id', 'test_func', ['result' => 'success']); + $builder = new MessageBuilder($functionResponse, MessageRoleEnum::user()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionResponse()); + $this->assertSame($functionResponse, $parts[0]->getFunctionResponse()); + } + + /** + * Tests constructor with MessagePartArrayShape input. + * + * @return void + */ + public function testConstructorWithMessagePartArrayShapeInput(): void + { + $partArray = ['text' => 'Hello from array']; + $builder = new MessageBuilder($partArray, MessageRoleEnum::user()); + $message = $builder->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isText()); + $this->assertEquals('Hello from array', $parts[0]->getText()); + } + + /** + * Tests constructor with invalid input throws exception. + * + * @return void + */ + public function testConstructorWithInvalidInputThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Input must be a string, MessagePart, MessagePartArrayShape, File, FunctionCall, or FunctionResponse.' + ); + + new MessageBuilder(['invalid' => 'array']); + } + + /** + * Tests constructor with null input creates empty builder. + * + * @return void + */ + public function testConstructorWithNullInputCreatesEmptyBuilder(): void + { + $builder = new MessageBuilder(null, MessageRoleEnum::user()); + + // Should be able to add content and build + $message = $builder->withText('Added later')->get(); + + $this->assertTrue($message->getRole()->isUser()); + $parts = $message->getParts(); + $this->assertCount(1, $parts); + $this->assertEquals('Added later', $parts[0]->getText()); + } +}