diff --git a/docs/bundles/ai-bundle.rst b/docs/bundles/ai-bundle.rst index 336d7c2e4..e50ce5b30 100644 --- a/docs/bundles/ai-bundle.rst +++ b/docs/bundles/ai-bundle.rst @@ -950,6 +950,48 @@ The profiler panel provides insights into the agent's execution: .. image:: profiler.png :alt: Profiler Panel +Message stores +-------------- + +Message stores are critical to store messages sent to agents in the short / long term, they can be configured +and reused in multiple chats, providing the capacity to agents to keep previous interactions. + +Configuring message stores +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Message stores are defined in the ``message_store`` section of your configuration: + +.. code-block:: yaml + + ai: + # ... + message_store: + youtube: + cache: + service: 'cache.app' + key: 'youtube' + +Chats +----- + +Chats are the entrypoint when it comes to sending messages to agents and retrieving content (mostly text) +that contains the response from the agent. + +Each chat requires to define an agent and a message store. + +Configuring Chats +~~~~~~~~~~~~~~~~~ + +Chats are defined in the ``chat`` section of your configuration: + +.. code-block:: yaml + + ai: + # ... + chat: + youtube: + agent: 'ai.agent.youtube' + message_store: 'ai.message_store.cache.youtube' .. _`Symfony AI Agent`: https://github.com/symfony/ai-agent .. _`Symfony AI Chat`: https://github.com/symfony/ai-chat diff --git a/src/ai-bundle/CHANGELOG.md b/src/ai-bundle/CHANGELOG.md index f790ab27b..f85ed1f4a 100644 --- a/src/ai-bundle/CHANGELOG.md +++ b/src/ai-bundle/CHANGELOG.md @@ -33,3 +33,4 @@ CHANGELOG - Automatic registration of token output processors for Mistral, OpenAI and Vertex AI - Token usage metadata in agent results including prompt, completion, total, cached, and thinking tokens - Rate limit information tracking for supported platforms + * Add support for configuring chats and message stores diff --git a/src/ai-bundle/composer.json b/src/ai-bundle/composer.json index 23854b822..359b45098 100644 --- a/src/ai-bundle/composer.json +++ b/src/ai-bundle/composer.json @@ -19,6 +19,7 @@ "symfony/ai-chat": "@dev", "symfony/ai-platform": "@dev", "symfony/ai-store": "@dev", + "symfony/clock": "^7.3|^8.0", "symfony/config": "^7.3|^8.0", "symfony/console": "^7.3|^8.0", "symfony/dependency-injection": "^7.3|^8.0", @@ -30,7 +31,6 @@ "phpstan/phpstan": "^2.1", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5", - "symfony/clock": "^7.3|^8.0", "symfony/expression-language": "^7.3|^8.0", "symfony/security-core": "^7.3|^8.0", "symfony/translation": "^7.3|^8.0" diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index fb92b7f7b..c2ed3f626 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -800,6 +800,15 @@ ->end() ->end() ->end() + ->arrayNode('chat') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('agent')->cannotBeEmpty()->end() + ->stringNode('message_store')->cannotBeEmpty()->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 eace5fb61..18c9a1b0c 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -169,6 +169,8 @@ ->args([ tagged_iterator('ai.traceable_platform'), tagged_iterator('ai.traceable_toolbox'), + tagged_iterator('ai.traceable_message_store'), + tagged_iterator('ai.traceable_chat'), ]) ->tag('data_collector') diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index df55772ca..9f02784d5 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -31,6 +31,8 @@ use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; use Symfony\AI\AiBundle\DependencyInjection\ProcessorCompilerPass; use Symfony\AI\AiBundle\Exception\InvalidArgumentException; +use Symfony\AI\AiBundle\Profiler\TraceableChat; +use Symfony\AI\AiBundle\Profiler\TraceableMessageStore; use Symfony\AI\AiBundle\Profiler\TraceablePlatform; use Symfony\AI\AiBundle\Profiler\TraceableToolbox; use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool; @@ -38,6 +40,8 @@ use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore; use Symfony\AI\Chat\Bridge\Pogocache\MessageStore as PogocacheMessageStore; use Symfony\AI\Chat\Bridge\Redis\MessageStore as RedisMessageStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Chat\ChatInterface; use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicPlatformFactory; use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAiPlatformFactory; @@ -180,11 +184,49 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $builder->setAlias(MessageStoreInterface::class, reset($messageStores)); } + if ($builder->getParameter('kernel.debug')) { + foreach ($messageStores as $messageStore) { + $traceableMessageStoreDefinition = (new Definition(TraceableMessageStore::class)) + ->setDecoratedService($messageStore) + ->setArguments([ + new Reference('.inner'), + new Reference(ClockInterface::class), + ]) + ->addTag('ai.traceable_message_store'); + $suffix = u($messageStore)->afterLast('.')->toString(); + $builder->setDefinition('ai.traceable_message_store.'.$suffix, $traceableMessageStoreDefinition); + } + } + if ([] === $messageStores) { $builder->removeDefinition('ai.command.setup_message_store'); $builder->removeDefinition('ai.command.drop_message_store'); } + foreach ($config['chat'] ?? [] as $name => $chat) { + $this->processChatConfig($name, $chat, $builder); + } + + $chats = array_keys($builder->findTaggedServiceIds('ai.chat')); + + if (1 === \count($chats)) { + $builder->setAlias(ChatInterface::class, reset($chats)); + } + + if ($builder->getParameter('kernel.debug')) { + foreach ($chats as $chat) { + $traceableChatDefinition = (new Definition(TraceableChat::class)) + ->setDecoratedService($chat) + ->setArguments([ + new Reference('.inner'), + new Reference(ClockInterface::class), + ]) + ->addTag('ai.traceable_chat'); + $suffix = u($chat)->afterLast('.')->toString(); + $builder->setDefinition('ai.traceable_chat.'.$suffix, $traceableChatDefinition); + } + } + foreach ($config['vectorizer'] ?? [] as $vectorizerName => $vectorizer) { $this->processVectorizerConfig($vectorizerName, $vectorizer, $builder); } @@ -1435,6 +1477,26 @@ private function processMessageStoreConfig(string $type, array $messageStores, C } } + /** + * @param array{ + * agent: string, + * message_store: string, + * } $configuration + */ + private function processChatConfig(string $name, array $configuration, ContainerBuilder $container): void + { + $definition = new Definition(Chat::class); + $definition + ->setArguments([ + new Reference($configuration['agent']), + new Reference($configuration['message_store']), + ]) + ->addTag('ai.chat'); + + $container->setDefinition('ai.chat.'.$name, $definition); + $container->registerAliasForArgument('ai.chat.'.$name, ChatInterface::class, $name); + } + /** * @param array $config */ diff --git a/src/ai-bundle/src/Profiler/DataCollector.php b/src/ai-bundle/src/Profiler/DataCollector.php index 88e8ac56d..24f5ca503 100644 --- a/src/ai-bundle/src/Profiler/DataCollector.php +++ b/src/ai-bundle/src/Profiler/DataCollector.php @@ -23,6 +23,8 @@ * @author Christopher Hertel * * @phpstan-import-type PlatformCallData from TraceablePlatform + * @phpstan-import-type MessageStoreData from TraceableMessageStore + * @phpstan-import-type ChatData from TraceableChat */ final class DataCollector extends AbstractDataCollector implements LateDataCollectorInterface { @@ -37,15 +39,31 @@ final class DataCollector extends AbstractDataCollector implements LateDataColle private readonly array $toolboxes; /** - * @param TraceablePlatform[] $platforms - * @param TraceableToolbox[] $toolboxes + * @var TraceableMessageStore[] + */ + private readonly array $messageStores; + + /** + * @var TraceableChat[] + */ + private readonly array $chats; + + /** + * @param TraceablePlatform[] $platforms + * @param TraceableToolbox[] $toolboxes + * @param TraceableMessageStore[] $messageStores + * @param TraceableChat[] $chats */ public function __construct( iterable $platforms, iterable $toolboxes, + iterable $messageStores, + iterable $chats, ) { $this->platforms = $platforms instanceof \Traversable ? iterator_to_array($platforms) : $platforms; $this->toolboxes = $toolboxes instanceof \Traversable ? iterator_to_array($toolboxes) : $toolboxes; + $this->messageStores = $messageStores instanceof \Traversable ? iterator_to_array($messageStores) : $messageStores; + $this->chats = $chats instanceof \Traversable ? iterator_to_array($chats) : $chats; } public function collect(Request $request, Response $response, ?\Throwable $exception = null): void @@ -59,6 +77,8 @@ public function lateCollect(): void 'tools' => $this->getAllTools(), 'platform_calls' => array_merge(...array_map($this->awaitCallResults(...), $this->platforms)), 'tool_calls' => array_merge(...array_map(fn (TraceableToolbox $toolbox) => $toolbox->calls, $this->toolboxes)), + 'messages' => array_merge(...array_map(static fn (TraceableMessageStore $messageStore): array => $messageStore->calls, $this->messageStores)), + 'chats' => array_merge(...array_map(static fn (TraceableChat $chat): array => $chat->calls, $this->chats)), ]; } @@ -91,6 +111,22 @@ public function getToolCalls(): array return $this->data['tool_calls'] ?? []; } + /** + * @return MessageStoreData[] + */ + public function getMessages(): array + { + return $this->data['messages'] ?? []; + } + + /** + * @return ChatData[] + */ + public function getChats(): array + { + return $this->data['chats'] ?? []; + } + /** * @return Tool[] */ diff --git a/src/ai-bundle/src/Profiler/TraceableChat.php b/src/ai-bundle/src/Profiler/TraceableChat.php new file mode 100644 index 000000000..f6d834e43 --- /dev/null +++ b/src/ai-bundle/src/Profiler/TraceableChat.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Profiler; + +use Symfony\AI\Chat\ChatInterface; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\Component\Clock\ClockInterface; + +/** + * @author Guillaume Loulier + * + * @phpstan-type ChatData array{ + * action: string, + * bag?: MessageBag, + * message?: UserMessage, + * saved_at: \DateTimeImmutable, + * } + */ +final class TraceableChat implements ChatInterface +{ + /** + * @var array + */ + public array $calls = []; + + public function __construct( + private readonly ChatInterface $chat, + private readonly ClockInterface $clock, + ) { + } + + public function initiate(MessageBag $messages): void + { + $this->calls[] = [ + 'action' => __FUNCTION__, + 'bag' => $messages, + 'saved_at' => $this->clock->now(), + ]; + + $this->chat->initiate($messages); + } + + public function submit(UserMessage $message): AssistantMessage + { + $this->calls[] = [ + 'action' => __FUNCTION__, + 'message' => $message, + 'saved_at' => $this->clock->now(), + ]; + + return $this->chat->submit($message); + } +} diff --git a/src/ai-bundle/src/Profiler/TraceableMessageStore.php b/src/ai-bundle/src/Profiler/TraceableMessageStore.php new file mode 100644 index 000000000..34b426e3e --- /dev/null +++ b/src/ai-bundle/src/Profiler/TraceableMessageStore.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Profiler; + +use Symfony\AI\Chat\ManagedStoreInterface; +use Symfony\AI\Chat\MessageStoreInterface; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\Clock\ClockInterface; + +/** + * @author Guillaume Loulier + * + * @phpstan-type MessageStoreData array{ + * bag: MessageBag, + * saved_at: \DateTimeImmutable, + * } + */ +final class TraceableMessageStore implements ManagedStoreInterface, MessageStoreInterface +{ + /** + * @var MessageStoreData[] + */ + public array $calls = []; + + public function __construct( + private readonly MessageStoreInterface|ManagedStoreInterface $messageStore, + private readonly ClockInterface $clock, + ) { + } + + public function setup(array $options = []): void + { + if (!$this->messageStore instanceof ManagedStoreInterface) { + return; + } + + $this->messageStore->setup($options); + } + + public function save(MessageBag $messages): void + { + $this->calls[] = [ + 'bag' => $messages, + 'saved_at' => $this->clock->now(), + ]; + + $this->messageStore->save($messages); + } + + public function load(): MessageBag + { + return $this->messageStore->load(); + } + + public function drop(): void + { + if (!$this->messageStore instanceof ManagedStoreInterface) { + return; + } + + $this->messageStore->drop(); + } +} diff --git a/src/ai-bundle/templates/data_collector.html.twig b/src/ai-bundle/templates/data_collector.html.twig index b8cbc9345..117bb6d76 100644 --- a/src/ai-bundle/templates/data_collector.html.twig +++ b/src/ai-bundle/templates/data_collector.html.twig @@ -24,6 +24,10 @@ Registered Tools {{ collector.tools|length }} +
+ Messages stored + {{ collector.messages|length }} +
Tool Calls {{ collector.toolCalls|length }} diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 36056cb12..4375d1bf1 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\ChatInterface; use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Store\Document\Filter\TextContainsFilter; use Symfony\AI\Store\Document\Loader\InMemoryLoader; @@ -224,6 +225,41 @@ public function testInjectionMessageStoreAliasIsRegistered() $this->assertTrue($container->hasAlias('.'.MessageStoreInterface::class.' $session_session')); } + public function testInjectionChatAliasIsRegistered() + { + $container = $this->buildContainer([ + 'ai' => [ + 'agent' => [ + 'my_agent' => [ + 'model' => 'gpt-4', + ], + ], + 'message_store' => [ + 'memory' => [ + 'main' => [ + 'identifier' => '_memory', + ], + ], + ], + 'chat' => [ + 'main' => [ + 'agent' => 'ai.agent.my_agent', + 'message_store' => 'ai.message_store.memory.main', + ], + ], + ], + ]); + + $this->assertCount(1, $container->findTaggedServiceIds('ai.chat')); + + $this->assertTrue($container->hasAlias(ChatInterface::class.' $main')); + + $chatDefinition = $container->getDefinition('ai.chat.main'); + $this->assertCount(2, $chatDefinition->getArguments()); + $this->assertInstanceOf(Reference::class, $chatDefinition->getArgument(0)); + $this->assertInstanceOf(Reference::class, $chatDefinition->getArgument(1)); + } + public function testAgentHasTag() { $container = $this->buildContainer([ @@ -3080,6 +3116,12 @@ private function getFullConfig(): array ], ], ], + 'chat' => [ + 'main' => [ + 'agent' => 'my_chat_agent', + 'message_store' => 'cache', + ], + ], 'vectorizer' => [ 'test_vectorizer' => [ 'platform' => 'mistral_platform_service_id', diff --git a/src/ai-bundle/tests/Profiler/DataCollectorTest.php b/src/ai-bundle/tests/Profiler/DataCollectorTest.php index e3f886133..409203083 100644 --- a/src/ai-bundle/tests/Profiler/DataCollectorTest.php +++ b/src/ai-bundle/tests/Profiler/DataCollectorTest.php @@ -12,17 +12,24 @@ namespace Symfony\AI\AiBundle\Tests\Profiler; use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\AgentInterface; use Symfony\AI\AiBundle\Profiler\DataCollector; +use Symfony\AI\AiBundle\Profiler\TraceableChat; +use Symfony\AI\AiBundle\Profiler\TraceableMessageStore; use Symfony\AI\AiBundle\Profiler\TraceablePlatform; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Chat\Chat; use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\UserMessage; use Symfony\AI\Platform\PlatformInterface; use Symfony\AI\Platform\Result\DeferredResult; use Symfony\AI\Platform\Result\RawResultInterface; use Symfony\AI\Platform\Result\StreamResult; use Symfony\AI\Platform\Result\TextResult; use Symfony\AI\Platform\Test\PlainConverter; +use Symfony\Component\Clock\MonotonicClock; class DataCollectorTest extends TestCase { @@ -38,7 +45,7 @@ public function testCollectsDataForNonStreamingResponse() $result = $traceablePlatform->invoke('gpt-4o', $messageBag, ['stream' => false]); $this->assertSame('Assistant response', $result->asText()); - $dataCollector = new DataCollector([$traceablePlatform], []); + $dataCollector = new DataCollector([$traceablePlatform], [], [], []); $dataCollector->lateCollect(); $this->assertCount(1, $dataCollector->getPlatformCalls()); @@ -62,10 +69,51 @@ public function testCollectsDataForStreamingResponse() $result = $traceablePlatform->invoke('gpt-4o', $messageBag, ['stream' => true]); $this->assertSame('Assistant response', implode('', iterator_to_array($result->asStream()))); - $dataCollector = new DataCollector([$traceablePlatform], []); + $dataCollector = new DataCollector([$traceablePlatform], [], [], []); $dataCollector->lateCollect(); $this->assertCount(1, $dataCollector->getPlatformCalls()); $this->assertSame('Assistant response', $dataCollector->getPlatformCalls()[0]['result']); } + + public function testCollectsDataForMessageStore() + { + $traceableMessageStore = new TraceableMessageStore(new InMemoryStore(), new MonotonicClock()); + $traceableMessageStore->save(new MessageBag( + Message::ofUser('Hello World'), + )); + + $dataCollector = new DataCollector([], [], [$traceableMessageStore], []); + $dataCollector->lateCollect(); + + $calls = $dataCollector->getMessages(); + + $this->assertArrayHasKey('bag', $calls[0]); + $this->assertArrayHasKey('saved_at', $calls[0]); + $this->assertInstanceOf(MessageBag::class, $calls[0]['bag']); + $this->assertCount(1, $calls[0]['bag']); + $this->assertInstanceOf(\DateTimeImmutable::class, $calls[0]['saved_at']); + } + + public function testCollectsDataForChat() + { + $agent = $this->createMock(AgentInterface::class); + $agent->expects($this->once())->method('call')->willReturn(new TextResult('foo')); + + $chat = new Chat($agent, new InMemoryStore()); + + $traceableChat = new TraceableChat($chat, new MonotonicClock()); + + $traceableChat->submit(Message::ofUser('Hello World')); + + $dataCollector = new DataCollector([], [], [], [$traceableChat]); + $dataCollector->lateCollect(); + + $calls = $dataCollector->getChats(); + + $this->assertArrayHasKey('message', $calls[0]); + $this->assertArrayHasKey('saved_at', $calls[0]); + $this->assertInstanceOf(UserMessage::class, $calls[0]['message']); + $this->assertInstanceOf(\DateTimeImmutable::class, $calls[0]['saved_at']); + } } diff --git a/src/ai-bundle/tests/Profiler/TraceableChatTest.php b/src/ai-bundle/tests/Profiler/TraceableChatTest.php new file mode 100644 index 000000000..525481f14 --- /dev/null +++ b/src/ai-bundle/tests/Profiler/TraceableChatTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Tests\Profiler; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\AiBundle\Profiler\TraceableChat; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\Component\Clock\MonotonicClock; + +final class TraceableChatTest extends TestCase +{ + public function testInitializationMessageBagCanBeRetrieved() + { + $agent = $this->createMock(AgentInterface::class); + $agent->expects($this->once())->method('call')->willReturn(new TextResult('foo')); + + $chat = new Chat($agent, new InMemoryStore()); + + $traceableChat = new TraceableChat($chat, new MonotonicClock()); + + $this->assertCount(0, $traceableChat->calls); + + $traceableChat->initiate(new MessageBag( + Message::ofUser('Hello World'), + )); + + $this->assertCount(1, $traceableChat->calls); + + $this->assertArrayHasKey('action', $traceableChat->calls[0]); + $this->assertArrayHasKey('bag', $traceableChat->calls[0]); + $this->assertArrayHasKey('saved_at', $traceableChat->calls[0]); + $this->assertSame('initiate', $traceableChat->calls[0]['action']); + $this->assertInstanceOf(MessageBag::class, $traceableChat->calls[0]['bag']); + $this->assertCount(1, $traceableChat->calls[0]['bag']); + $this->assertInstanceOf(\DateTimeImmutable::class, $traceableChat->calls[0]['saved_at']); + + $traceableChat->submit(Message::ofUser('Second Hello world')); + + $this->assertCount(2, $traceableChat->calls); + + $this->assertArrayHasKey('action', $traceableChat->calls[1]); + $this->assertArrayHasKey('message', $traceableChat->calls[1]); + $this->assertArrayHasKey('saved_at', $traceableChat->calls[1]); + $this->assertSame('submit', $traceableChat->calls[1]['action']); + $this->assertInstanceOf(UserMessage::class, $traceableChat->calls[1]['message']); + $this->assertInstanceOf(\DateTimeImmutable::class, $traceableChat->calls[1]['saved_at']); + } +} diff --git a/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php b/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php new file mode 100644 index 000000000..7e59ce5da --- /dev/null +++ b/src/ai-bundle/tests/Profiler/TraceableMessageStoreTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Tests\Profiler; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\AiBundle\Profiler\TraceableMessageStore; +use Symfony\AI\Chat\Bridge\Local\InMemoryStore; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\Clock\MonotonicClock; + +final class TraceableMessageStoreTest extends TestCase +{ + public function testSubmittedMessageBagCanBeRetrieved() + { + $messageStore = new InMemoryStore(); + + $traceableMessageStore = new TraceableMessageStore($messageStore, new MonotonicClock()); + + $this->assertCount(0, $traceableMessageStore->calls); + + $traceableMessageStore->save(new MessageBag( + Message::ofUser('Hello World'), + )); + + $this->assertCount(1, $traceableMessageStore->calls); + + $calls = $traceableMessageStore->calls; + + $this->assertArrayHasKey('bag', $calls[0]); + $this->assertArrayHasKey('saved_at', $calls[0]); + $this->assertInstanceOf(MessageBag::class, $calls[0]['bag']); + $this->assertCount(1, $calls[0]['bag']); + $this->assertInstanceOf(\DateTimeImmutable::class, $calls[0]['saved_at']); + } +}