From aa750351706d45b877913fcb59f3c5a6bd6cc956 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 4 Dec 2025 16:44:11 -0700 Subject: [PATCH 1/9] feat: adds support for event dispatching --- composer.json | 1 + composer.lock | 52 ++++- src/AiClient.php | 46 ++++ src/Builders/PromptBuilder.php | 48 +++- src/Events/AfterPromptSentEvent.php | 111 +++++++++ src/Events/BeforePromptSentEvent.php | 103 +++++++++ tests/mocks/MockEventDispatcher.php | 98 ++++++++ tests/unit/AiClientTest.php | 90 +++++++- .../PromptBuilderEventDispatchingTest.php | 218 ++++++++++++++++++ .../unit/Events/AfterPromptSentEventTest.php | 84 +++++++ .../unit/Events/BeforePromptSentEventTest.php | 103 +++++++++ 11 files changed, 946 insertions(+), 8 deletions(-) create mode 100644 src/Events/AfterPromptSentEvent.php create mode 100644 src/Events/BeforePromptSentEvent.php create mode 100644 tests/mocks/MockEventDispatcher.php create mode 100644 tests/unit/Builders/PromptBuilderEventDispatchingTest.php create mode 100644 tests/unit/Events/AfterPromptSentEventTest.php create mode 100644 tests/unit/Events/BeforePromptSentEventTest.php diff --git a/composer.json b/composer.json index 5ca727a0..a1994147 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "php-http/message-factory": "^1.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", + "psr/event-dispatcher": "^1.0", "psr/http-message": "^1.0 || ^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index d6a5af90..519aef77 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "893f04e6854aa23afd7dcd0cabfe1def", + "content-hash": "c290e23e54f97989b8d04bb33bf137fc", "packages": [ { "name": "php-http/discovery", @@ -249,6 +249,56 @@ }, "time": "2024-03-15T13:55:21+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/http-client", "version": "1.0.3", diff --git a/src/AiClient.php b/src/AiClient.php index ab9ceadd..760b9302 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient; +use Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; @@ -90,6 +91,11 @@ class AiClient */ private static ?ProviderRegistry $defaultRegistry = null; + /** + * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events. + */ + private static ?EventDispatcherInterface $eventDispatcher = null; + /** * Gets the default provider registry instance. * @@ -114,6 +120,46 @@ public static function defaultRegistry(): ProviderRegistry return self::$defaultRegistry; } + /** + * Sets the event dispatcher for prompt lifecycle events. + * + * The event dispatcher will be used to dispatch BeforePromptSentEvent and + * AfterPromptSentEvent during prompt generation. + * + * @since n.e.x.t + * + * @param EventDispatcherInterface|null $dispatcher The event dispatcher, or null to disable. + * @return void + */ + public static function setEventDispatcher(?EventDispatcherInterface $dispatcher): void + { + self::$eventDispatcher = $dispatcher; + } + + /** + * Gets the event dispatcher for prompt lifecycle events. + * + * @since n.e.x.t + * + * @return EventDispatcherInterface|null The event dispatcher, or null if not set. + */ + public static function getEventDispatcher(): ?EventDispatcherInterface + { + return self::$eventDispatcher; + } + + /** + * Checks if an event dispatcher is registered. + * + * @since n.e.x.t + * + * @return bool True if an event dispatcher is set, false otherwise. + */ + public static function hasEventDispatcher(): bool + { + return self::$eventDispatcher !== null; + } + /** * Checks if a provider is configured and available for use. * diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 758aeeda..fb66b681 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -4,8 +4,11 @@ namespace WordPress\AiClient\Builders; +use WordPress\AiClient\AiClient; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Events\AfterPromptSentEvent; +use WordPress\AiClient\Events\BeforePromptSentEvent; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\Message; @@ -826,7 +829,42 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi $model = $this->getConfiguredModel($capability); + // Dispatch BeforePromptSentEvent if dispatcher is set + $messages = $this->messages; + if (AiClient::hasEventDispatcher()) { + $beforeEvent = new BeforePromptSentEvent($messages, $model, $capability); + AiClient::getEventDispatcher()->dispatch($beforeEvent); + $messages = $beforeEvent->getMessages(); + } + // Route to the appropriate generation method based on capability + $result = $this->executeModelGeneration($model, $capability, $messages); + + // Dispatch AfterPromptSentEvent if dispatcher is set + if (AiClient::hasEventDispatcher()) { + $afterEvent = new AfterPromptSentEvent($messages, $model, $capability, $result); + AiClient::getEventDispatcher()->dispatch($afterEvent); + } + + return $result; + } + + /** + * Executes the model generation based on capability. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to use for generation. + * @param CapabilityEnum $capability The capability to use. + * @param list $messages The messages to send. + * @return GenerativeAiResult The generated result. + * @throws RuntimeException If the model doesn't support the required capability. + */ + private function executeModelGeneration( + ModelInterface $model, + CapabilityEnum $capability, + array $messages + ): GenerativeAiResult { if ($capability->isTextGeneration()) { if (!$model instanceof TextGenerationModelInterface) { throw new RuntimeException( @@ -836,7 +874,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi ) ); } - return $model->generateTextResult($this->messages); + return $model->generateTextResult($messages); } if ($capability->isImageGeneration()) { @@ -848,7 +886,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi ) ); } - return $model->generateImageResult($this->messages); + return $model->generateImageResult($messages); } if ($capability->isTextToSpeechConversion()) { @@ -860,7 +898,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi ) ); } - return $model->convertTextToSpeechResult($this->messages); + return $model->convertTextToSpeechResult($messages); } if ($capability->isSpeechGeneration()) { @@ -872,15 +910,13 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi ) ); } - return $model->generateSpeechResult($this->messages); + return $model->generateSpeechResult($messages); } if ($capability->isVideoGeneration()) { - // Video generation is not yet implemented throw new RuntimeException('Output modality "video" is not yet supported.'); } - // TODO: Add support for other capabilities when interfaces are available throw new RuntimeException( sprintf('Capability "%s" is not yet supported for generation.', $capability->value) ); diff --git a/src/Events/AfterPromptSentEvent.php b/src/Events/AfterPromptSentEvent.php new file mode 100644 index 00000000..53a04ae5 --- /dev/null +++ b/src/Events/AfterPromptSentEvent.php @@ -0,0 +1,111 @@ + The messages that were sent to the model. + */ + private array $messages; + + /** + * @var ModelInterface The model that processed the prompt. + */ + private ModelInterface $model; + + /** + * @var CapabilityEnum|null The capability that was used for generation. + */ + private ?CapabilityEnum $capability; + + /** + * @var GenerativeAiResult The result from the model. + */ + private GenerativeAiResult $result; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param list $messages The messages that were sent to the model. + * @param ModelInterface $model The model that processed the prompt. + * @param CapabilityEnum|null $capability The capability that was used for generation. + * @param GenerativeAiResult $result The result from the model. + */ + public function __construct( + array $messages, + ModelInterface $model, + ?CapabilityEnum $capability, + GenerativeAiResult $result + ) { + $this->messages = $messages; + $this->model = $model; + $this->capability = $capability; + $this->result = $result; + } + + /** + * Gets the messages that were sent to the model. + * + * @since n.e.x.t + * + * @return list The messages. + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * Gets the model that processed the prompt. + * + * @since n.e.x.t + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + + /** + * Gets the capability that was used for generation. + * + * @since n.e.x.t + * + * @return CapabilityEnum|null The capability, or null if not specified. + */ + public function getCapability(): ?CapabilityEnum + { + return $this->capability; + } + + /** + * Gets the result from the model. + * + * @since n.e.x.t + * + * @return GenerativeAiResult The result. + */ + public function getResult(): GenerativeAiResult + { + return $this->result; + } +} diff --git a/src/Events/BeforePromptSentEvent.php b/src/Events/BeforePromptSentEvent.php new file mode 100644 index 00000000..5b213275 --- /dev/null +++ b/src/Events/BeforePromptSentEvent.php @@ -0,0 +1,103 @@ + The messages to be sent to the model. + */ + private array $messages; + + /** + * @var ModelInterface The model that will process the prompt. + */ + private ModelInterface $model; + + /** + * @var CapabilityEnum|null The capability being used for generation. + */ + private ?CapabilityEnum $capability; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param list $messages The messages to be sent to the model. + * @param ModelInterface $model The model that will process the prompt. + * @param CapabilityEnum|null $capability The capability being used for generation. + */ + public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability) + { + $this->messages = $messages; + $this->model = $model; + $this->capability = $capability; + } + + /** + * Gets the messages to be sent to the model. + * + * @since n.e.x.t + * + * @return list The messages. + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * Sets the messages to be sent to the model. + * + * This allows listeners to modify the messages before they are sent. + * + * @since n.e.x.t + * + * @param list $messages The modified messages. + * @return void + */ + public function setMessages(array $messages): void + { + $this->messages = $messages; + } + + /** + * Gets the model that will process the prompt. + * + * @since n.e.x.t + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + + /** + * Gets the capability being used for generation. + * + * @since n.e.x.t + * + * @return CapabilityEnum|null The capability, or null if not specified. + */ + public function getCapability(): ?CapabilityEnum + { + return $this->capability; + } +} diff --git a/tests/mocks/MockEventDispatcher.php b/tests/mocks/MockEventDispatcher.php new file mode 100644 index 00000000..f7e5507c --- /dev/null +++ b/tests/mocks/MockEventDispatcher.php @@ -0,0 +1,98 @@ + The list of dispatched events. + */ + private array $dispatchedEvents = []; + + /** + * @var array> The registered listeners keyed by event class name. + */ + private array $listeners = []; + + /** + * {@inheritDoc} + * + * @param object $event The event to dispatch. + * @return object The event after being processed by listeners. + */ + public function dispatch(object $event): object + { + $this->dispatchedEvents[] = $event; + + $eventClass = get_class($event); + if (isset($this->listeners[$eventClass])) { + foreach ($this->listeners[$eventClass] as $listener) { + $listener($event); + } + } + + return $event; + } + + /** + * Registers a listener for a specific event class. + * + * @param string $eventClass The event class name. + * @param callable $listener The listener callback. + * @return void + */ + public function addListener(string $eventClass, callable $listener): void + { + if (!isset($this->listeners[$eventClass])) { + $this->listeners[$eventClass] = []; + } + $this->listeners[$eventClass][] = $listener; + } + + /** + * Gets all dispatched events. + * + * @return list The dispatched events. + */ + public function getDispatchedEvents(): array + { + return $this->dispatchedEvents; + } + + /** + * Gets dispatched events of a specific type. + * + * @template T of object + * @param class-string $eventClass The event class to filter by. + * @return list The filtered events. + */ + public function getDispatchedEventsOfType(string $eventClass): array + { + return array_values(array_filter( + $this->dispatchedEvents, + static function (object $event) use ($eventClass): bool { + return $event instanceof $eventClass; + } + )); + } + + /** + * Clears all dispatched events. + * + * @return void + */ + public function clearDispatchedEvents(): void + { + $this->dispatchedEvents = []; + } +} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 8aef3f0b..4ac8355f 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -7,12 +7,15 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use WordPress\AiClient\AiClient; +use WordPress\AiClient\Events\AfterPromptSentEvent; +use WordPress\AiClient\Events\BeforePromptSentEvent; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; +use WordPress\AiClient\Tests\mocks\MockEventDispatcher; use WordPress\AiClient\Tests\traits\MockModelCreationTrait; /** @@ -30,7 +33,8 @@ protected function setUp(): void protected function tearDown(): void { - // Tests use dependency injection - registry instances passed directly to methods + // Clean up static event dispatcher after each test + AiClient::setEventDispatcher(null); } /** @@ -742,4 +746,88 @@ public function testGetConfiguredPromptBuilderHelperIntegration(): void $this->expectExceptionMessageMatches('/No models found that support/'); AiClient::generateResult($prompt, null, $this->createMockEmptyRegistry()); } + + /** + * Tests setEventDispatcher and getEventDispatcher methods. + */ + public function testEventDispatcherGetterAndSetter(): void + { + // Initially null + $this->assertNull(AiClient::getEventDispatcher()); + + // Set a dispatcher + $dispatcher = new MockEventDispatcher(); + AiClient::setEventDispatcher($dispatcher); + + $this->assertSame($dispatcher, AiClient::getEventDispatcher()); + + // Set to null + AiClient::setEventDispatcher(null); + $this->assertNull(AiClient::getEventDispatcher()); + } + + /** + * Tests hasEventDispatcher method. + */ + public function testHasEventDispatcher(): void + { + // Initially false + $this->assertFalse(AiClient::hasEventDispatcher()); + + // Set a dispatcher + $dispatcher = new MockEventDispatcher(); + AiClient::setEventDispatcher($dispatcher); + + $this->assertTrue(AiClient::hasEventDispatcher()); + + // Set to null + AiClient::setEventDispatcher(null); + $this->assertFalse(AiClient::hasEventDispatcher()); + } + + /** + * Tests that event dispatcher is passed to PromptBuilder via prompt() method. + */ + public function testEventDispatcherIsPassedToPromptBuilder(): void + { + $dispatcher = new MockEventDispatcher(); + AiClient::setEventDispatcher($dispatcher); + + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockTextGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); + + $result = AiClient::generateTextResult('Test prompt', $mockModel, $registry); + + $this->assertSame($expectedResult, $result); + + // Verify events were dispatched + $beforeEvents = $dispatcher->getDispatchedEventsOfType(BeforePromptSentEvent::class); + $afterEvents = $dispatcher->getDispatchedEventsOfType(AfterPromptSentEvent::class); + + $this->assertCount(1, $beforeEvents); + $this->assertCount(1, $afterEvents); + } + + /** + * Tests that prompt() method creates builder with event dispatcher. + */ + public function testPromptMethodPassesEventDispatcher(): void + { + $dispatcher = new MockEventDispatcher(); + AiClient::setEventDispatcher($dispatcher); + + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockTextGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); + + $result = AiClient::prompt('Test prompt', $registry) + ->usingModel($mockModel) + ->generateTextResult(); + + $this->assertSame($expectedResult, $result); + + // Verify events were dispatched + $this->assertCount(2, $dispatcher->getDispatchedEvents()); + } } diff --git a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php new file mode 100644 index 00000000..ad1f50eb --- /dev/null +++ b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php @@ -0,0 +1,218 @@ +registry = new ProviderRegistry(); + $this->registry->registerProvider(MockProvider::class); + $this->dispatcher = new MockEventDispatcher(); + } + + /** + * Cleans up after each test. + * + * @return void + */ + protected function tearDown(): void + { + // Clean up global event dispatcher + AiClient::setEventDispatcher(null); + } + + /** + * Tests that events are dispatched when a dispatcher is set globally. + * + * @return void + */ + public function testEventsAreDispatchedWhenDispatcherIsSet(): void + { + AiClient::setEventDispatcher($this->dispatcher); + + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Hello, world!'); + $builder->usingModel($model); + + $builder->generateTextResult(); + + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforePromptSentEvent::class); + $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterPromptSentEvent::class); + + $this->assertCount(1, $beforeEvents); + $this->assertCount(1, $afterEvents); + } + + /** + * Tests that no events are dispatched when dispatcher is not set. + * + * @return void + */ + public function testNoEventsDispatchedWithoutDispatcher(): void + { + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Hello, world!'); + $builder->usingModel($model); + + $builder->generateTextResult(); + + // No dispatcher set, so no events should be dispatched + $this->assertCount(0, $this->dispatcher->getDispatchedEvents()); + } + + /** + * Tests that BeforePromptSentEvent contains correct data. + * + * @return void + */ + public function testBeforePromptSentEventContainsCorrectData(): void + { + AiClient::setEventDispatcher($this->dispatcher); + + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModel($model); + + $builder->generateTextResult(); + + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforePromptSentEvent::class); + $this->assertCount(1, $beforeEvents); + + $event = $beforeEvents[0]; + $this->assertCount(1, $event->getMessages()); + $this->assertSame($model, $event->getModel()); + $this->assertEquals(CapabilityEnum::textGeneration(), $event->getCapability()); + } + + /** + * Tests that AfterPromptSentEvent contains correct data. + * + * @return void + */ + public function testAfterPromptSentEventContainsCorrectData(): void + { + AiClient::setEventDispatcher($this->dispatcher); + + $result = $this->createTestResult('Generated response'); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModel($model); + + $returnedResult = $builder->generateTextResult(); + + $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterPromptSentEvent::class); + $this->assertCount(1, $afterEvents); + + $event = $afterEvents[0]; + $this->assertCount(1, $event->getMessages()); + $this->assertSame($model, $event->getModel()); + $this->assertEquals(CapabilityEnum::textGeneration(), $event->getCapability()); + $this->assertSame($result, $event->getResult()); + $this->assertSame($returnedResult, $event->getResult()); + } + + /** + * Tests that BeforePromptSentEvent can modify messages. + * + * @return void + */ + public function testBeforePromptSentEventCanModifyMessages(): void + { + AiClient::setEventDispatcher($this->dispatcher); + + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + // Register a listener that modifies the messages + $modifiedMessages = [ + new UserMessage([new MessagePart('Modified message')]) + ]; + + $this->dispatcher->addListener( + BeforePromptSentEvent::class, + static function (BeforePromptSentEvent $event) use ($modifiedMessages): void { + $event->setMessages($modifiedMessages); + } + ); + + $builder = new PromptBuilder($this->registry, 'Original message'); + $builder->usingModel($model); + + $builder->generateTextResult(); + + // Verify the modification happened + $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterPromptSentEvent::class); + $this->assertCount(1, $afterEvents); + + $afterEvent = $afterEvents[0]; + $this->assertSame($modifiedMessages, $afterEvent->getMessages()); + } + + /** + * Tests that events are dispatched in correct order. + * + * @return void + */ + public function testEventsDispatchedInCorrectOrder(): void + { + AiClient::setEventDispatcher($this->dispatcher); + + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $builder = new PromptBuilder($this->registry, 'Hello'); + $builder->usingModel($model); + + $builder->generateTextResult(); + + $events = $this->dispatcher->getDispatchedEvents(); + $this->assertCount(2, $events); + $this->assertInstanceOf(BeforePromptSentEvent::class, $events[0]); + $this->assertInstanceOf(AfterPromptSentEvent::class, $events[1]); + } +} diff --git a/tests/unit/Events/AfterPromptSentEventTest.php b/tests/unit/Events/AfterPromptSentEventTest.php new file mode 100644 index 00000000..063b9591 --- /dev/null +++ b/tests/unit/Events/AfterPromptSentEventTest.php @@ -0,0 +1,84 @@ +createTestResult('Hello!'); + $model = $this->createMockTextGenerationModel($result); + $capability = CapabilityEnum::textGeneration(); + + $event = new AfterPromptSentEvent($messages, $model, $capability, $result); + + $this->assertSame($messages, $event->getMessages()); + $this->assertSame($model, $event->getModel()); + $this->assertSame($capability, $event->getCapability()); + $this->assertSame($result, $event->getResult()); + } + + /** + * Tests event construction with null capability. + * + * @return void + */ + public function testConstructionWithNullCapability(): void + { + $messages = [ + new UserMessage([new MessagePart('Hello')]) + ]; + $result = $this->createTestResult('Response'); + $model = $this->createMockTextGenerationModel($result); + + $event = new AfterPromptSentEvent($messages, $model, null, $result); + + $this->assertNull($event->getCapability()); + } + + /** + * Tests that result is accessible. + * + * @return void + */ + public function testGetResult(): void + { + $messages = [ + new UserMessage([new MessagePart('Test prompt')]) + ]; + $result = $this->createTestResult('Test response'); + $model = $this->createMockTextGenerationModel($result); + + $event = new AfterPromptSentEvent( + $messages, + $model, + CapabilityEnum::textGeneration(), + $result + ); + + $this->assertSame($result, $event->getResult()); + $this->assertCount(1, $event->getResult()->getCandidates()); + } +} diff --git a/tests/unit/Events/BeforePromptSentEventTest.php b/tests/unit/Events/BeforePromptSentEventTest.php new file mode 100644 index 00000000..d99afd88 --- /dev/null +++ b/tests/unit/Events/BeforePromptSentEventTest.php @@ -0,0 +1,103 @@ +createTestResult(); + $model = $this->createMockTextGenerationModel($result); + $capability = CapabilityEnum::textGeneration(); + + $event = new BeforePromptSentEvent($messages, $model, $capability); + + $this->assertSame($messages, $event->getMessages()); + $this->assertSame($model, $event->getModel()); + $this->assertSame($capability, $event->getCapability()); + } + + /** + * Tests event construction with null capability. + * + * @return void + */ + public function testConstructionWithNullCapability(): void + { + $messages = [ + new UserMessage([new MessagePart('Hello')]) + ]; + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $event = new BeforePromptSentEvent($messages, $model, null); + + $this->assertNull($event->getCapability()); + } + + /** + * Tests message modification. + * + * @return void + */ + public function testSetMessages(): void + { + $originalMessages = [ + new UserMessage([new MessagePart('Original message')]) + ]; + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $event = new BeforePromptSentEvent($originalMessages, $model, null); + + $newMessages = [ + new UserMessage([new MessagePart('Modified message')]) + ]; + $event->setMessages($newMessages); + + $this->assertSame($newMessages, $event->getMessages()); + $this->assertNotSame($originalMessages, $event->getMessages()); + } + + /** + * Tests that the event can hold multiple messages. + * + * @return void + */ + public function testMultipleMessages(): void + { + $messages = [ + new UserMessage([new MessagePart('First message')]), + new UserMessage([new MessagePart('Second message')]), + new UserMessage([new MessagePart('Third message')]) + ]; + $result = $this->createTestResult(); + $model = $this->createMockTextGenerationModel($result); + + $event = new BeforePromptSentEvent($messages, $model, CapabilityEnum::textGeneration()); + + $this->assertCount(3, $event->getMessages()); + } +} From 7df2383243ca238b0078e04ad3956f1b0147a536 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 5 Dec 2025 12:12:40 -0700 Subject: [PATCH 2/9] refactor: simplifies event dispatching --- src/AiClient.php | 18 ++++++++++++++---- src/Builders/PromptBuilder.php | 21 +++++++++------------ tests/unit/AiClientTest.php | 19 ------------------- 3 files changed, 23 insertions(+), 35 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 760b9302..df742c24 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -149,15 +149,25 @@ public static function getEventDispatcher(): ?EventDispatcherInterface } /** - * Checks if an event dispatcher is registered. + * Dispatches an event if an event dispatcher is registered. + * + * This is a convenience method that handles the null check internally, + * only dispatching if a dispatcher has been set via setEventDispatcher(). * * @since n.e.x.t * - * @return bool True if an event dispatcher is set, false otherwise. + * @template T of object + * @param T $event The event to dispatch. + * @return T The event (potentially modified by listeners). */ - public static function hasEventDispatcher(): bool + public static function dispatchEvent(object $event): object { - return self::$eventDispatcher !== null; + if (self::$eventDispatcher !== null) { + /** @var T */ + return self::$eventDispatcher->dispatch($event); + } + + return $event; } /** diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index fb66b681..317d053e 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -829,22 +829,19 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi $model = $this->getConfiguredModel($capability); - // Dispatch BeforePromptSentEvent if dispatcher is set - $messages = $this->messages; - if (AiClient::hasEventDispatcher()) { - $beforeEvent = new BeforePromptSentEvent($messages, $model, $capability); - AiClient::getEventDispatcher()->dispatch($beforeEvent); - $messages = $beforeEvent->getMessages(); - } + // Dispatch BeforePromptSentEvent (allows message modification) + $beforeEvent = AiClient::dispatchEvent( + new BeforePromptSentEvent($this->messages, $model, $capability) + ); + $messages = $beforeEvent->getMessages(); // Route to the appropriate generation method based on capability $result = $this->executeModelGeneration($model, $capability, $messages); - // Dispatch AfterPromptSentEvent if dispatcher is set - if (AiClient::hasEventDispatcher()) { - $afterEvent = new AfterPromptSentEvent($messages, $model, $capability, $result); - AiClient::getEventDispatcher()->dispatch($afterEvent); - } + // Dispatch AfterPromptSentEvent + AiClient::dispatchEvent( + new AfterPromptSentEvent($messages, $model, $capability, $result) + ); return $result; } diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 4ac8355f..d75c592b 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -766,25 +766,6 @@ public function testEventDispatcherGetterAndSetter(): void $this->assertNull(AiClient::getEventDispatcher()); } - /** - * Tests hasEventDispatcher method. - */ - public function testHasEventDispatcher(): void - { - // Initially false - $this->assertFalse(AiClient::hasEventDispatcher()); - - // Set a dispatcher - $dispatcher = new MockEventDispatcher(); - AiClient::setEventDispatcher($dispatcher); - - $this->assertTrue(AiClient::hasEventDispatcher()); - - // Set to null - AiClient::setEventDispatcher(null); - $this->assertFalse(AiClient::hasEventDispatcher()); - } - /** * Tests that event dispatcher is passed to PromptBuilder via prompt() method. */ From 5d1d5682b37a4211db1cebaebed7d2ed86907b34 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 10 Dec 2025 13:12:53 -0700 Subject: [PATCH 3/9] refactor: renames event classes --- src/Builders/PromptBuilder.php | 8 +++---- ...Event.php => AfterGenerateResultEvent.php} | 2 +- ...vent.php => BeforeGenerateResultEvent.php} | 2 +- tests/unit/AiClientTest.php | 8 +++---- .../PromptBuilderEventDispatchingTest.php | 22 +++++++++---------- .../unit/Events/AfterPromptSentEventTest.php | 8 +++---- .../unit/Events/BeforePromptSentEventTest.php | 10 ++++----- 7 files changed, 30 insertions(+), 30 deletions(-) rename src/Events/{AfterPromptSentEvent.php => AfterGenerateResultEvent.php} (98%) rename src/Events/{BeforePromptSentEvent.php => BeforeGenerateResultEvent.php} (98%) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 317d053e..5df25364 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -7,8 +7,8 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; -use WordPress\AiClient\Events\AfterPromptSentEvent; -use WordPress\AiClient\Events\BeforePromptSentEvent; +use WordPress\AiClient\Events\AfterGenerateResultEvent; +use WordPress\AiClient\Events\BeforeGenerateResultEvent; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\Message; @@ -831,7 +831,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi // Dispatch BeforePromptSentEvent (allows message modification) $beforeEvent = AiClient::dispatchEvent( - new BeforePromptSentEvent($this->messages, $model, $capability) + new BeforeGenerateResultEvent($this->messages, $model, $capability) ); $messages = $beforeEvent->getMessages(); @@ -840,7 +840,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi // Dispatch AfterPromptSentEvent AiClient::dispatchEvent( - new AfterPromptSentEvent($messages, $model, $capability, $result) + new AfterGenerateResultEvent($messages, $model, $capability, $result) ); return $result; diff --git a/src/Events/AfterPromptSentEvent.php b/src/Events/AfterGenerateResultEvent.php similarity index 98% rename from src/Events/AfterPromptSentEvent.php rename to src/Events/AfterGenerateResultEvent.php index 53a04ae5..1cc650f3 100644 --- a/src/Events/AfterPromptSentEvent.php +++ b/src/Events/AfterGenerateResultEvent.php @@ -17,7 +17,7 @@ * * @since n.e.x.t */ -class AfterPromptSentEvent +class AfterGenerateResultEvent { /** * @var list The messages that were sent to the model. diff --git a/src/Events/BeforePromptSentEvent.php b/src/Events/BeforeGenerateResultEvent.php similarity index 98% rename from src/Events/BeforePromptSentEvent.php rename to src/Events/BeforeGenerateResultEvent.php index 5b213275..31a182a1 100644 --- a/src/Events/BeforePromptSentEvent.php +++ b/src/Events/BeforeGenerateResultEvent.php @@ -17,7 +17,7 @@ * * @since n.e.x.t */ -class BeforePromptSentEvent +class BeforeGenerateResultEvent { /** * @var list The messages to be sent to the model. diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index d75c592b..f6b7d958 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -7,8 +7,8 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use WordPress\AiClient\AiClient; -use WordPress\AiClient\Events\AfterPromptSentEvent; -use WordPress\AiClient\Events\BeforePromptSentEvent; +use WordPress\AiClient\Events\AfterGenerateResultEvent; +use WordPress\AiClient\Events\BeforeGenerateResultEvent; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; @@ -783,8 +783,8 @@ public function testEventDispatcherIsPassedToPromptBuilder(): void $this->assertSame($expectedResult, $result); // Verify events were dispatched - $beforeEvents = $dispatcher->getDispatchedEventsOfType(BeforePromptSentEvent::class); - $afterEvents = $dispatcher->getDispatchedEventsOfType(AfterPromptSentEvent::class); + $beforeEvents = $dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); + $afterEvents = $dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class); $this->assertCount(1, $beforeEvents); $this->assertCount(1, $afterEvents); diff --git a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php index ad1f50eb..5deee9a8 100644 --- a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php +++ b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php @@ -7,8 +7,8 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\AiClient; use WordPress\AiClient\Builders\PromptBuilder; -use WordPress\AiClient\Events\AfterPromptSentEvent; -use WordPress\AiClient\Events\BeforePromptSentEvent; +use WordPress\AiClient\Events\AfterGenerateResultEvent; +use WordPress\AiClient\Events\BeforeGenerateResultEvent; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -76,8 +76,8 @@ public function testEventsAreDispatchedWhenDispatcherIsSet(): void $builder->generateTextResult(); - $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforePromptSentEvent::class); - $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterPromptSentEvent::class); + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); + $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class); $this->assertCount(1, $beforeEvents); $this->assertCount(1, $afterEvents); @@ -119,7 +119,7 @@ public function testBeforePromptSentEventContainsCorrectData(): void $builder->generateTextResult(); - $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforePromptSentEvent::class); + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); $this->assertCount(1, $beforeEvents); $event = $beforeEvents[0]; @@ -145,7 +145,7 @@ public function testAfterPromptSentEventContainsCorrectData(): void $returnedResult = $builder->generateTextResult(); - $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterPromptSentEvent::class); + $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class); $this->assertCount(1, $afterEvents); $event = $afterEvents[0]; @@ -174,8 +174,8 @@ public function testBeforePromptSentEventCanModifyMessages(): void ]; $this->dispatcher->addListener( - BeforePromptSentEvent::class, - static function (BeforePromptSentEvent $event) use ($modifiedMessages): void { + BeforeGenerateResultEvent::class, + static function (BeforeGenerateResultEvent $event) use ($modifiedMessages): void { $event->setMessages($modifiedMessages); } ); @@ -186,7 +186,7 @@ static function (BeforePromptSentEvent $event) use ($modifiedMessages): void { $builder->generateTextResult(); // Verify the modification happened - $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterPromptSentEvent::class); + $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class); $this->assertCount(1, $afterEvents); $afterEvent = $afterEvents[0]; @@ -212,7 +212,7 @@ public function testEventsDispatchedInCorrectOrder(): void $events = $this->dispatcher->getDispatchedEvents(); $this->assertCount(2, $events); - $this->assertInstanceOf(BeforePromptSentEvent::class, $events[0]); - $this->assertInstanceOf(AfterPromptSentEvent::class, $events[1]); + $this->assertInstanceOf(BeforeGenerateResultEvent::class, $events[0]); + $this->assertInstanceOf(AfterGenerateResultEvent::class, $events[1]); } } diff --git a/tests/unit/Events/AfterPromptSentEventTest.php b/tests/unit/Events/AfterPromptSentEventTest.php index 063b9591..f8601611 100644 --- a/tests/unit/Events/AfterPromptSentEventTest.php +++ b/tests/unit/Events/AfterPromptSentEventTest.php @@ -5,7 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Events; use PHPUnit\Framework\TestCase; -use WordPress\AiClient\Events\AfterPromptSentEvent; +use WordPress\AiClient\Events\AfterGenerateResultEvent; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -32,7 +32,7 @@ public function testConstruction(): void $model = $this->createMockTextGenerationModel($result); $capability = CapabilityEnum::textGeneration(); - $event = new AfterPromptSentEvent($messages, $model, $capability, $result); + $event = new AfterGenerateResultEvent($messages, $model, $capability, $result); $this->assertSame($messages, $event->getMessages()); $this->assertSame($model, $event->getModel()); @@ -53,7 +53,7 @@ public function testConstructionWithNullCapability(): void $result = $this->createTestResult('Response'); $model = $this->createMockTextGenerationModel($result); - $event = new AfterPromptSentEvent($messages, $model, null, $result); + $event = new AfterGenerateResultEvent($messages, $model, null, $result); $this->assertNull($event->getCapability()); } @@ -71,7 +71,7 @@ public function testGetResult(): void $result = $this->createTestResult('Test response'); $model = $this->createMockTextGenerationModel($result); - $event = new AfterPromptSentEvent( + $event = new AfterGenerateResultEvent( $messages, $model, CapabilityEnum::textGeneration(), diff --git a/tests/unit/Events/BeforePromptSentEventTest.php b/tests/unit/Events/BeforePromptSentEventTest.php index d99afd88..a611ae2d 100644 --- a/tests/unit/Events/BeforePromptSentEventTest.php +++ b/tests/unit/Events/BeforePromptSentEventTest.php @@ -5,7 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Events; use PHPUnit\Framework\TestCase; -use WordPress\AiClient\Events\BeforePromptSentEvent; +use WordPress\AiClient\Events\BeforeGenerateResultEvent; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -32,7 +32,7 @@ public function testConstruction(): void $model = $this->createMockTextGenerationModel($result); $capability = CapabilityEnum::textGeneration(); - $event = new BeforePromptSentEvent($messages, $model, $capability); + $event = new BeforeGenerateResultEvent($messages, $model, $capability); $this->assertSame($messages, $event->getMessages()); $this->assertSame($model, $event->getModel()); @@ -52,7 +52,7 @@ public function testConstructionWithNullCapability(): void $result = $this->createTestResult(); $model = $this->createMockTextGenerationModel($result); - $event = new BeforePromptSentEvent($messages, $model, null); + $event = new BeforeGenerateResultEvent($messages, $model, null); $this->assertNull($event->getCapability()); } @@ -70,7 +70,7 @@ public function testSetMessages(): void $result = $this->createTestResult(); $model = $this->createMockTextGenerationModel($result); - $event = new BeforePromptSentEvent($originalMessages, $model, null); + $event = new BeforeGenerateResultEvent($originalMessages, $model, null); $newMessages = [ new UserMessage([new MessagePart('Modified message')]) @@ -96,7 +96,7 @@ public function testMultipleMessages(): void $result = $this->createTestResult(); $model = $this->createMockTextGenerationModel($result); - $event = new BeforePromptSentEvent($messages, $model, CapabilityEnum::textGeneration()); + $event = new BeforeGenerateResultEvent($messages, $model, CapabilityEnum::textGeneration()); $this->assertCount(3, $event->getMessages()); } From 069c8d33233f3917fdef51535fe5a5b34cb0b061 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 10 Dec 2025 13:21:28 -0700 Subject: [PATCH 4/9] refactor: removes event data mutation --- src/Builders/PromptBuilder.php | 11 +++--- src/Events/BeforeGenerateResultEvent.php | 15 ------- .../PromptBuilderEventDispatchingTest.php | 39 ------------------- .../unit/Events/BeforePromptSentEventTest.php | 24 ------------ 4 files changed, 5 insertions(+), 84 deletions(-) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 5df25364..2328e420 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -829,18 +829,17 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi $model = $this->getConfiguredModel($capability); - // Dispatch BeforePromptSentEvent (allows message modification) - $beforeEvent = AiClient::dispatchEvent( + // Dispatch BeforeGenerateResultEvent + AiClient::dispatchEvent( new BeforeGenerateResultEvent($this->messages, $model, $capability) ); - $messages = $beforeEvent->getMessages(); // Route to the appropriate generation method based on capability - $result = $this->executeModelGeneration($model, $capability, $messages); + $result = $this->executeModelGeneration($model, $capability, $this->messages); - // Dispatch AfterPromptSentEvent + // Dispatch AfterGenerateResultEvent AiClient::dispatchEvent( - new AfterGenerateResultEvent($messages, $model, $capability, $result) + new AfterGenerateResultEvent($this->messages, $model, $capability, $result) ); return $result; diff --git a/src/Events/BeforeGenerateResultEvent.php b/src/Events/BeforeGenerateResultEvent.php index 31a182a1..c6e27785 100644 --- a/src/Events/BeforeGenerateResultEvent.php +++ b/src/Events/BeforeGenerateResultEvent.php @@ -62,21 +62,6 @@ public function getMessages(): array return $this->messages; } - /** - * Sets the messages to be sent to the model. - * - * This allows listeners to modify the messages before they are sent. - * - * @since n.e.x.t - * - * @param list $messages The modified messages. - * @return void - */ - public function setMessages(array $messages): void - { - $this->messages = $messages; - } - /** * Gets the model that will process the prompt. * diff --git a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php index 5deee9a8..f5930ac7 100644 --- a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php +++ b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php @@ -9,8 +9,6 @@ use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Events\AfterGenerateResultEvent; use WordPress\AiClient\Events\BeforeGenerateResultEvent; -use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Tests\mocks\MockEventDispatcher; @@ -156,43 +154,6 @@ public function testAfterPromptSentEventContainsCorrectData(): void $this->assertSame($returnedResult, $event->getResult()); } - /** - * Tests that BeforePromptSentEvent can modify messages. - * - * @return void - */ - public function testBeforePromptSentEventCanModifyMessages(): void - { - AiClient::setEventDispatcher($this->dispatcher); - - $result = $this->createTestResult(); - $model = $this->createMockTextGenerationModel($result); - - // Register a listener that modifies the messages - $modifiedMessages = [ - new UserMessage([new MessagePart('Modified message')]) - ]; - - $this->dispatcher->addListener( - BeforeGenerateResultEvent::class, - static function (BeforeGenerateResultEvent $event) use ($modifiedMessages): void { - $event->setMessages($modifiedMessages); - } - ); - - $builder = new PromptBuilder($this->registry, 'Original message'); - $builder->usingModel($model); - - $builder->generateTextResult(); - - // Verify the modification happened - $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class); - $this->assertCount(1, $afterEvents); - - $afterEvent = $afterEvents[0]; - $this->assertSame($modifiedMessages, $afterEvent->getMessages()); - } - /** * Tests that events are dispatched in correct order. * diff --git a/tests/unit/Events/BeforePromptSentEventTest.php b/tests/unit/Events/BeforePromptSentEventTest.php index a611ae2d..8163d61e 100644 --- a/tests/unit/Events/BeforePromptSentEventTest.php +++ b/tests/unit/Events/BeforePromptSentEventTest.php @@ -57,30 +57,6 @@ public function testConstructionWithNullCapability(): void $this->assertNull($event->getCapability()); } - /** - * Tests message modification. - * - * @return void - */ - public function testSetMessages(): void - { - $originalMessages = [ - new UserMessage([new MessagePart('Original message')]) - ]; - $result = $this->createTestResult(); - $model = $this->createMockTextGenerationModel($result); - - $event = new BeforeGenerateResultEvent($originalMessages, $model, null); - - $newMessages = [ - new UserMessage([new MessagePart('Modified message')]) - ]; - $event->setMessages($newMessages); - - $this->assertSame($newMessages, $event->getMessages()); - $this->assertNotSame($originalMessages, $event->getMessages()); - } - /** * Tests that the event can hold multiple messages. * From 350f40dbd9eb80038d7b39c479a90c075311f79e Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 10 Dec 2025 17:10:00 -0700 Subject: [PATCH 5/9] refactor: switches to using DI for passing dispatcher --- src/AiClient.php | 32 ++------- src/Builders/PromptBuilder.php | 35 ++++++++-- tests/unit/AiClientTest.php | 68 ------------------- .../PromptBuilderEventDispatchingTest.php | 40 +++-------- 4 files changed, 47 insertions(+), 128 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index df742c24..624bf8c2 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -123,8 +123,8 @@ public static function defaultRegistry(): ProviderRegistry /** * Sets the event dispatcher for prompt lifecycle events. * - * The event dispatcher will be used to dispatch BeforePromptSentEvent and - * AfterPromptSentEvent during prompt generation. + * The event dispatcher will be used to dispatch BeforeGenerateResultEvent and + * AfterGenerateResultEvent during prompt generation. * * @since n.e.x.t * @@ -148,28 +148,6 @@ public static function getEventDispatcher(): ?EventDispatcherInterface return self::$eventDispatcher; } - /** - * Dispatches an event if an event dispatcher is registered. - * - * This is a convenience method that handles the null check internally, - * only dispatching if a dispatcher has been set via setEventDispatcher(). - * - * @since n.e.x.t - * - * @template T of object - * @param T $event The event to dispatch. - * @return T The event (potentially modified by listeners). - */ - public static function dispatchEvent(object $event): object - { - if (self::$eventDispatcher !== null) { - /** @var T */ - return self::$eventDispatcher->dispatch($event); - } - - return $event; - } - /** * Checks if a provider is configured and available for use. * @@ -227,7 +205,11 @@ public static function isConfigured($availabilityOrIdOrClassName): bool */ public static function prompt($prompt = null, ?ProviderRegistry $registry = null): PromptBuilder { - return new PromptBuilder($registry ?? self::defaultRegistry(), $prompt); + return new PromptBuilder( + $registry ?? self::defaultRegistry(), + $prompt, + self::$eventDispatcher + ); } /** diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 2328e420..99129101 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Builders; -use WordPress\AiClient\AiClient; +use Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Events\AfterGenerateResultEvent; @@ -85,6 +85,11 @@ class PromptBuilder */ protected ?RequestOptions $requestOptions = null; + /** + * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events. + */ + private ?EventDispatcherInterface $eventDispatcher = null; + // phpcs:disable Generic.Files.LineLength.TooLong /** * Constructor. @@ -93,12 +98,17 @@ class PromptBuilder * * @param ProviderRegistry $registry The provider registry for finding suitable models. * @param Prompt $prompt Optional initial prompt content. + * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events. */ // phpcs:enable Generic.Files.LineLength.TooLong - public function __construct(ProviderRegistry $registry, $prompt = null) - { + public function __construct( + ProviderRegistry $registry, + $prompt = null, + ?EventDispatcherInterface $eventDispatcher = null + ) { $this->registry = $registry; $this->modelConfig = new ModelConfig(); + $this->eventDispatcher = $eventDispatcher; if ($prompt === null) { return; @@ -830,7 +840,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi $model = $this->getConfiguredModel($capability); // Dispatch BeforeGenerateResultEvent - AiClient::dispatchEvent( + $this->dispatchEvent( new BeforeGenerateResultEvent($this->messages, $model, $capability) ); @@ -838,7 +848,7 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi $result = $this->executeModelGeneration($model, $capability, $this->messages); // Dispatch AfterGenerateResultEvent - AiClient::dispatchEvent( + $this->dispatchEvent( new AfterGenerateResultEvent($this->messages, $model, $capability, $result) ); @@ -1554,4 +1564,19 @@ private function includeOutputModalities(ModalityEnum ...$modalities): void $this->modelConfig->setOutputModalities(array_merge($existing, $toAdd)); } } + + /** + * Dispatches an event if an event dispatcher is registered. + * + * @since n.e.x.t + * + * @param object $event The event to dispatch. + * @return void + */ + private function dispatchEvent(object $event): void + { + if ($this->eventDispatcher !== null) { + $this->eventDispatcher->dispatch($event); + } + } } diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index f6b7d958..b77dcdfe 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -7,15 +7,12 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use WordPress\AiClient\AiClient; -use WordPress\AiClient\Events\AfterGenerateResultEvent; -use WordPress\AiClient\Events\BeforeGenerateResultEvent; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; -use WordPress\AiClient\Tests\mocks\MockEventDispatcher; use WordPress\AiClient\Tests\traits\MockModelCreationTrait; /** @@ -746,69 +743,4 @@ public function testGetConfiguredPromptBuilderHelperIntegration(): void $this->expectExceptionMessageMatches('/No models found that support/'); AiClient::generateResult($prompt, null, $this->createMockEmptyRegistry()); } - - /** - * Tests setEventDispatcher and getEventDispatcher methods. - */ - public function testEventDispatcherGetterAndSetter(): void - { - // Initially null - $this->assertNull(AiClient::getEventDispatcher()); - - // Set a dispatcher - $dispatcher = new MockEventDispatcher(); - AiClient::setEventDispatcher($dispatcher); - - $this->assertSame($dispatcher, AiClient::getEventDispatcher()); - - // Set to null - AiClient::setEventDispatcher(null); - $this->assertNull(AiClient::getEventDispatcher()); - } - - /** - * Tests that event dispatcher is passed to PromptBuilder via prompt() method. - */ - public function testEventDispatcherIsPassedToPromptBuilder(): void - { - $dispatcher = new MockEventDispatcher(); - AiClient::setEventDispatcher($dispatcher); - - $expectedResult = $this->createTestResult(); - $mockModel = $this->createMockTextGenerationModel($expectedResult); - $registry = $this->createRegistryWithMockProvider(); - - $result = AiClient::generateTextResult('Test prompt', $mockModel, $registry); - - $this->assertSame($expectedResult, $result); - - // Verify events were dispatched - $beforeEvents = $dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); - $afterEvents = $dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class); - - $this->assertCount(1, $beforeEvents); - $this->assertCount(1, $afterEvents); - } - - /** - * Tests that prompt() method creates builder with event dispatcher. - */ - public function testPromptMethodPassesEventDispatcher(): void - { - $dispatcher = new MockEventDispatcher(); - AiClient::setEventDispatcher($dispatcher); - - $expectedResult = $this->createTestResult(); - $mockModel = $this->createMockTextGenerationModel($expectedResult); - $registry = $this->createRegistryWithMockProvider(); - - $result = AiClient::prompt('Test prompt', $registry) - ->usingModel($mockModel) - ->generateTextResult(); - - $this->assertSame($expectedResult, $result); - - // Verify events were dispatched - $this->assertCount(2, $dispatcher->getDispatchedEvents()); - } } diff --git a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php index f5930ac7..092a9330 100644 --- a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php +++ b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php @@ -5,7 +5,6 @@ namespace WordPress\AiClient\Tests\unit\Builders; use PHPUnit\Framework\TestCase; -use WordPress\AiClient\AiClient; use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Events\AfterGenerateResultEvent; use WordPress\AiClient\Events\BeforeGenerateResultEvent; @@ -47,29 +46,16 @@ protected function setUp(): void } /** - * Cleans up after each test. + * Tests that events are dispatched when a dispatcher is injected. * * @return void */ - protected function tearDown(): void + public function testEventsAreDispatchedWhenDispatcherIsInjected(): void { - // Clean up global event dispatcher - AiClient::setEventDispatcher(null); - } - - /** - * Tests that events are dispatched when a dispatcher is set globally. - * - * @return void - */ - public function testEventsAreDispatchedWhenDispatcherIsSet(): void - { - AiClient::setEventDispatcher($this->dispatcher); - $result = $this->createTestResult(); $model = $this->createMockTextGenerationModel($result); - $builder = new PromptBuilder($this->registry, 'Hello, world!'); + $builder = new PromptBuilder($this->registry, 'Hello, world!', $this->dispatcher); $builder->usingModel($model); $builder->generateTextResult(); @@ -101,18 +87,16 @@ public function testNoEventsDispatchedWithoutDispatcher(): void } /** - * Tests that BeforePromptSentEvent contains correct data. + * Tests that BeforeGenerateResultEvent contains correct data. * * @return void */ - public function testBeforePromptSentEventContainsCorrectData(): void + public function testBeforeGenerateResultEventContainsCorrectData(): void { - AiClient::setEventDispatcher($this->dispatcher); - $result = $this->createTestResult(); $model = $this->createMockTextGenerationModel($result); - $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder = new PromptBuilder($this->registry, 'Test prompt', $this->dispatcher); $builder->usingModel($model); $builder->generateTextResult(); @@ -127,18 +111,16 @@ public function testBeforePromptSentEventContainsCorrectData(): void } /** - * Tests that AfterPromptSentEvent contains correct data. + * Tests that AfterGenerateResultEvent contains correct data. * * @return void */ - public function testAfterPromptSentEventContainsCorrectData(): void + public function testAfterGenerateResultEventContainsCorrectData(): void { - AiClient::setEventDispatcher($this->dispatcher); - $result = $this->createTestResult('Generated response'); $model = $this->createMockTextGenerationModel($result); - $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder = new PromptBuilder($this->registry, 'Test prompt', $this->dispatcher); $builder->usingModel($model); $returnedResult = $builder->generateTextResult(); @@ -161,12 +143,10 @@ public function testAfterPromptSentEventContainsCorrectData(): void */ public function testEventsDispatchedInCorrectOrder(): void { - AiClient::setEventDispatcher($this->dispatcher); - $result = $this->createTestResult(); $model = $this->createMockTextGenerationModel($result); - $builder = new PromptBuilder($this->registry, 'Hello'); + $builder = new PromptBuilder($this->registry, 'Hello', $this->dispatcher); $builder->usingModel($model); $builder->generateTextResult(); From 626979ea8b813cb1dcf19f13fdf98dc578866d18 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 10 Dec 2025 17:27:44 -0700 Subject: [PATCH 6/9] revert: adds back comments removed by AI --- src/AiClient.php | 1 - src/Builders/PromptBuilder.php | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AiClient.php b/src/AiClient.php index 624bf8c2..4af69003 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -263,7 +263,6 @@ public static function generateTextResult( return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult(); } - /** * Generates an image using the traditional API approach. * diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 99129101..ab5e4e8d 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -919,10 +919,12 @@ private function executeModelGeneration( return $model->generateSpeechResult($messages); } + // Video generation is not yet implemented if ($capability->isVideoGeneration()) { throw new RuntimeException('Output modality "video" is not yet supported.'); } + // TODO: Add support for other capabilities when interfaces are available throw new RuntimeException( sprintf('Capability "%s" is not yet supported for generation.', $capability->value) ); From d1710bd4fc2d9e3f4a600e3f98323187ee75d572 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 11 Dec 2025 11:15:49 -0700 Subject: [PATCH 7/9] chore: documents event dispatcher integration --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/README.md b/README.md index 8c4bb32a..0345027f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,57 @@ See the [`PromptBuilder` class](https://github.com/WordPress/php-ai-client/blob/ **More documentation is coming soon.** +## Event Dispatching + +The AI Client supports PSR-14 event dispatching for prompt lifecycle events. This allows you to hook into the generation process for logging, monitoring, or other integrations. + +### Available Events + +- `BeforeGenerateResultEvent` - Dispatched before a prompt is sent to the model +- `AfterGenerateResultEvent` - Dispatched after a result is received from the model + +### Connecting Your Event Dispatcher + +To enable event dispatching, pass any PSR-14 compatible `EventDispatcherInterface` to the client: + +```php +use WordPress\AiClient\AiClient; + +// Set your PSR-14 event dispatcher +AiClient::setEventDispatcher($yourEventDispatcher); + +// Events will now be dispatched during generation +$text = AiClient::prompt('Hello, world!') + ->generateText(); +``` + +### Example: Logging Events + +```php +use WordPress\AiClient\Events\BeforeGenerateResultEvent; +use WordPress\AiClient\Events\AfterGenerateResultEvent; + +// In your event listener/subscriber +class AiEventListener +{ + public function onBeforeGenerate(BeforeGenerateResultEvent $event): void + { + $model = $event->getModel(); + $messages = $event->getMessages(); + $capability = $event->getCapability(); + + // Log, monitor, or perform other actions + } + + public function onAfterGenerate(AfterGenerateResultEvent $event): void + { + $result = $event->getResult(); + + // Log the result, track usage, etc. + } +} +``` + ## Further reading For more information on the requirements and guiding principles, please review: From 612c065c45ca5165839cd72a2ebe94909b86c1c8 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 11 Dec 2025 17:55:05 -0700 Subject: [PATCH 8/9] chore: adds event listener details to readme Co-authored-by: Felix Arntz --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0345027f..334840cf 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ The AI Client supports PSR-14 event dispatching for prompt lifecycle events. Thi - `BeforeGenerateResultEvent` - Dispatched before a prompt is sent to the model - `AfterGenerateResultEvent` - Dispatched after a result is received from the model +**Important:** Event listeners should not return a value, as they will be ignored. In order to modify data that is passed with the event object, you need to rely on setters on the event object. Any event data for which there are no setters on the event object is meant to be immutable or, in other words, read-only for the event listener. + ### Connecting Your Event Dispatcher To enable event dispatching, pass any PSR-14 compatible `EventDispatcherInterface` to the client: From 943a3e55f6f55fe0959187741b509f8099d50550 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 11 Dec 2025 18:46:03 -0700 Subject: [PATCH 9/9] test: removes unnecessary setup/teardown methods --- tests/unit/AiClientTest.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index b77dcdfe..5ab0a6a2 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -22,18 +22,6 @@ class AiClientTest extends TestCase { use MockModelCreationTrait; - protected function setUp(): void - { - // Tests use dependency injection - registry instances passed directly to methods - } - - - protected function tearDown(): void - { - // Clean up static event dispatcher after each test - AiClient::setEventDispatcher(null); - } - /** * Creates a mock registry that returns empty results for model discovery. *