From 018520175332aa2c61e280f497cbd1f6b214b429 Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Mon, 6 Oct 2025 14:17:43 +0200 Subject: [PATCH] feat(core): commands added for message store + configuration --- .github/workflows/integration-tests.yaml | 4 +- docs/components/chat.rst | 22 +++ examples/commands/message-stores.php | 64 +++++++++ src/ai-bundle/composer.json | 1 + src/ai-bundle/config/options.php | 29 ++++ src/ai-bundle/config/services.php | 12 ++ src/ai-bundle/src/AiBundle.php | 73 ++++++++++ .../DependencyInjection/AiBundleTest.php | 88 ++++++++++++ src/chat/composer.json | 2 + src/chat/phpstan.dist.neon | 1 + src/chat/src/Bridge/Local/InMemoryStore.php | 18 ++- src/chat/src/Command/DropStoreCommand.php | 99 ++++++++++++++ src/chat/src/Command/SetupStoreCommand.php | 91 +++++++++++++ src/chat/src/Exception/ExceptionInterface.php | 19 +++ src/chat/src/Exception/RuntimeException.php | 19 +++ .../tests/Command/DropStoreCommandTest.php | 127 ++++++++++++++++++ .../tests/Command/SetupStoreCommandTest.php | 107 +++++++++++++++ 17 files changed, 770 insertions(+), 6 deletions(-) create mode 100644 examples/commands/message-stores.php create mode 100644 src/chat/src/Command/DropStoreCommand.php create mode 100644 src/chat/src/Command/SetupStoreCommand.php create mode 100644 src/chat/src/Exception/ExceptionInterface.php create mode 100644 src/chat/src/Exception/RuntimeException.php create mode 100644 src/chat/tests/Command/DropStoreCommandTest.php create mode 100644 src/chat/tests/Command/SetupStoreCommandTest.php diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index 45b92be8e..e814b28de 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -75,7 +75,9 @@ jobs: run: ../link - name: Run commands examples - run: php examples/commands/stores.php + run: | + php examples/commands/stores.php + php examples/commands/message-stores.php demo: runs-on: ubuntu-latest diff --git a/docs/components/chat.rst b/docs/components/chat.rst index 90fd1e6f1..252b1c3b6 100644 --- a/docs/components/chat.rst +++ b/docs/components/chat.rst @@ -84,3 +84,25 @@ This leads to a store implementing two methods:: // Implementation to drop the store (and related messages) } } + +Commands +-------- + +While using the `Chat` component in your Symfony application along with the ``AiBundle``, +you can use the ``bin/console ai:message-store:setup`` command to initialize the message store and ``bin/console ai:message-store:drop`` to clean up the message store: + +.. code-block:: yaml + + # config/packages/ai.yaml + ai: + # ... + + message_store: + cache: + symfonycon: + service: 'cache.app' + +.. code-block:: terminal + + $ php bin/console ai:message-store:setup symfonycon + $ php bin/console ai:message-store:drop symfonycon diff --git a/examples/commands/message-stores.php b/examples/commands/message-stores.php new file mode 100644 index 000000000..697b5a694 --- /dev/null +++ b/examples/commands/message-stores.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +require_once dirname(__DIR__).'/bootstrap.php'; + +use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore; +use Symfony\AI\Chat\Bridge\Local\CacheStore; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Chat\Command\DropStoreCommand; +use Symfony\AI\Chat\Command\SetupStoreCommand; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; + +$factories = [ + 'cache' => static fn (): CacheStore => new CacheStore(new ArrayAdapter(), cacheKey: 'symfony'), + 'memory' => static fn (): InMemoryStore => new InMemoryStore('symfony'), + 'session' => static function (): SessionStore { + $request = Request::create('/'); + $request->setSession(new Session(new MockArraySessionStorage())); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + return new SessionStore($requestStack, 'symfony'); + }, +]; + +$storesIds = array_keys($factories); + +$application = new Application(); +$application->setAutoExit(false); +$application->setCatchExceptions(false); +$application->addCommands([ + new SetupStoreCommand(new ServiceLocator($factories)), + new DropStoreCommand(new ServiceLocator($factories)), +]); + +foreach ($storesIds as $store) { + $setupOutputCode = $application->run(new ArrayInput([ + 'command' => 'ai:message-store:setup', + 'store' => $store, + ]), new ConsoleOutput()); + + $dropOutputCode = $application->run(new ArrayInput([ + 'command' => 'ai:message-store:drop', + 'store' => $store, + '--force' => true, + ]), new ConsoleOutput()); +} diff --git a/src/ai-bundle/composer.json b/src/ai-bundle/composer.json index c3d37642e..b08e890c9 100644 --- a/src/ai-bundle/composer.json +++ b/src/ai-bundle/composer.json @@ -16,6 +16,7 @@ "require": { "php": ">=8.2", "symfony/ai-agent": "@dev", + "symfony/ai-chat": "@dev", "symfony/ai-platform": "@dev", "symfony/ai-store": "@dev", "symfony/config": "^7.3|^8.0", diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index ace9a82d3..3fb8c9dcd 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -697,6 +697,35 @@ ->end() ->end() ->end() + ->arrayNode('message_store') + ->children() + ->arrayNode('memory') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('identifier')->cannotBeEmpty()->end() + ->end() + ->end() + ->end() + ->arrayNode('cache') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('service')->cannotBeEmpty()->defaultValue('cache.app')->end() + ->stringNode('key')->end() + ->end() + ->end() + ->end() + ->arrayNode('session') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('identifier')->cannotBeEmpty()->end() + ->end() + ->end() + ->end() + ->end() + ->end() ->arrayNode('vectorizer') ->info('Vectorizers for converting strings to Vector objects and transforming TextDocument arrays to VectorDocument arrays') ->useAttributeAsKey('name') diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index b32b3a548..753940c0f 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -26,6 +26,8 @@ use Symfony\AI\AiBundle\Profiler\DataCollector; use Symfony\AI\AiBundle\Profiler\TraceableToolbox; use Symfony\AI\AiBundle\Security\EventListener\IsGrantedToolAttributeListener; +use Symfony\AI\Chat\Command\DropStoreCommand as DropMessageStoreCommand; +use Symfony\AI\Chat\Command\SetupStoreCommand as SetupMessageStoreCommand; use Symfony\AI\Platform\Bridge\AiMlApi\ModelCatalog as AiMlApiModelCatalog; use Symfony\AI\Platform\Bridge\Anthropic\Contract\AnthropicContract; use Symfony\AI\Platform\Bridge\Anthropic\ModelCatalog as AnthropicModelCatalog; @@ -224,5 +226,15 @@ tagged_locator('ai.platform', 'name'), ]) ->tag('console.command') + ->set('ai.command.setup_message_store', SetupMessageStoreCommand::class) + ->args([ + tagged_locator('ai.message_store', 'name'), + ]) + ->tag('console.command') + ->set('ai.command.drop_message_store', DropMessageStoreCommand::class) + ->args([ + tagged_locator('ai.message_store', 'name'), + ]) + ->tag('console.command') ; }; diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 356cd8f1d..4adcd4e5b 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -34,6 +34,8 @@ use Symfony\AI\AiBundle\Profiler\TraceablePlatform; use Symfony\AI\AiBundle\Profiler\TraceableToolbox; use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool; +use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore; +use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicPlatformFactory; use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAiPlatformFactory; use Symfony\AI\Platform\Bridge\Cerebras\PlatformFactory as CerebrasPlatformFactory; @@ -163,6 +165,21 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $builder->removeDefinition('ai.command.drop_store'); } + foreach ($config['message_store'] ?? [] as $type => $store) { + $this->processMessageStoreConfig($type, $store, $builder); + } + + $messageStores = array_keys($builder->findTaggedServiceIds('ai.message_store')); + + if (1 === \count($messageStores)) { + $builder->setAlias(MessageStoreInterface::class, reset($messageStores)); + } + + if ([] === $messageStores) { + $builder->removeDefinition('ai.command.setup_message_store'); + $builder->removeDefinition('ai.command.drop_message_store'); + } + foreach ($config['vectorizer'] ?? [] as $vectorizerName => $vectorizer) { $this->processVectorizerConfig($vectorizerName, $vectorizer, $builder); } @@ -1254,6 +1271,62 @@ private function processStoreConfig(string $type, array $stores, ContainerBuilde } } + /** + * @param array $messageStores + */ + private function processMessageStoreConfig(string $type, array $messageStores, ContainerBuilder $container): void + { + if ('memory' === $type) { + foreach ($messageStores as $name => $messageStore) { + $definition = new Definition(InMemoryStore::class); + $definition + ->setArgument(0, $messageStore['identifier']) + ->addTag('ai.message_store'); + + $container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name); + } + } + + if ('cache' === $type) { + foreach ($messageStores as $name => $messageStore) { + $arguments = [ + new Reference($messageStore['service']), + ]; + + if (\array_key_exists('key', $messageStore)) { + $arguments['key'] = $messageStore['key']; + } + + $definition = new Definition(CacheStore::class); + $definition + ->setArguments($arguments) + ->addTag('ai.message_store'); + + $container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name); + } + } + + if ('session' === $type) { + foreach ($messageStores as $name => $messageStore) { + $definition = new Definition(SessionStore::class); + $definition + ->setArguments([ + new Reference('request_stack'), + $messageStore['identifier'], + ]) + ->addTag('ai.message_store'); + + $container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name); + } + } + } + /** * @param array $config */ diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 59548b339..81e529bc3 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -21,6 +21,7 @@ use Symfony\AI\Agent\MultiAgent\Handoff; use Symfony\AI\Agent\MultiAgent\MultiAgent; use Symfony\AI\AiBundle\AiBundle; +use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Store\Document\Filter\TextContainsFilter; use Symfony\AI\Store\Document\Loader\InMemoryLoader; use Symfony\AI\Store\Document\Transformer\TextTrimTransformer; @@ -58,6 +59,30 @@ public function testStoreCommandsArentDefinedWithoutStore() $this->assertSame([ 'ai.command.setup_store' => true, 'ai.command.drop_store' => true, + 'ai.command.setup_message_store' => true, + 'ai.command.drop_message_store' => true, + ], $container->getRemovedIds()); + } + + public function testMessageStoreCommandsArentDefinedWithoutMessageStore() + { + $container = $this->buildContainer([ + 'ai' => [ + 'agent' => [ + 'my_agent' => [ + 'model' => 'gpt-4', + ], + ], + ], + ]); + + $this->assertFalse($container->hasDefinition('ai.command.setup_message_store')); + $this->assertFalse($container->hasDefinition('ai.command.drop_message_store')); + $this->assertSame([ + 'ai.command.setup_store' => true, + 'ai.command.drop_store' => true, + 'ai.command.setup_message_store' => true, + 'ai.command.drop_message_store' => true, ], $container->getRemovedIds()); } @@ -78,6 +103,23 @@ public function testStoreCommandsAreDefined() $this->assertArrayHasKey('console.command', $dropStoreCommandDefinition->getTags()); } + public function testMessageStoreCommandsAreDefined() + { + $container = $this->buildContainer($this->getFullConfig()); + + $this->assertTrue($container->hasDefinition('ai.command.setup_message_store')); + + $setupStoreCommandDefinition = $container->getDefinition('ai.command.setup_message_store'); + $this->assertCount(1, $setupStoreCommandDefinition->getArguments()); + $this->assertArrayHasKey('console.command', $setupStoreCommandDefinition->getTags()); + + $this->assertTrue($container->hasDefinition('ai.command.drop_message_store')); + + $dropStoreCommandDefinition = $container->getDefinition('ai.command.drop_message_store'); + $this->assertCount(1, $dropStoreCommandDefinition->getArguments()); + $this->assertArrayHasKey('console.command', $dropStoreCommandDefinition->getTags()); + } + public function testInjectionAgentAliasIsRegistered() { $container = $this->buildContainer([ @@ -125,6 +167,31 @@ public function testInjectionStoreAliasIsRegistered() $this->assertTrue($container->hasAlias(StoreInterface::class.' $weaviateMain')); } + public function testInjectionMessageStoreAliasIsRegistered() + { + $container = $this->buildContainer([ + 'ai' => [ + 'message_store' => [ + 'memory' => [ + 'main' => [ + 'identifier' => '_memory', + ], + ], + 'session' => [ + 'session' => [ + 'identifier' => 'session', + ], + ], + ], + ], + ]); + + $this->assertTrue($container->hasAlias(MessageStoreInterface::class.' $main')); + $this->assertTrue($container->hasAlias('.'.MessageStoreInterface::class.' $memory_main')); + $this->assertTrue($container->hasAlias(MessageStoreInterface::class.' $session')); + $this->assertTrue($container->hasAlias('.'.MessageStoreInterface::class.' $session_session')); + } + public function testAgentHasTag() { $container = $this->buildContainer([ @@ -2943,6 +3010,27 @@ private function getFullConfig(): array ], ], ], + 'message_store' => [ + 'cache' => [ + 'my_cache_message_store' => [ + 'service' => 'cache.system', + ], + 'my_cache_message_store_with_custom_cache_key' => [ + 'service' => 'cache.system', + 'key' => 'foo', + ], + ], + 'memory' => [ + 'my_memory_message_store' => [ + 'identifier' => '_memory', + ], + ], + 'session' => [ + 'my_session_message_store' => [ + 'identifier' => 'session', + ], + ], + ], 'vectorizer' => [ 'test_vectorizer' => [ 'platform' => 'mistral_platform_service_id', diff --git a/src/chat/composer.json b/src/chat/composer.json index 2a9df48fa..4da6583cc 100644 --- a/src/chat/composer.json +++ b/src/chat/composer.json @@ -27,6 +27,8 @@ "phpstan/phpstan": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5.13", + "symfony/dependency-injection": "^7.3|^8.0", + "symfony/console": "^7.3|^8.0", "symfony/http-foundation": "^7.3|^8.0", "psr/cache": "^3.0" }, diff --git a/src/chat/phpstan.dist.neon b/src/chat/phpstan.dist.neon index 0ea90278a..988f6c4d0 100644 --- a/src/chat/phpstan.dist.neon +++ b/src/chat/phpstan.dist.neon @@ -6,6 +6,7 @@ parameters: paths: - src/ - tests/ + treatPhpDocTypesAsCertain: false ignoreErrors: - message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#" diff --git a/src/chat/src/Bridge/Local/InMemoryStore.php b/src/chat/src/Bridge/Local/InMemoryStore.php index 03fb986b2..362a90472 100644 --- a/src/chat/src/Bridge/Local/InMemoryStore.php +++ b/src/chat/src/Bridge/Local/InMemoryStore.php @@ -20,25 +20,33 @@ */ final class InMemoryStore implements ManagedStoreInterface, MessageStoreInterface { - private MessageBag $messages; + /** + * @var MessageBag[] + */ + private array $messages = []; + + public function __construct( + private readonly string $identifier = '_message_store_memory', + ) { + } public function setup(array $options = []): void { - $this->messages = new MessageBag(); + $this->messages[$this->identifier] = new MessageBag(); } public function save(MessageBag $messages): void { - $this->messages = $messages; + $this->messages[$this->identifier] = $messages; } public function load(): MessageBag { - return $this->messages ?? new MessageBag(); + return $this->messages[$this->identifier] ?? new MessageBag(); } public function drop(): void { - $this->messages = new MessageBag(); + $this->messages[$this->identifier] = new MessageBag(); } } diff --git a/src/chat/src/Command/DropStoreCommand.php b/src/chat/src/Command/DropStoreCommand.php new file mode 100644 index 000000000..196a3f4d4 --- /dev/null +++ b/src/chat/src/Command/DropStoreCommand.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Command; + +use Symfony\AI\Chat\Exception\RuntimeException; +use Symfony\AI\Chat\ManagedStoreInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\ServiceLocator; + +/** + * @author Guillaume Loulier + */ +#[AsCommand(name: 'ai:message-store:drop', description: 'Drop the required infrastructure for the message store')] +final class DropStoreCommand extends Command +{ + /** + * @param ServiceLocator $stores + */ + public function __construct( + private readonly ServiceLocator $stores, + ) { + parent::__construct(); + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('store')) { + $suggestions->suggestValues(array_keys($this->stores->getProvidedServices())); + } + } + + protected function configure(): void + { + $this + ->addArgument('store', InputArgument::REQUIRED, 'Name of the store to drop') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force dropping the message store even if it contains messages') + ->setHelp(<<%command.name% command drop the message store: + + php %command.full_name% +EOF + ) + ; + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $storeName = $input->getArgument('store'); + if (!$this->stores->has($storeName)) { + throw new RuntimeException(\sprintf('The "%s" message store does not exist.', $storeName)); + } + + $store = $this->stores->get($storeName); + if (!$store instanceof ManagedStoreInterface) { + throw new RuntimeException(\sprintf('The "%s" message store does not support to be dropped.', $storeName)); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (!$input->getOption('force')) { + $io->warning('The --force option is required to drop the message store.'); + + return Command::FAILURE; + } + + $storeName = $input->getArgument('store'); + + $store = $this->stores->get($storeName); + + try { + $store->drop(); + $io->success(\sprintf('The "%s" message store was dropped successfully.', $storeName)); + } catch (\Exception $e) { + throw new RuntimeException(\sprintf('An error occurred while dropping the "%s" message store: ', $storeName).$e->getMessage(), previous: $e); + } + + return Command::SUCCESS; + } +} diff --git a/src/chat/src/Command/SetupStoreCommand.php b/src/chat/src/Command/SetupStoreCommand.php new file mode 100644 index 000000000..6d29bac67 --- /dev/null +++ b/src/chat/src/Command/SetupStoreCommand.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Command; + +use Symfony\AI\Chat\Exception\RuntimeException; +use Symfony\AI\Chat\ManagedStoreInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\ServiceLocator; + +/** + * @author Guillaume Loulier + */ +#[AsCommand(name: 'ai:message-store:setup', description: 'Prepare the required infrastructure for the message store')] +final class SetupStoreCommand extends Command +{ + /** + * @param ServiceLocator $stores + */ + public function __construct( + private readonly ServiceLocator $stores, + ) { + parent::__construct(); + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('store')) { + $suggestions->suggestValues(array_keys($this->stores->getProvidedServices())); + } + } + + protected function configure(): void + { + $this + ->addArgument('store', InputArgument::REQUIRED, 'Name of the store to setup') + ->setHelp(<<%command.name% command set up the message store: + + php %command.full_name% +EOF + ) + ; + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $storeName = $input->getArgument('store'); + if (!$this->stores->has($storeName)) { + throw new RuntimeException(\sprintf('The "%s" message store does not exist.', $storeName)); + } + + $store = $this->stores->get($storeName); + if (!$store instanceof ManagedStoreInterface) { + throw new RuntimeException(\sprintf('The "%s" message store does not support setup.', $storeName)); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $storeName = $input->getArgument('store'); + + $store = $this->stores->get($storeName); + + try { + $store->setup(); + $io->success(\sprintf('The "%s" message store was set up successfully.', $storeName)); + } catch (\Exception $e) { + throw new RuntimeException(\sprintf('An error occurred while setting up the "%s" message store: ', $storeName).$e->getMessage(), previous: $e); + } + + return Command::SUCCESS; + } +} diff --git a/src/chat/src/Exception/ExceptionInterface.php b/src/chat/src/Exception/ExceptionInterface.php new file mode 100644 index 000000000..23bb8addb --- /dev/null +++ b/src/chat/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Exception; + +/** + * @author Guillaume Loulier + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/chat/src/Exception/RuntimeException.php b/src/chat/src/Exception/RuntimeException.php new file mode 100644 index 000000000..042c02e7e --- /dev/null +++ b/src/chat/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Exception; + +/** + * @author Guillaume Loulier + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/chat/tests/Command/DropStoreCommandTest.php b/src/chat/tests/Command/DropStoreCommandTest.php new file mode 100644 index 000000000..5cc11b65d --- /dev/null +++ b/src/chat/tests/Command/DropStoreCommandTest.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Chat\Command\DropStoreCommand; +use Symfony\AI\Chat\Exception\RuntimeException; +use Symfony\AI\Chat\ManagedStoreInterface; +use Symfony\AI\Chat\MessageStoreInterface; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\ServiceLocator; + +final class DropStoreCommandTest extends TestCase +{ + public function testCommandIsConfigured() + { + $command = new DropStoreCommand(new ServiceLocator([])); + + $this->assertSame('ai:message-store:drop', $command->getName()); + $this->assertSame('Drop the required infrastructure for the message store', $command->getDescription()); + + $definition = $command->getDefinition(); + $this->assertTrue($definition->hasArgument('store')); + + $storeArgument = $definition->getArgument('store'); + $this->assertSame('Name of the store to drop', $storeArgument->getDescription()); + $this->assertTrue($storeArgument->isRequired()); + } + + public function testCommandCannotDropUndefinedStore() + { + $command = new DropStoreCommand(new ServiceLocator([])); + + $tester = new CommandTester($command); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The "foo" message store does not exist.'); + $this->expectExceptionCode(0); + $tester->execute([ + 'store' => 'foo', + ]); + } + + public function testCommandCannotDropInvalidStore() + { + $store = $this->createMock(MessageStoreInterface::class); + + $command = new DropStoreCommand(new ServiceLocator([ + 'foo' => static fn (): object => $store, + ])); + + $tester = new CommandTester($command); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The "foo" message store does not support to be dropped.'); + $this->expectExceptionCode(0); + $tester->execute([ + 'store' => 'foo', + ]); + } + + public function testCommandCannotDropStoreWithException() + { + $store = $this->createMock(ManagedStoreInterface::class); + $store->expects($this->once())->method('drop')->willThrowException(new RuntimeException('foo')); + + $command = new DropStoreCommand(new ServiceLocator([ + 'foo' => static fn (): object => $store, + ])); + + $tester = new CommandTester($command); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('An error occurred while dropping the "foo" message store: foo'); + $this->expectExceptionCode(0); + $tester->execute([ + 'store' => 'foo', + '--force' => true, + ]); + } + + public function testCommandCannotBeDroppedWithoutForceOption() + { + $store = $this->createMock(ManagedStoreInterface::class); + $store->expects($this->never())->method('drop'); + + $command = new DropStoreCommand(new ServiceLocator([ + 'foo' => static fn (): object => $store, + ])); + + $tester = new CommandTester($command); + + $tester->execute([ + 'store' => 'foo', + ]); + + $this->assertStringContainsString('The --force option is required to drop the message store.', $tester->getDisplay()); + } + + public function testCommandCanDrop() + { + $store = $this->createMock(ManagedStoreInterface::class); + $store->expects($this->once())->method('drop'); + + $command = new DropStoreCommand(new ServiceLocator([ + 'foo' => static fn (): object => $store, + ])); + + $tester = new CommandTester($command); + + $tester->execute([ + 'store' => 'foo', + '--force' => true, + ]); + + $this->assertStringContainsString('The "foo" message store was dropped successfully.', $tester->getDisplay()); + } +} diff --git a/src/chat/tests/Command/SetupStoreCommandTest.php b/src/chat/tests/Command/SetupStoreCommandTest.php new file mode 100644 index 000000000..fc59e9c83 --- /dev/null +++ b/src/chat/tests/Command/SetupStoreCommandTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Chat\Command\SetupStoreCommand; +use Symfony\AI\Chat\Exception\RuntimeException; +use Symfony\AI\Chat\ManagedStoreInterface; +use Symfony\AI\Chat\MessageStoreInterface; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\ServiceLocator; + +final class SetupStoreCommandTest extends TestCase +{ + public function testCommandIsConfigured() + { + $command = new SetupStoreCommand(new ServiceLocator([])); + + $this->assertSame('ai:message-store:setup', $command->getName()); + $this->assertSame('Prepare the required infrastructure for the message store', $command->getDescription()); + + $definition = $command->getDefinition(); + $this->assertTrue($definition->hasArgument('store')); + + $storeArgument = $definition->getArgument('store'); + $this->assertSame('Name of the store to setup', $storeArgument->getDescription()); + $this->assertTrue($storeArgument->isRequired()); + } + + public function testCommandCannotSetupUndefinedStore() + { + $command = new SetupStoreCommand(new ServiceLocator([])); + + $tester = new CommandTester($command); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The "foo" message store does not exist.'); + $this->expectExceptionCode(0); + $tester->execute([ + 'store' => 'foo', + ]); + } + + public function testCommandCannotSetupInvalidStore() + { + $store = $this->createMock(MessageStoreInterface::class); + + $command = new SetupStoreCommand(new ServiceLocator([ + 'foo' => static fn (): object => $store, + ])); + + $tester = new CommandTester($command); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The "foo" message store does not support setup.'); + $this->expectExceptionCode(0); + $tester->execute([ + 'store' => 'foo', + ]); + } + + public function testCommandCannotSetupStoreWithException() + { + $store = $this->createMock(ManagedStoreInterface::class); + $store->expects($this->once())->method('setup')->willThrowException(new RuntimeException('foo')); + + $command = new SetupStoreCommand(new ServiceLocator([ + 'foo' => static fn (): object => $store, + ])); + + $tester = new CommandTester($command); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('An error occurred while setting up the "foo" message store: foo'); + $this->expectExceptionCode(0); + $tester->execute([ + 'store' => 'foo', + ]); + } + + public function testCommandCanSetupDefinedStore() + { + $store = $this->createMock(ManagedStoreInterface::class); + $store->expects($this->once())->method('setup'); + + $command = new SetupStoreCommand(new ServiceLocator([ + 'foo' => static fn (): object => $store, + ])); + + $tester = new CommandTester($command); + + $tester->execute([ + 'store' => 'foo', + ]); + + $this->assertStringContainsString('The "foo" message store was set up successfully.', $tester->getDisplay()); + } +}