import uuid import pytest # Import shared fixtures and constants from conftest from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall, Function as OpenAIFunction from letta.orm.errors import UniqueConstraintViolationError from letta.schemas.enums import ( MessageRole, ) from letta.schemas.letta_message import UpdateAssistantMessage, UpdateReasoningMessage, UpdateSystemMessage, UpdateUserMessage from letta.schemas.letta_message_content import TextContent from letta.schemas.message import Message as PydanticMessage, MessageUpdate from letta.server.server import SyncServer # ====================================================================================================================== # AgentManager Tests - Messages Relationship # ====================================================================================================================== @pytest.mark.asyncio async def test_reset_messages_no_messages(server: SyncServer, sarah_agent, default_user): """ Test that resetting messages on an agent clears message_ids to only system message, but messages remain in the database. """ assert len(sarah_agent.message_ids) == 4 og_message_ids = sarah_agent.message_ids # Reset messages reset_agent = await server.agent_manager.reset_messages_async(agent_id=sarah_agent.id, actor=default_user) assert len(reset_agent.message_ids) == 1 assert og_message_ids[0] == reset_agent.message_ids[0] # Messages should still exist in the database (only cleared from context, not deleted) assert await server.message_manager.size_async(agent_id=sarah_agent.id, actor=default_user) == 4 @pytest.mark.asyncio async def test_reset_messages_default_messages(server: SyncServer, sarah_agent, default_user): """ Test that resetting messages with add_default_initial_messages=True clears context and adds new default messages, while old messages remain in database. """ assert len(sarah_agent.message_ids) == 4 og_message_ids = sarah_agent.message_ids # Reset messages reset_agent = await server.agent_manager.reset_messages_async( agent_id=sarah_agent.id, actor=default_user, add_default_initial_messages=True ) assert len(reset_agent.message_ids) == 4 assert og_message_ids[0] == reset_agent.message_ids[0] assert og_message_ids[1] != reset_agent.message_ids[1] assert og_message_ids[2] != reset_agent.message_ids[2] assert og_message_ids[3] != reset_agent.message_ids[3] # Old messages (4) + new default messages (3) = 7 total in database # (system message is preserved, so 4 old + 3 new non-system = 7) assert await server.message_manager.size_async(agent_id=sarah_agent.id, actor=default_user) == 7 @pytest.mark.asyncio async def test_reset_messages_with_existing_messages(server: SyncServer, sarah_agent, default_user): """ Test that resetting messages on an agent with actual messages clears message_ids but keeps messages in the database. """ # 1. Create multiple messages for the agent msg1 = await server.message_manager.create_many_messages_async( [ PydanticMessage( agent_id=sarah_agent.id, role="user", content=[TextContent(text="Hello, Sarah!")], ), ], actor=default_user, ) msg1 = msg1[0] msg2 = await server.message_manager.create_many_messages_async( [ PydanticMessage( agent_id=sarah_agent.id, role="assistant", content=[TextContent(text="Hello, user!")], ), ], actor=default_user, ) msg2 = msg2[0] # Verify the messages were created agent_before = await server.agent_manager.get_agent_by_id_async(sarah_agent.id, default_user) # This is 4 because creating the message does not necessarily add it to the in context message ids assert len(agent_before.message_ids) == 4 assert await server.message_manager.size_async(agent_id=sarah_agent.id, actor=default_user) == 6 # 2. Reset all messages reset_agent = await server.agent_manager.reset_messages_async(agent_id=sarah_agent.id, actor=default_user) # 3. Verify the agent now has only system message in context assert len(reset_agent.message_ids) == 1 # 4. Verify the messages still exist in the database (only cleared from context) assert await server.message_manager.size_async(agent_id=sarah_agent.id, actor=default_user) == 6 @pytest.mark.asyncio async def test_reset_messages_idempotency(server: SyncServer, sarah_agent, default_user): """ Test that calling reset_messages multiple times has no adverse effect. """ # Clear messages first (actually delete from DB for this test setup) await server.message_manager.delete_messages_by_ids_async(message_ids=sarah_agent.message_ids[1:], actor=default_user) # Create a single message await server.message_manager.create_many_messages_async( [ PydanticMessage( agent_id=sarah_agent.id, role="user", content=[TextContent(text="Hello, Sarah!")], ), ], actor=default_user, ) # First reset - clears context but messages remain in DB reset_agent = await server.agent_manager.reset_messages_async(agent_id=sarah_agent.id, actor=default_user) assert len(reset_agent.message_ids) == 1 # DB has system message + the user message we created = 2 assert await server.message_manager.size_async(agent_id=sarah_agent.id, actor=default_user) == 2 # Second reset should do nothing new reset_agent_again = await server.agent_manager.reset_messages_async(agent_id=sarah_agent.id, actor=default_user) assert len(reset_agent_again.message_ids) == 1 assert await server.message_manager.size_async(agent_id=sarah_agent.id, actor=default_user) == 2 @pytest.mark.asyncio async def test_reset_messages_preserves_system_message_id(server: SyncServer, sarah_agent, default_user): """ Test that resetting messages preserves the original system message ID. """ # Get the original system message ID original_agent = await server.agent_manager.get_agent_by_id_async(sarah_agent.id, default_user) original_system_message_id = original_agent.message_ids[0] # Add some user messages await server.message_manager.create_many_messages_async( [ PydanticMessage( agent_id=sarah_agent.id, role="user", content=[TextContent(text="Hello!")], ), ], actor=default_user, ) # Reset messages reset_agent = await server.agent_manager.reset_messages_async(agent_id=sarah_agent.id, actor=default_user) # Verify the system message ID is preserved assert len(reset_agent.message_ids) == 1 assert reset_agent.message_ids[0] == original_system_message_id # Verify the system message still exists in the database system_message = await server.message_manager.get_message_by_id_async(message_id=original_system_message_id, actor=default_user) assert system_message.role == "system" @pytest.mark.asyncio async def test_reset_messages_preserves_system_message_content(server: SyncServer, sarah_agent, default_user): """ Test that resetting messages preserves the original system message content. """ # Get the original system message original_agent = await server.agent_manager.get_agent_by_id_async(sarah_agent.id, default_user) original_system_message = await server.message_manager.get_message_by_id_async( message_id=original_agent.message_ids[0], actor=default_user ) # Add some messages and reset await server.message_manager.create_many_messages_async( [ PydanticMessage( agent_id=sarah_agent.id, role="user", content=[TextContent(text="Hello!")], ), ], actor=default_user, ) reset_agent = await server.agent_manager.reset_messages_async(agent_id=sarah_agent.id, actor=default_user) # Verify the system message content is unchanged preserved_system_message = await server.message_manager.get_message_by_id_async( message_id=reset_agent.message_ids[0], actor=default_user ) assert preserved_system_message.content == original_system_message.content assert preserved_system_message.role == "system" assert preserved_system_message.id == original_system_message.id @pytest.mark.asyncio async def test_modify_letta_message(server: SyncServer, sarah_agent, default_user): """ Test updating a message. """ messages = await server.message_manager.list_messages(agent_id=sarah_agent.id, actor=default_user) letta_messages = PydanticMessage.to_letta_messages_from_list(messages=messages) system_message = next(msg for msg in letta_messages if msg.message_type == "system_message") assistant_message = next(msg for msg in letta_messages if msg.message_type == "assistant_message") user_message = next(msg for msg in letta_messages if msg.message_type == "user_message") reasoning_message = next(msg for msg in letta_messages if msg.message_type == "reasoning_message") # user message update_user_message = UpdateUserMessage(content="Hello, Sarah!") original_user_message = await server.message_manager.get_message_by_id_async(message_id=user_message.id, actor=default_user) assert original_user_message.content[0].text != update_user_message.content await server.message_manager.update_message_by_letta_message_async( message_id=user_message.id, letta_message_update=update_user_message, actor=default_user ) updated_user_message = await server.message_manager.get_message_by_id_async(message_id=user_message.id, actor=default_user) assert updated_user_message.content[0].text == update_user_message.content # system message update_system_message = UpdateSystemMessage(content="You are a friendly assistant!") original_system_message = await server.message_manager.get_message_by_id_async(message_id=system_message.id, actor=default_user) assert original_system_message.content[0].text != update_system_message.content await server.message_manager.update_message_by_letta_message_async( message_id=system_message.id, letta_message_update=update_system_message, actor=default_user ) updated_system_message = await server.message_manager.get_message_by_id_async(message_id=system_message.id, actor=default_user) assert updated_system_message.content[0].text == update_system_message.content # reasoning message update_reasoning_message = UpdateReasoningMessage(reasoning="I am thinking") original_reasoning_message = await server.message_manager.get_message_by_id_async(message_id=reasoning_message.id, actor=default_user) assert original_reasoning_message.content[0].text != update_reasoning_message.reasoning await server.message_manager.update_message_by_letta_message_async( message_id=reasoning_message.id, letta_message_update=update_reasoning_message, actor=default_user ) updated_reasoning_message = await server.message_manager.get_message_by_id_async(message_id=reasoning_message.id, actor=default_user) assert updated_reasoning_message.content[0].text == update_reasoning_message.reasoning # assistant message def parse_send_message(tool_call): import json function_call = tool_call.function arguments = json.loads(function_call.arguments) return arguments["message"] update_assistant_message = UpdateAssistantMessage(content="I am an agent!") original_assistant_message = await server.message_manager.get_message_by_id_async(message_id=assistant_message.id, actor=default_user) print("ORIGINAL", original_assistant_message.tool_calls) print("MESSAGE", parse_send_message(original_assistant_message.tool_calls[0])) assert parse_send_message(original_assistant_message.tool_calls[0]) != update_assistant_message.content await server.message_manager.update_message_by_letta_message_async( message_id=assistant_message.id, letta_message_update=update_assistant_message, actor=default_user ) updated_assistant_message = await server.message_manager.get_message_by_id_async(message_id=assistant_message.id, actor=default_user) print("UPDATED", updated_assistant_message.tool_calls) print("MESSAGE", parse_send_message(updated_assistant_message.tool_calls[0])) assert parse_send_message(updated_assistant_message.tool_calls[0]) == update_assistant_message.content # TODO: tool calls/responses @pytest.mark.asyncio async def test_message_create(server: SyncServer, hello_world_message_fixture, default_user): """Test creating a message using hello_world_message_fixture fixture""" assert hello_world_message_fixture.id is not None assert hello_world_message_fixture.content[0].text == "Hello, world!" assert hello_world_message_fixture.role == "user" # Verify we can retrieve it retrieved = await server.message_manager.get_message_by_id_async( hello_world_message_fixture.id, actor=default_user, ) assert retrieved is not None assert retrieved.id == hello_world_message_fixture.id assert retrieved.content[0].text == hello_world_message_fixture.content[0].text assert retrieved.role == hello_world_message_fixture.role @pytest.mark.asyncio async def test_message_get_by_id(server: SyncServer, hello_world_message_fixture, default_user): """Test retrieving a message by ID""" retrieved = await server.message_manager.get_message_by_id_async(hello_world_message_fixture.id, actor=default_user) assert retrieved is not None assert retrieved.id == hello_world_message_fixture.id assert retrieved.content[0].text == hello_world_message_fixture.content[0].text @pytest.mark.asyncio async def test_message_update(server: SyncServer, hello_world_message_fixture, default_user, other_user): """Test updating a message""" new_text = "Updated text" updated = await server.message_manager.update_message_by_id_async( hello_world_message_fixture.id, MessageUpdate(content=new_text), actor=other_user ) assert updated is not None assert updated.content[0].text == new_text retrieved = await server.message_manager.get_message_by_id_async(hello_world_message_fixture.id, actor=default_user) assert retrieved.content[0].text == new_text # Assert that orm metadata fields are populated assert retrieved.created_by_id == default_user.id assert retrieved.last_updated_by_id == other_user.id @pytest.mark.asyncio async def test_message_delete(server: SyncServer, hello_world_message_fixture, default_user): """Test deleting a message""" await server.message_manager.delete_message_by_id_async(hello_world_message_fixture.id, actor=default_user) retrieved = await server.message_manager.get_message_by_id_async(hello_world_message_fixture.id, actor=default_user) assert retrieved is None @pytest.mark.asyncio async def test_message_conversation_id_persistence(server: SyncServer, sarah_agent, default_user): """Test that conversation_id is properly persisted and retrieved from DB to Pydantic object""" from letta.schemas.conversation import CreateConversation from letta.services.conversation_manager import ConversationManager conversation_manager = ConversationManager() # Test 1: Create a message without conversation_id (should be None - the default/backward-compat case) message_no_conv = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text="Test message without conversation")], ) created_no_conv = await server.message_manager.create_many_messages_async([message_no_conv], actor=default_user) assert len(created_no_conv) == 1 assert created_no_conv[0].conversation_id is None # Verify retrieval also has None - this confirms ORM-to-Pydantic conversion works for None retrieved_no_conv = await server.message_manager.get_message_by_id_async(created_no_conv[0].id, actor=default_user) assert retrieved_no_conv is not None assert retrieved_no_conv.conversation_id is None assert retrieved_no_conv.id == created_no_conv[0].id # Test 2: Create a conversation and a message with that conversation_id conversation = await conversation_manager.create_conversation( agent_id=sarah_agent.id, conversation_create=CreateConversation(summary="Test conversation"), actor=default_user, ) message_with_conv = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text="Test message with conversation")], conversation_id=conversation.id, ) created_with_conv = await server.message_manager.create_many_messages_async([message_with_conv], actor=default_user) assert len(created_with_conv) == 1 assert created_with_conv[0].conversation_id == conversation.id # Verify retrieval has the correct conversation_id - this confirms ORM-to-Pydantic conversion works for non-None retrieved_with_conv = await server.message_manager.get_message_by_id_async(created_with_conv[0].id, actor=default_user) assert retrieved_with_conv is not None assert retrieved_with_conv.conversation_id == conversation.id assert retrieved_with_conv.id == created_with_conv[0].id # Test 3: Verify the field exists on the Pydantic model assert hasattr(retrieved_with_conv, "conversation_id") @pytest.mark.asyncio async def test_message_size(server: SyncServer, hello_world_message_fixture, default_user): """Test counting messages with filters""" base_message = hello_world_message_fixture # Create additional test messages messages = [ PydanticMessage( agent_id=base_message.agent_id, role=base_message.role, content=[TextContent(text=f"Test message {i}")], ) for i in range(4) ] await server.message_manager.create_many_messages_async(messages, actor=default_user) # Test total count total = await server.message_manager.size_async(actor=default_user, role=MessageRole.user) assert total == 6 # login message + base message + 4 test messages # TODO: change login message to be a system not user message # Test count with agent filter agent_count = await server.message_manager.size_async(actor=default_user, agent_id=base_message.agent_id, role=MessageRole.user) assert agent_count == 6 # Test count with role filter role_count = await server.message_manager.size_async(actor=default_user, role=base_message.role) assert role_count == 6 # Test count with non-existent filter empty_count = await server.message_manager.size_async(actor=default_user, agent_id="non-existent", role=MessageRole.user) assert empty_count == 0 async def create_test_messages(server: SyncServer, base_message: PydanticMessage, default_user) -> list[PydanticMessage]: """Helper function to create test messages for all tests""" messages = [ PydanticMessage( agent_id=base_message.agent_id, role=base_message.role, content=[TextContent(text=f"Test message {i}")], ) for i in range(4) ] await server.message_manager.create_many_messages_async(messages, actor=default_user) return messages @pytest.mark.asyncio async def test_get_messages_by_ids(server: SyncServer, hello_world_message_fixture, default_user, sarah_agent): """Test basic message listing with limit""" messages = await create_test_messages(server, hello_world_message_fixture, default_user) message_ids = [m.id for m in messages] results = await server.message_manager.get_messages_by_ids_async(message_ids=message_ids, actor=default_user) assert sorted(message_ids) == sorted([r.id for r in results]) @pytest.mark.asyncio async def test_message_listing_basic(server: SyncServer, hello_world_message_fixture, default_user, sarah_agent): """Test basic message listing with limit""" await create_test_messages(server, hello_world_message_fixture, default_user) results = await server.message_manager.list_user_messages_for_agent_async(agent_id=sarah_agent.id, limit=3, actor=default_user) assert len(results) == 3 @pytest.mark.asyncio async def test_message_listing_cursor(server: SyncServer, hello_world_message_fixture, default_user, sarah_agent): """Test cursor-based pagination functionality""" await create_test_messages(server, hello_world_message_fixture, default_user) # Make sure there are 6 messages assert await server.message_manager.size_async(actor=default_user, role=MessageRole.user) == 6 # Get first page first_page = await server.message_manager.list_user_messages_for_agent_async(agent_id=sarah_agent.id, actor=default_user, limit=3) assert len(first_page) == 3 last_id_on_first_page = first_page[-1].id # Get second page second_page = await server.message_manager.list_user_messages_for_agent_async( agent_id=sarah_agent.id, actor=default_user, after=last_id_on_first_page, limit=3 ) assert len(second_page) == 3 # Should have 3 remaining messages assert all(r1.id != r2.id for r1 in first_page for r2 in second_page) # Get the middle middle_page = await server.message_manager.list_user_messages_for_agent_async( agent_id=sarah_agent.id, actor=default_user, before=second_page[1].id, after=first_page[0].id ) assert len(middle_page) == 3 assert middle_page[0].id == first_page[1].id assert middle_page[1].id == first_page[-1].id assert middle_page[-1].id == second_page[0].id middle_page_desc = await server.message_manager.list_user_messages_for_agent_async( agent_id=sarah_agent.id, actor=default_user, before=second_page[1].id, after=first_page[0].id, ascending=False ) assert len(middle_page_desc) == 3 assert middle_page_desc[0].id == second_page[0].id assert middle_page_desc[1].id == first_page[-1].id assert middle_page_desc[-1].id == first_page[1].id @pytest.mark.asyncio async def test_message_listing_filtering(server: SyncServer, hello_world_message_fixture, default_user, sarah_agent): """Test filtering messages by agent ID""" await create_test_messages(server, hello_world_message_fixture, default_user) agent_results = await server.message_manager.list_user_messages_for_agent_async(agent_id=sarah_agent.id, actor=default_user, limit=10) assert len(agent_results) == 6 # login message + base message + 4 test messages assert all(msg.agent_id == hello_world_message_fixture.agent_id for msg in agent_results) @pytest.mark.asyncio async def test_message_listing_text_search(server: SyncServer, hello_world_message_fixture, default_user, sarah_agent): """Test searching messages by text content""" await create_test_messages(server, hello_world_message_fixture, default_user) search_results = await server.message_manager.list_user_messages_for_agent_async( agent_id=sarah_agent.id, actor=default_user, query_text="Test message", limit=10 ) assert len(search_results) == 4 assert all("Test message" in msg.content[0].text for msg in search_results) # Test no results search_results = await server.message_manager.list_user_messages_for_agent_async( agent_id=sarah_agent.id, actor=default_user, query_text="Letta", limit=10 ) assert len(search_results) == 0 @pytest.mark.asyncio async def test_create_many_messages_async_basic(server: SyncServer, sarah_agent, default_user): """Test basic batch creation of messages""" message_manager = server.message_manager messages = [] for i in range(5): msg = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text=f"Test message {i}")], name=None, tool_calls=None, tool_call_id=None, ) messages.append(msg) created_messages = await message_manager.create_many_messages_async(pydantic_msgs=messages, actor=default_user) assert len(created_messages) == 5 for i, msg in enumerate(created_messages): assert msg.id is not None assert msg.content[0].text == f"Test message {i}" assert msg.agent_id == sarah_agent.id @pytest.mark.asyncio async def test_create_many_messages_async_allow_partial_false(server: SyncServer, sarah_agent, default_user): """Test that allow_partial=False (default) fails on duplicate IDs""" message_manager = server.message_manager initial_msg = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text="Initial message")], ) created = await message_manager.create_many_messages_async(pydantic_msgs=[initial_msg], actor=default_user) assert len(created) == 1 created_msg = created[0] duplicate_msg = PydanticMessage( id=created_msg.id, agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text="Duplicate message")], ) with pytest.raises(UniqueConstraintViolationError): await message_manager.create_many_messages_async(pydantic_msgs=[duplicate_msg], actor=default_user, allow_partial=False) @pytest.mark.asyncio async def test_create_many_messages_async_allow_partial_true_some_duplicates(server: SyncServer, sarah_agent, default_user): """Test that allow_partial=True handles partial duplicates correctly""" message_manager = server.message_manager initial_messages = [] for i in range(3): msg = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text=f"Existing message {i}")], ) initial_messages.append(msg) created_initial = await message_manager.create_many_messages_async(pydantic_msgs=initial_messages, actor=default_user) assert len(created_initial) == 3 existing_ids = [msg.id for msg in created_initial] mixed_messages = [] for created_msg in created_initial: duplicate_msg = PydanticMessage( id=created_msg.id, agent_id=sarah_agent.id, role=MessageRole.user, content=created_msg.content, ) mixed_messages.append(duplicate_msg) for i in range(3, 6): msg = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text=f"New message {i}")], ) mixed_messages.append(msg) result = await message_manager.create_many_messages_async(pydantic_msgs=mixed_messages, actor=default_user, allow_partial=True) assert len(result) == 6 result_ids = {msg.id for msg in result} for existing_id in existing_ids: assert existing_id in result_ids @pytest.mark.asyncio async def test_create_many_messages_async_allow_partial_true_all_duplicates(server: SyncServer, sarah_agent, default_user): """Test that allow_partial=True handles all duplicates correctly""" message_manager = server.message_manager initial_messages = [] for i in range(3): msg = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text=f"Message {i}")], ) initial_messages.append(msg) created_initial = await message_manager.create_many_messages_async(pydantic_msgs=initial_messages, actor=default_user) assert len(created_initial) == 3 duplicate_messages = [] for created_msg in created_initial: duplicate_msg = PydanticMessage( id=created_msg.id, agent_id=sarah_agent.id, role=MessageRole.user, content=created_msg.content, ) duplicate_messages.append(duplicate_msg) result = await message_manager.create_many_messages_async(pydantic_msgs=duplicate_messages, actor=default_user, allow_partial=True) assert len(result) == 3 for i, msg in enumerate(result): assert msg.id == created_initial[i].id assert msg.content[0].text == f"Message {i}" @pytest.mark.asyncio async def test_create_many_messages_async_empty_list(server: SyncServer, default_user): """Test that empty list returns empty list""" message_manager = server.message_manager result = await message_manager.create_many_messages_async(pydantic_msgs=[], actor=default_user) assert result == [] @pytest.mark.asyncio async def test_check_existing_message_ids(server: SyncServer, sarah_agent, default_user): """Test the check_existing_message_ids convenience function""" message_manager = server.message_manager messages = [] for i in range(3): msg = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text=f"Message {i}")], ) messages.append(msg) created_messages = await message_manager.create_many_messages_async(pydantic_msgs=messages, actor=default_user) existing_ids = [msg.id for msg in created_messages] non_existent_ids = [f"message-{uuid.uuid4().hex[:8]}" for _ in range(3)] all_ids = existing_ids + non_existent_ids existing = await message_manager.check_existing_message_ids(message_ids=all_ids, actor=default_user) assert existing == set(existing_ids) for non_existent_id in non_existent_ids: assert non_existent_id not in existing @pytest.mark.asyncio async def test_filter_existing_messages(server: SyncServer, sarah_agent, default_user): """Test the filter_existing_messages helper function""" message_manager = server.message_manager initial_messages = [] for i in range(3): msg = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text=f"Existing {i}")], ) initial_messages.append(msg) created_existing = await message_manager.create_many_messages_async(pydantic_msgs=initial_messages, actor=default_user) existing_messages = [] for created_msg in created_existing: msg = PydanticMessage( id=created_msg.id, agent_id=sarah_agent.id, role=MessageRole.user, content=created_msg.content, ) existing_messages.append(msg) new_messages = [] for i in range(3): msg = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text=f"New {i}")], ) new_messages.append(msg) all_messages = existing_messages + new_messages new_filtered, existing_filtered = await message_manager.filter_existing_messages(messages=all_messages, actor=default_user) assert len(new_filtered) == 3 assert len(existing_filtered) == 3 existing_filtered_ids = {msg.id for msg in existing_filtered} for created_msg in created_existing: assert created_msg.id in existing_filtered_ids for msg in new_filtered: assert msg.id not in existing_filtered_ids @pytest.mark.asyncio async def test_create_many_messages_async_with_turbopuffer(server: SyncServer, sarah_agent, default_user): """Test batch creation with turbopuffer embedding (if enabled)""" message_manager = server.message_manager messages = [] for i in range(3): msg = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.user, content=[TextContent(text=f"Important information about topic {i}")], ) messages.append(msg) created_messages = await message_manager.create_many_messages_async( pydantic_msgs=messages, actor=default_user, strict_mode=True, project_id="test_project", template_id="test_template" ) assert len(created_messages) == 3 for msg in created_messages: assert msg.id is not None assert msg.agent_id == sarah_agent.id # ====================================================================================================================== # Pydantic Object Tests - Tool Call Message Conversion # ====================================================================================================================== @pytest.mark.asyncio async def test_convert_tool_call_messages_no_assistant_mode(server: SyncServer, sarah_agent, default_user): """Test that when assistant mode is off, all tool calls go into a single ToolCallMessage""" # create a message with multiple tool calls tool_calls = [ OpenAIToolCall( id="call_1", type="function", function=OpenAIFunction(name="archival_memory_insert", arguments='{"content": "test memory 1"}') ), OpenAIToolCall( id="call_2", type="function", function=OpenAIFunction(name="conversation_search", arguments='{"query": "test search"}') ), OpenAIToolCall(id="call_3", type="function", function=OpenAIFunction(name="send_message", arguments='{"message": "Hello there!"}')), ] message = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.assistant, content=[TextContent(text="Let me help you with that.")], tool_calls=tool_calls, ) # convert without assistant mode (reverse=True by default) letta_messages = message.to_letta_messages(use_assistant_message=False) # should have 2 messages in reverse order: tool call message, then reasoning message assert len(letta_messages) == 2 assert letta_messages[0].message_type == "tool_call_message" assert letta_messages[1].message_type == "reasoning_message" # check the tool call message has all tool calls in the new field tool_call_msg = letta_messages[0] assert tool_call_msg.tool_calls is not None assert len(tool_call_msg.tool_calls) == 3 # check backwards compatibility - first tool call in deprecated field assert tool_call_msg.tool_call is not None assert tool_call_msg.tool_call.name == "archival_memory_insert" assert tool_call_msg.tool_call.tool_call_id == "call_1" # verify all tool calls are present in the list tool_names = [tc.name for tc in tool_call_msg.tool_calls] assert "archival_memory_insert" in tool_names assert "conversation_search" in tool_names assert "send_message" in tool_names @pytest.mark.asyncio async def test_convert_tool_call_messages_with_assistant_mode(server: SyncServer, sarah_agent, default_user): """Test that with assistant mode, send_message becomes AssistantMessage and others are grouped""" # create a message with tool calls including send_message tool_calls = [ OpenAIToolCall( id="call_1", type="function", function=OpenAIFunction(name="archival_memory_insert", arguments='{"content": "test memory 1"}') ), OpenAIToolCall(id="call_2", type="function", function=OpenAIFunction(name="send_message", arguments='{"message": "Hello there!"}')), OpenAIToolCall( id="call_3", type="function", function=OpenAIFunction(name="conversation_search", arguments='{"query": "test search"}') ), ] message = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.assistant, content=[TextContent(text="Let me help you with that.")], tool_calls=tool_calls, ) # convert with assistant mode (reverse=True by default) letta_messages = message.to_letta_messages(use_assistant_message=True) # should have 4 messages in reverse order: # conversation_search tool call, assistant message, archival_memory tool call, reasoning assert len(letta_messages) == 4 assert letta_messages[0].message_type == "tool_call_message" assert letta_messages[1].message_type == "assistant_message" assert letta_messages[2].message_type == "tool_call_message" assert letta_messages[3].message_type == "reasoning_message" # check first tool call message (actually the last in forward order) has conversation_search first_tool_msg = letta_messages[0] assert len(first_tool_msg.tool_calls) == 1 assert first_tool_msg.tool_calls[0].name == "conversation_search" assert first_tool_msg.tool_call.name == "conversation_search" # backwards compat # check assistant message content assistant_msg = letta_messages[1] assert assistant_msg.content == "Hello there!" # check last tool call message (actually the first in forward order) has archival_memory_insert last_tool_msg = letta_messages[2] assert len(last_tool_msg.tool_calls) == 1 assert last_tool_msg.tool_calls[0].name == "archival_memory_insert" assert last_tool_msg.tool_call.name == "archival_memory_insert" # backwards compat @pytest.mark.asyncio async def test_convert_tool_call_messages_multiple_non_assistant_tools(server: SyncServer, sarah_agent, default_user): """Test that multiple non-assistant tools are batched together until assistant tool is reached""" tool_calls = [ OpenAIToolCall( id="call_1", type="function", function=OpenAIFunction(name="archival_memory_insert", arguments='{"content": "memory 1"}') ), OpenAIToolCall( id="call_2", type="function", function=OpenAIFunction(name="conversation_search", arguments='{"query": "search 1"}') ), OpenAIToolCall( id="call_3", type="function", function=OpenAIFunction(name="archival_memory_search", arguments='{"query": "archive search"}') ), OpenAIToolCall( id="call_4", type="function", function=OpenAIFunction(name="send_message", arguments='{"message": "Results found!"}') ), ] message = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.assistant, content=[TextContent(text="Processing...")], tool_calls=tool_calls, ) # convert with assistant mode (reverse=True by default) letta_messages = message.to_letta_messages(use_assistant_message=True) # should have 3 messages in reverse order: assistant, tool call (with 3 tools), reasoning assert len(letta_messages) == 3 assert letta_messages[0].message_type == "assistant_message" assert letta_messages[1].message_type == "tool_call_message" assert letta_messages[2].message_type == "reasoning_message" # check the tool call message has all 3 non-assistant tools tool_msg = letta_messages[1] assert len(tool_msg.tool_calls) == 3 tool_names = [tc.name for tc in tool_msg.tool_calls] assert "archival_memory_insert" in tool_names assert "conversation_search" in tool_names assert "archival_memory_search" in tool_names # check backwards compat field has first tool assert tool_msg.tool_call.name == "archival_memory_insert" # check assistant message assert letta_messages[0].content == "Results found!" @pytest.mark.asyncio async def test_convert_single_tool_call_both_fields(server: SyncServer, sarah_agent, default_user): """Test that a single tool call is written to both tool_call and tool_calls fields""" tool_calls = [ OpenAIToolCall( id="call_1", type="function", function=OpenAIFunction(name="archival_memory_insert", arguments='{"content": "single tool call"}'), ), ] message = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.assistant, content=[TextContent(text="Saving to memory...")], tool_calls=tool_calls, ) # test without assistant mode (reverse=True by default) letta_messages = message.to_letta_messages(use_assistant_message=False) assert len(letta_messages) == 2 # tool call + reasoning (reversed) tool_msg = letta_messages[0] # tool call is first due to reverse # both fields should be populated assert tool_msg.tool_call is not None assert tool_msg.tool_call.name == "archival_memory_insert" assert tool_msg.tool_calls is not None assert len(tool_msg.tool_calls) == 1 assert tool_msg.tool_calls[0].name == "archival_memory_insert" assert tool_msg.tool_calls[0].tool_call_id == "call_1" # test with assistant mode (reverse=True by default) letta_messages_assist = message.to_letta_messages(use_assistant_message=True) assert len(letta_messages_assist) == 2 # tool call + reasoning (reversed) tool_msg_assist = letta_messages_assist[0] # tool call is first due to reverse # both fields should still be populated assert tool_msg_assist.tool_call is not None assert tool_msg_assist.tool_calls is not None assert len(tool_msg_assist.tool_calls) == 1 @pytest.mark.asyncio async def test_convert_tool_calls_only_assistant_tools(server: SyncServer, sarah_agent, default_user): """Test that only send_message tools are converted to AssistantMessages""" tool_calls = [ OpenAIToolCall( id="call_1", type="function", function=OpenAIFunction(name="send_message", arguments='{"message": "First message"}') ), OpenAIToolCall( id="call_2", type="function", function=OpenAIFunction(name="send_message", arguments='{"message": "Second message"}') ), ] message = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.assistant, content=[TextContent(text="Sending messages...")], tool_calls=tool_calls, ) # convert with assistant mode (reverse=True by default) letta_messages = message.to_letta_messages(use_assistant_message=True) # should have 3 messages in reverse order: 2 assistant messages, then reasoning assert len(letta_messages) == 3 assert letta_messages[0].message_type == "assistant_message" assert letta_messages[1].message_type == "assistant_message" assert letta_messages[2].message_type == "reasoning_message" # check assistant messages content (they appear in reverse order) assert letta_messages[0].content == "Second message" assert letta_messages[1].content == "First message" @pytest.mark.asyncio async def test_convert_assistant_message_with_dict_content(server: SyncServer, sarah_agent, default_user): """Test that send_message with dict content is properly serialized to JSON string Regression test for bug where dict content like {'tofu': 1, 'mofu': 1, 'bofu': 1} caused pydantic validation error because AssistantMessage.content expects a string. """ import json # Test case 1: Simple dict as message content tool_calls = [ OpenAIToolCall( id="call_1", type="function", function=OpenAIFunction(name="send_message", arguments='{"message": {"tofu": 1, "mofu": 1, "bofu": 1}}'), ), ] message = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.assistant, content=[TextContent(text="Sending structured data...")], tool_calls=tool_calls, ) # convert with assistant mode - should not raise validation error letta_messages = message.to_letta_messages(use_assistant_message=True) assert len(letta_messages) == 2 assert letta_messages[0].message_type == "assistant_message" assert letta_messages[1].message_type == "reasoning_message" # check that dict was serialized to JSON string assistant_msg = letta_messages[0] assert isinstance(assistant_msg.content, str) # verify the JSON-serialized content can be parsed back parsed_content = json.loads(assistant_msg.content) assert parsed_content == {"tofu": 1, "mofu": 1, "bofu": 1} # Test case 2: Nested dict with various types tool_calls_nested = [ OpenAIToolCall( id="call_2", type="function", function=OpenAIFunction( name="send_message", arguments='{"message": {"status": "success", "data": {"count": 42, "items": ["a", "b"]}, "meta": null}}', ), ), ] message_nested = PydanticMessage( agent_id=sarah_agent.id, role=MessageRole.assistant, content=[TextContent(text="Sending complex data...")], tool_calls=tool_calls_nested, ) letta_messages_nested = message_nested.to_letta_messages(use_assistant_message=True) assistant_msg_nested = letta_messages_nested[0] assert isinstance(assistant_msg_nested.content, str) parsed_nested = json.loads(assistant_msg_nested.content) assert parsed_nested == {"status": "success", "data": {"count": 42, "items": ["a", "b"]}, "meta": None}