Files
letta-ai--letta/tests/managers/test_conversation_manager.py
wehub-resource-sync 4deafc5b1e
🌿 Preview Python SDK / changed-files (push) Has been cancelled
🌿 Preview Python SDK / preview-python-sdk (push) Has been cancelled
🌿 Preview TypeScript SDK / changed-files (push) Has been cancelled
🌿 Preview TypeScript SDK / preview-typescript-sdk (push) Has been cancelled
Sync Code / notify (push) Has been cancelled
Test Package Installation / test-install (3.13) (push) Has been cancelled
Test Package Installation / test-install (3.12) (push) Has been cancelled
Notify Submodule Repos / notify (push) Has been cancelled
Run Docker integration tests / test (push) Has been cancelled
🌿 Publish Docs / run (push) Has been cancelled
Test Package Installation / test-install (3.11) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:25 +08:00

1834 lines
66 KiB
Python

"""
Tests for ConversationManager.
"""
from datetime import datetime, timedelta, timezone
import pytest
from letta.orm.errors import NoResultFound
from letta.schemas.conversation import CreateConversation, UpdateConversation
from letta.server.server import SyncServer
from letta.services.conversation_manager import ConversationManager
# ======================================================================================================================
# ConversationManager Tests
# ======================================================================================================================
@pytest.fixture
def conversation_manager():
"""Create a ConversationManager instance."""
return ConversationManager()
@pytest.mark.asyncio
async def test_create_conversation(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test creating a conversation."""
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test conversation"),
actor=default_user,
)
assert conversation.id is not None
assert conversation.agent_id == sarah_agent.id
assert conversation.summary == "Test conversation"
assert conversation.id.startswith("conv-")
@pytest.mark.asyncio
async def test_create_conversation_no_summary(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test creating a conversation without summary."""
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(),
actor=default_user,
)
assert conversation.id is not None
assert conversation.agent_id == sarah_agent.id
assert conversation.summary is None
@pytest.mark.asyncio
async def test_get_conversation_by_id(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test retrieving a conversation by ID."""
# Create a conversation
created = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Retrieve it
retrieved = await conversation_manager.get_conversation_by_id(
conversation_id=created.id,
actor=default_user,
)
assert retrieved.id == created.id
assert retrieved.agent_id == created.agent_id
assert retrieved.summary == created.summary
@pytest.mark.asyncio
async def test_get_conversation_not_found(conversation_manager, server: SyncServer, default_user):
"""Test retrieving a non-existent conversation raises error."""
with pytest.raises(NoResultFound):
await conversation_manager.get_conversation_by_id(
conversation_id="conv-nonexistent",
actor=default_user,
)
@pytest.mark.asyncio
async def test_list_conversations(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test listing conversations for an agent."""
# Create multiple conversations
for i in range(3):
await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary=f"Conversation {i}"),
actor=default_user,
)
# List them
conversations = await conversation_manager.list_conversations(
agent_id=sarah_agent.id,
actor=default_user,
)
assert len(conversations) == 3
@pytest.mark.asyncio
async def test_list_conversations_with_limit(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test listing conversations with a limit."""
# Create multiple conversations
for i in range(5):
await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary=f"Conversation {i}"),
actor=default_user,
)
# List with limit
conversations = await conversation_manager.list_conversations(
agent_id=sarah_agent.id,
actor=default_user,
limit=2,
)
assert len(conversations) == 2
@pytest.mark.asyncio
async def test_update_conversation(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test updating a conversation."""
# Create a conversation
created = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Original"),
actor=default_user,
)
# Update it
updated = await conversation_manager.update_conversation(
conversation_id=created.id,
conversation_update=UpdateConversation(summary="Updated summary"),
actor=default_user,
)
assert updated.id == created.id
assert updated.summary == "Updated summary"
@pytest.mark.asyncio
async def test_delete_conversation(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test soft deleting a conversation."""
# Create a conversation
created = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="To delete"),
actor=default_user,
)
# Delete it
await conversation_manager.delete_conversation(
conversation_id=created.id,
actor=default_user,
)
# Verify it's no longer accessible
with pytest.raises(NoResultFound):
await conversation_manager.get_conversation_by_id(
conversation_id=created.id,
actor=default_user,
)
@pytest.mark.asyncio
async def test_delete_conversation_removes_from_list(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that soft-deleted conversations are excluded from list results."""
# Create two conversations
conv1 = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Keep me"),
actor=default_user,
)
conv2 = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Delete me"),
actor=default_user,
)
# Delete one
await conversation_manager.delete_conversation(
conversation_id=conv2.id,
actor=default_user,
)
# List should only return the non-deleted conversation
conversations = await conversation_manager.list_conversations(
agent_id=sarah_agent.id,
actor=default_user,
)
conv_ids = [c.id for c in conversations]
assert conv1.id in conv_ids
assert conv2.id not in conv_ids
@pytest.mark.asyncio
async def test_delete_conversation_soft_deletes_messages(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that deleting a conversation soft-deletes messages by Message.conversation_id."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create conversation and verify the initial system message is stamped with the conversation ID.
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Delete with messages"),
actor=default_user,
)
message_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=conversation.id,
actor=default_user,
)
assert len(message_ids) == 1
system_message = await server.message_manager.get_message_by_id_async(message_ids[0], actor=default_user)
assert system_message is not None
assert system_message.conversation_id == conversation.id
user_message = PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Message to be soft deleted")],
conversation_id=conversation.id,
)
created_messages = await server.message_manager.create_many_messages_async([user_message], actor=default_user)
user_message_id = created_messages[0].id
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[user_message_id],
actor=default_user,
)
# Sanity-check message is visible before delete
before_delete = await server.message_manager.get_message_by_id_async(user_message_id, actor=default_user)
assert before_delete is not None
# Delete conversation
await conversation_manager.delete_conversation(
conversation_id=conversation.id,
actor=default_user,
)
# Both system and user messages should now be hidden (soft-deleted)
after_delete_system = await server.message_manager.get_message_by_id_async(message_ids[0], actor=default_user)
after_delete = await server.message_manager.get_message_by_id_async(user_message_id, actor=default_user)
assert after_delete_system is None
assert after_delete is None
@pytest.mark.asyncio
async def test_delete_conversation_only_soft_deletes_messages_for_that_conversation(
conversation_manager, server: SyncServer, sarah_agent, default_user
):
"""Test deleting one conversation does not affect messages from another conversation."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
conv1 = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Conversation one"),
actor=default_user,
)
conv2 = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Conversation two"),
actor=default_user,
)
conv1_message = PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="conv1 message")],
conversation_id=conv1.id,
)
conv2_message = PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="conv2 message")],
conversation_id=conv2.id,
)
created_messages = await server.message_manager.create_many_messages_async([conv1_message, conv2_message], actor=default_user)
conv1_message_id, conv2_message_id = [message.id for message in created_messages]
await conversation_manager.add_messages_to_conversation(
conversation_id=conv1.id,
agent_id=sarah_agent.id,
message_ids=[conv1_message_id],
actor=default_user,
)
await conversation_manager.add_messages_to_conversation(
conversation_id=conv2.id,
agent_id=sarah_agent.id,
message_ids=[conv2_message_id],
actor=default_user,
)
await conversation_manager.delete_conversation(
conversation_id=conv1.id,
actor=default_user,
)
assert await server.message_manager.get_message_by_id_async(conv1_message_id, actor=default_user) is None
still_visible = await server.message_manager.get_message_by_id_async(conv2_message_id, actor=default_user)
assert still_visible is not None
assert still_visible.conversation_id == conv2.id
@pytest.mark.asyncio
async def test_delete_conversation_double_delete_raises(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that deleting an already-deleted conversation raises NoResultFound."""
created = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Delete me twice"),
actor=default_user,
)
await conversation_manager.delete_conversation(
conversation_id=created.id,
actor=default_user,
)
# Second delete should raise
with pytest.raises(NoResultFound):
await conversation_manager.delete_conversation(
conversation_id=created.id,
actor=default_user,
)
@pytest.mark.asyncio
async def test_update_deleted_conversation_raises(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that updating a soft-deleted conversation raises NoResultFound."""
created = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Original"),
actor=default_user,
)
await conversation_manager.delete_conversation(
conversation_id=created.id,
actor=default_user,
)
with pytest.raises(NoResultFound):
await conversation_manager.update_conversation(
conversation_id=created.id,
conversation_update=UpdateConversation(summary="Should fail"),
actor=default_user,
)
@pytest.mark.asyncio
async def test_delete_conversation_excluded_from_summary_search(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that soft-deleted conversations are excluded from summary search results."""
await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="alpha search term"),
actor=default_user,
)
to_delete = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="alpha deleted term"),
actor=default_user,
)
await conversation_manager.delete_conversation(
conversation_id=to_delete.id,
actor=default_user,
)
results = await conversation_manager.list_conversations(
agent_id=sarah_agent.id,
actor=default_user,
summary_search="alpha",
)
result_ids = [c.id for c in results]
assert to_delete.id not in result_ids
assert len(results) == 1
@pytest.mark.asyncio
async def test_list_conversations_sort_by_last_message_at(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test sorting conversations by last_message_at with nulls last."""
conv_old = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="old"),
actor=default_user,
)
conv_new = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="new"),
actor=default_user,
)
conv_null = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="null"),
actor=default_user,
)
now = datetime.now(timezone.utc)
await conversation_manager.update_conversation(
conversation_id=conv_old.id,
conversation_update=UpdateConversation(last_message_at=now - timedelta(minutes=5)),
actor=default_user,
)
await conversation_manager.update_conversation(
conversation_id=conv_new.id,
conversation_update=UpdateConversation(last_message_at=now),
actor=default_user,
)
desc_conversations = await conversation_manager.list_conversations(
agent_id=sarah_agent.id,
actor=default_user,
sort_by="last_message_at",
ascending=False,
)
desc_ids = [c.id for c in desc_conversations]
assert desc_ids.index(conv_new.id) < desc_ids.index(conv_old.id)
assert desc_ids.index(conv_old.id) < desc_ids.index(conv_null.id)
asc_conversations = await conversation_manager.list_conversations(
agent_id=sarah_agent.id,
actor=default_user,
sort_by="last_message_at",
ascending=True,
)
asc_ids = [c.id for c in asc_conversations]
assert asc_ids.index(conv_old.id) < asc_ids.index(conv_new.id)
assert asc_ids.index(conv_new.id) < asc_ids.index(conv_null.id)
@pytest.mark.asyncio
async def test_conversation_isolation_by_agent(conversation_manager, server: SyncServer, sarah_agent, charles_agent, default_user):
"""Test that conversations are isolated by agent."""
# Create conversation for sarah_agent
await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Sarah's conversation"),
actor=default_user,
)
# Create conversation for charles_agent
await conversation_manager.create_conversation(
agent_id=charles_agent.id,
conversation_create=CreateConversation(summary="Charles's conversation"),
actor=default_user,
)
# List for sarah_agent
sarah_convos = await conversation_manager.list_conversations(
agent_id=sarah_agent.id,
actor=default_user,
)
assert len(sarah_convos) == 1
assert sarah_convos[0].summary == "Sarah's conversation"
# List for charles_agent
charles_convos = await conversation_manager.list_conversations(
agent_id=charles_agent.id,
actor=default_user,
)
assert len(charles_convos) == 1
assert charles_convos[0].summary == "Charles's conversation"
@pytest.mark.asyncio
async def test_conversation_isolation_by_organization(
conversation_manager, server: SyncServer, sarah_agent, default_user, other_user_different_org
):
"""Test that conversations are isolated by organization."""
# Create conversation
created = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Other org user should not be able to access it
with pytest.raises(NoResultFound):
await conversation_manager.get_conversation_by_id(
conversation_id=created.id,
actor=other_user_different_org,
)
# ======================================================================================================================
# Conversation Message Management Tests
# ======================================================================================================================
@pytest.mark.asyncio
async def test_add_messages_to_conversation(
conversation_manager, server: SyncServer, sarah_agent, default_user, hello_world_message_fixture
):
"""Test adding messages to a conversation."""
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Add the message to the conversation
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[hello_world_message_fixture.id],
actor=default_user,
)
# Verify message is in conversation
message_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=conversation.id,
actor=default_user,
)
# create_conversation auto-creates a system message at position 0
assert len(message_ids) == 2
assert hello_world_message_fixture.id in message_ids
@pytest.mark.asyncio
async def test_get_messages_for_conversation(
conversation_manager, server: SyncServer, sarah_agent, default_user, hello_world_message_fixture
):
"""Test getting full message objects from a conversation."""
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Add the message
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[hello_world_message_fixture.id],
actor=default_user,
)
# Get full messages
messages = await conversation_manager.get_messages_for_conversation(
conversation_id=conversation.id,
actor=default_user,
)
# create_conversation auto-creates a system message at position 0
assert len(messages) == 2
assert any(m.id == hello_world_message_fixture.id for m in messages)
@pytest.mark.asyncio
async def test_message_ordering_in_conversation(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that messages maintain their order in a conversation."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Create multiple messages
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text=f"Message {i}")],
)
for i in range(3)
]
messages = await server.message_manager.create_many_messages_async(
pydantic_messages,
actor=default_user,
)
# Add messages in order
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# Verify order is maintained
retrieved_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=conversation.id,
actor=default_user,
)
# create_conversation auto-creates a system message at position 0,
# so the user messages start at index 1
assert len(retrieved_ids) == len(messages) + 1
assert retrieved_ids[1:] == [m.id for m in messages]
@pytest.mark.asyncio
async def test_update_in_context_messages(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test updating which messages are in context."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Create messages
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text=f"Message {i}")],
)
for i in range(3)
]
messages = await server.message_manager.create_many_messages_async(
pydantic_messages,
actor=default_user,
)
# Add all messages
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# Update to only keep first and last in context
await conversation_manager.update_in_context_messages(
conversation_id=conversation.id,
in_context_message_ids=[messages[0].id, messages[2].id],
actor=default_user,
)
# Verify only the selected messages are in context
in_context_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=conversation.id,
actor=default_user,
)
assert len(in_context_ids) == 2
assert messages[0].id in in_context_ids
assert messages[2].id in in_context_ids
assert messages[1].id not in in_context_ids
@pytest.mark.asyncio
async def test_empty_conversation_message_ids(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test getting message IDs from a newly created conversation (has auto-created system message)."""
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Empty"),
actor=default_user,
)
# create_conversation auto-creates a system message at position 0,
# so a newly created conversation has exactly one message
message_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=conversation.id,
actor=default_user,
)
assert len(message_ids) == 1
@pytest.mark.asyncio
async def test_list_conversation_messages(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test listing messages from a conversation as LettaMessages."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Create messages with different roles
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Hello!")],
),
PydanticMessage(
agent_id=sarah_agent.id,
role="assistant",
content=[TextContent(text="Hi there!")],
),
]
messages = await server.message_manager.create_many_messages_async(
pydantic_messages,
actor=default_user,
)
# Add messages to conversation
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# List conversation messages (returns LettaMessages)
letta_messages = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
)
# create_conversation auto-creates a system message, so we get 3 total
assert len(letta_messages) == 3
# Check message types
message_types = [m.message_type for m in letta_messages]
assert "system_message" in message_types
assert "user_message" in message_types
assert "assistant_message" in message_types
@pytest.mark.asyncio
async def test_list_conversation_messages_pagination(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test pagination when listing conversation messages."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Create multiple messages
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text=f"Message {i}")],
)
for i in range(5)
]
messages = await server.message_manager.create_many_messages_async(
pydantic_messages,
actor=default_user,
)
# Add messages to conversation
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# List with limit
letta_messages = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
limit=2,
)
assert len(letta_messages) == 2
# List with after cursor (get messages after the first one)
letta_messages_after = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
after=messages[0].id,
)
assert len(letta_messages_after) == 4 # Should get messages 1-4
# ======================================================================================================================
# Isolated Blocks Tests
# ======================================================================================================================
@pytest.mark.asyncio
async def test_create_conversation_with_isolated_blocks(conversation_manager, server: SyncServer, charles_agent, default_user):
"""Test creating a conversation with isolated block labels."""
# Get the agent's blocks to know what labels exist
agent_state = await server.agent_manager.get_agent_by_id_async(charles_agent.id, default_user, include_relationships=["memory"])
block_labels = [block.label for block in agent_state.memory.blocks]
assert len(block_labels) > 0, "Agent should have at least one block"
# Create conversation with isolated blocks
first_label = block_labels[0]
conversation = await conversation_manager.create_conversation(
agent_id=charles_agent.id,
conversation_create=CreateConversation(
summary="Test with isolated blocks",
isolated_block_labels=[first_label],
),
actor=default_user,
)
assert conversation.id is not None
assert conversation.agent_id == charles_agent.id
assert len(conversation.isolated_block_ids) == 1
# Verify the isolated block was created
isolated_blocks = await conversation_manager.get_isolated_blocks_for_conversation(
conversation_id=conversation.id,
actor=default_user,
)
assert first_label in isolated_blocks
assert isolated_blocks[first_label].label == first_label
@pytest.mark.asyncio
async def test_isolated_blocks_have_different_ids(conversation_manager, server: SyncServer, charles_agent, default_user):
"""Test that isolated blocks have different IDs from agent's original blocks."""
# Get the agent's blocks
agent_state = await server.agent_manager.get_agent_by_id_async(charles_agent.id, default_user, include_relationships=["memory"])
original_block = agent_state.memory.blocks[0]
# Create conversation with isolated block
conversation = await conversation_manager.create_conversation(
agent_id=charles_agent.id,
conversation_create=CreateConversation(
summary="Test isolated block IDs",
isolated_block_labels=[original_block.label],
),
actor=default_user,
)
# Get the isolated blocks
isolated_blocks = await conversation_manager.get_isolated_blocks_for_conversation(
conversation_id=conversation.id,
actor=default_user,
)
# Verify the isolated block has a different ID
isolated_block = isolated_blocks[original_block.label]
assert isolated_block.id != original_block.id
assert isolated_block.label == original_block.label
assert isolated_block.value == original_block.value # Same initial value
@pytest.mark.asyncio
async def test_isolated_blocks_are_conversation_specific(conversation_manager, server: SyncServer, charles_agent, default_user):
"""Test that isolated blocks are specific to each conversation."""
# Get the agent's first block label
agent_state = await server.agent_manager.get_agent_by_id_async(charles_agent.id, default_user, include_relationships=["memory"])
block_label = agent_state.memory.blocks[0].label
# Create two conversations with the same isolated block label
conv1 = await conversation_manager.create_conversation(
agent_id=charles_agent.id,
conversation_create=CreateConversation(
summary="Conversation 1",
isolated_block_labels=[block_label],
),
actor=default_user,
)
conv2 = await conversation_manager.create_conversation(
agent_id=charles_agent.id,
conversation_create=CreateConversation(
summary="Conversation 2",
isolated_block_labels=[block_label],
),
actor=default_user,
)
# Get isolated blocks for both conversations
isolated_blocks_1 = await conversation_manager.get_isolated_blocks_for_conversation(
conversation_id=conv1.id,
actor=default_user,
)
isolated_blocks_2 = await conversation_manager.get_isolated_blocks_for_conversation(
conversation_id=conv2.id,
actor=default_user,
)
# Verify they have different block IDs
block_1 = isolated_blocks_1[block_label]
block_2 = isolated_blocks_2[block_label]
assert block_1.id != block_2.id
@pytest.mark.asyncio
async def test_create_conversation_invalid_block_label(conversation_manager, server: SyncServer, charles_agent, default_user):
"""Test that creating a conversation with non-existent block label raises error."""
from letta.errors import LettaInvalidArgumentError
with pytest.raises(LettaInvalidArgumentError) as exc_info:
await conversation_manager.create_conversation(
agent_id=charles_agent.id,
conversation_create=CreateConversation(
summary="Test invalid label",
isolated_block_labels=["nonexistent_block_label"],
),
actor=default_user,
)
assert "nonexistent_block_label" in str(exc_info.value)
@pytest.mark.asyncio
async def test_apply_isolated_blocks_to_agent_state(conversation_manager, server: SyncServer, charles_agent, default_user):
"""Test that isolated blocks are correctly applied to agent state."""
# Get the original agent state
original_agent_state = await server.agent_manager.get_agent_by_id_async(
charles_agent.id, default_user, include_relationships=["memory"]
)
original_block = original_agent_state.memory.blocks[0]
# Create conversation with isolated block
conversation = await conversation_manager.create_conversation(
agent_id=charles_agent.id,
conversation_create=CreateConversation(
summary="Test apply isolated blocks",
isolated_block_labels=[original_block.label],
),
actor=default_user,
)
# Get fresh agent state
agent_state = await server.agent_manager.get_agent_by_id_async(charles_agent.id, default_user, include_relationships=["memory"])
# Apply isolated blocks
modified_state = await conversation_manager.apply_isolated_blocks_to_agent_state(
agent_state=agent_state,
conversation_id=conversation.id,
actor=default_user,
)
# Verify the block was replaced
modified_block = modified_state.memory.get_block(original_block.label)
assert modified_block.id != original_block.id
assert modified_block.label == original_block.label
assert modified_block.id in conversation.isolated_block_ids
@pytest.mark.asyncio
async def test_conversation_without_isolated_blocks(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that creating a conversation without isolated blocks works normally."""
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="No isolated blocks"),
actor=default_user,
)
assert conversation.id is not None
assert conversation.isolated_block_ids == []
isolated_blocks = await conversation_manager.get_isolated_blocks_for_conversation(
conversation_id=conversation.id,
actor=default_user,
)
assert isolated_blocks == {}
@pytest.mark.asyncio
async def test_apply_no_isolated_blocks_preserves_state(conversation_manager, server: SyncServer, charles_agent, default_user):
"""Test that applying isolated blocks to a conversation without them preserves original state."""
# Create conversation without isolated blocks
conversation = await conversation_manager.create_conversation(
agent_id=charles_agent.id,
conversation_create=CreateConversation(summary="No isolated blocks"),
actor=default_user,
)
# Get agent state
agent_state = await server.agent_manager.get_agent_by_id_async(charles_agent.id, default_user, include_relationships=["memory"])
original_block_ids = [block.id for block in agent_state.memory.blocks]
# Apply isolated blocks (should be a no-op)
modified_state = await conversation_manager.apply_isolated_blocks_to_agent_state(
agent_state=agent_state,
conversation_id=conversation.id,
actor=default_user,
)
# Verify blocks are unchanged
modified_block_ids = [block.id for block in modified_state.memory.blocks]
assert original_block_ids == modified_block_ids
@pytest.mark.asyncio
async def test_delete_conversation_cleans_up_isolated_blocks(conversation_manager, server: SyncServer, charles_agent, default_user):
"""Test that deleting a conversation also hard-deletes its isolated blocks."""
# Get the agent's first block label
agent_state = await server.agent_manager.get_agent_by_id_async(charles_agent.id, default_user, include_relationships=["memory"])
block_label = agent_state.memory.blocks[0].label
# Create conversation with isolated block
conversation = await conversation_manager.create_conversation(
agent_id=charles_agent.id,
conversation_create=CreateConversation(
summary="Test delete cleanup",
isolated_block_labels=[block_label],
),
actor=default_user,
)
# Get the isolated block ID
isolated_block_ids = conversation.isolated_block_ids
assert len(isolated_block_ids) == 1
isolated_block_id = isolated_block_ids[0]
# Verify the isolated block exists
isolated_block = await server.block_manager.get_block_by_id_async(isolated_block_id, default_user)
assert isolated_block is not None
# Delete the conversation
await conversation_manager.delete_conversation(
conversation_id=conversation.id,
actor=default_user,
)
# Verify the isolated block was hard-deleted
deleted_block = await server.block_manager.get_block_by_id_async(isolated_block_id, default_user)
assert deleted_block is None
# ======================================================================================================================
# list_conversation_messages with order/reverse Tests
# ======================================================================================================================
@pytest.mark.asyncio
async def test_list_conversation_messages_ascending_order(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test listing messages in ascending order (oldest first)."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Create messages in a known order
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text=f"Message {i}")],
)
for i in range(3)
]
messages = await server.message_manager.create_many_messages_async(
pydantic_messages,
actor=default_user,
)
# Add messages to conversation
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# List messages in ascending order (reverse=False)
letta_messages = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
reverse=False,
)
# create_conversation auto-creates a system message at position 0,
# so we get 4 messages total (system + 3 user messages)
assert len(letta_messages) == 4
# First message is the auto-created system message; "Message 0" is second
assert letta_messages[0].message_type == "system_message"
assert "Message 0" in letta_messages[1].content
@pytest.mark.asyncio
async def test_list_conversation_messages_descending_order(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test listing messages in descending order (newest first)."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Create messages in a known order
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text=f"Message {i}")],
)
for i in range(3)
]
messages = await server.message_manager.create_many_messages_async(
pydantic_messages,
actor=default_user,
)
# Add messages to conversation
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# List messages in descending order (reverse=True)
letta_messages = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
reverse=True,
)
# create_conversation auto-creates a system message, so 4 total
# First message should be "Message 2" (newest) in descending order
assert len(letta_messages) == 4
assert "Message 2" in letta_messages[0].content
@pytest.mark.asyncio
async def test_list_conversation_messages_with_group_id_filter(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test filtering messages by group_id."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Create messages with different group_ids
group_a_id = "group-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
group_b_id = "group-bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
messages_group_a = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Group A message 1")],
group_id=group_a_id,
),
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Group A message 2")],
group_id=group_a_id,
),
]
messages_group_b = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Group B message 1")],
group_id=group_b_id,
),
]
created_a = await server.message_manager.create_many_messages_async(messages_group_a, actor=default_user)
created_b = await server.message_manager.create_many_messages_async(messages_group_b, actor=default_user)
# Add all messages to conversation
all_message_ids = [m.id for m in created_a] + [m.id for m in created_b]
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=all_message_ids,
actor=default_user,
)
# List messages filtered by group A
messages_a = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
group_id=group_a_id,
)
assert len(messages_a) == 2
for msg in messages_a:
assert "Group A" in msg.content
# List messages filtered by group B
messages_b = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
group_id=group_b_id,
)
assert len(messages_b) == 1
assert "Group B" in messages_b[0].content
@pytest.mark.asyncio
async def test_list_conversation_messages_no_group_id_returns_all(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that not providing group_id returns all messages."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Create messages with different group_ids
group_a_id = "group-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
group_b_id = "group-bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Group A message")],
group_id=group_a_id,
),
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Group B message")],
group_id=group_b_id,
),
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="No group message")],
group_id=None,
),
]
messages = await server.message_manager.create_many_messages_async(pydantic_messages, actor=default_user)
# Add all messages to conversation
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# List all messages without group_id filter
all_messages = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
)
# create_conversation auto-creates a system message, so 4 total
assert len(all_messages) == 4
@pytest.mark.asyncio
async def test_list_conversation_messages_order_with_pagination(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that order affects pagination correctly."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create a conversation
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test"),
actor=default_user,
)
# Create messages
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text=f"Message {i}")],
)
for i in range(5)
]
messages = await server.message_manager.create_many_messages_async(
pydantic_messages,
actor=default_user,
)
# Add messages to conversation
await conversation_manager.add_messages_to_conversation(
conversation_id=conversation.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# Get first page in ascending order with limit
page_asc = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
reverse=False,
limit=2,
)
# Get first page in descending order with limit
page_desc = await conversation_manager.list_conversation_messages(
conversation_id=conversation.id,
actor=default_user,
reverse=True,
limit=2,
)
# The first messages should be different
assert page_asc[0].content != page_desc[0].content
# In ascending, first is the auto-created system message, second is "Message 0"
assert page_asc[0].message_type == "system_message"
# In descending, first should be "Message 4"
assert "Message 4" in page_desc[0].content
# ======================================================================================================================
# Model/Model Settings Override Tests
# ======================================================================================================================
@pytest.mark.asyncio
async def test_create_conversation_with_model(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test creating a conversation with a model override."""
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Test with model override", model="openai/gpt-4o"),
actor=default_user,
)
assert conversation.id is not None
assert conversation.model == "openai/gpt-4o"
assert conversation.model_settings is None
@pytest.mark.asyncio
async def test_create_conversation_with_model_and_settings(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test creating a conversation with model and model_settings."""
from letta.schemas.model import OpenAIModelSettings
settings = OpenAIModelSettings(temperature=0.5)
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(
summary="Test with settings",
model="openai/gpt-4o",
model_settings=settings,
),
actor=default_user,
)
assert conversation.model == "openai/gpt-4o"
assert conversation.model_settings is not None
assert conversation.model_settings.temperature == 0.5
@pytest.mark.asyncio
async def test_create_conversation_without_model_override(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test creating a conversation without model override returns None for model fields."""
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="No override"),
actor=default_user,
)
assert conversation.id is not None
assert conversation.model is None
assert conversation.model_settings is None
@pytest.mark.asyncio
async def test_update_conversation_set_model(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test updating a conversation to add a model override."""
# Create without override
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Original"),
actor=default_user,
)
assert conversation.model is None
# Update to add override
updated = await conversation_manager.update_conversation(
conversation_id=conversation.id,
conversation_update=UpdateConversation(model="anthropic/claude-3-opus"),
actor=default_user,
)
assert updated.model == "anthropic/claude-3-opus"
@pytest.mark.asyncio
async def test_update_conversation_preserves_model(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that updating summary preserves existing model override."""
# Create with override
conversation = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="With override", model="openai/gpt-4o"),
actor=default_user,
)
assert conversation.model == "openai/gpt-4o"
# Update summary only
updated = await conversation_manager.update_conversation(
conversation_id=conversation.id,
conversation_update=UpdateConversation(summary="New summary"),
actor=default_user,
)
assert updated.summary == "New summary"
assert updated.model == "openai/gpt-4o"
@pytest.mark.asyncio
async def test_retrieve_conversation_includes_model(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that retrieving a conversation includes model/model_settings."""
from letta.schemas.model import OpenAIModelSettings
created = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(
summary="Retrieve test",
model="openai/gpt-4o",
model_settings=OpenAIModelSettings(temperature=0.7),
),
actor=default_user,
)
retrieved = await conversation_manager.get_conversation_by_id(
conversation_id=created.id,
actor=default_user,
)
assert retrieved.model == "openai/gpt-4o"
assert retrieved.model_settings is not None
assert retrieved.model_settings.temperature == 0.7
@pytest.mark.asyncio
async def test_list_conversations_includes_model(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that listing conversations includes model fields."""
await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="List test", model="openai/gpt-4o"),
actor=default_user,
)
conversations = await conversation_manager.list_conversations(
agent_id=sarah_agent.id,
actor=default_user,
)
assert len(conversations) >= 1
conv_with_model = [c for c in conversations if c.summary == "List test"]
assert len(conv_with_model) == 1
assert conv_with_model[0].model == "openai/gpt-4o"
@pytest.mark.asyncio
async def test_create_conversation_schema_model_validation():
"""Test that CreateConversation validates model handle format."""
from letta.errors import LettaInvalidArgumentError
# Valid format should work
create = CreateConversation(model="openai/gpt-4o")
assert create.model == "openai/gpt-4o"
# Invalid format should raise
with pytest.raises(LettaInvalidArgumentError):
CreateConversation(model="invalid-no-slash")
@pytest.mark.asyncio
async def test_update_conversation_schema_model_validation():
"""Test that UpdateConversation validates model handle format."""
from letta.errors import LettaInvalidArgumentError
# Valid format should work
update = UpdateConversation(model="anthropic/claude-3-opus")
assert update.model == "anthropic/claude-3-opus"
# Invalid format should raise
with pytest.raises(LettaInvalidArgumentError):
UpdateConversation(model="no-slash")
# ======================================================================================================================
# Fork Conversation Tests
# ======================================================================================================================
@pytest.mark.asyncio
async def test_fork_conversation_basic(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test basic conversation forking creates a new conversation with shared messages."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create source conversation
source = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Source conversation"),
actor=default_user,
)
# Add some user messages to the source conversation
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text=f"Message {i}")],
)
for i in range(3)
]
messages = await server.message_manager.create_many_messages_async(
pydantic_messages,
actor=default_user,
)
await conversation_manager.add_messages_to_conversation(
conversation_id=source.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# Fork the conversation
forked = await conversation_manager.fork_conversation(
conversation_id=source.id,
actor=default_user,
)
# Verify the fork has a different ID
assert forked.id != source.id
assert forked.id.startswith("conv-")
assert forked.agent_id == sarah_agent.id
# Get messages from both conversations
source_msg_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=source.id,
actor=default_user,
)
forked_msg_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=forked.id,
actor=default_user,
)
# Both should have system message + 3 user messages = 4 messages
assert len(source_msg_ids) == 4 # system + 3 user
assert len(forked_msg_ids) == 4 # new system + 3 shared user
# The user messages should be SHARED (same message IDs)
source_user_msgs = source_msg_ids[1:] # skip system message
forked_user_msgs = forked_msg_ids[1:] # skip system message
assert source_user_msgs == forked_user_msgs
# The system messages should be DIFFERENT
assert source_msg_ids[0] != forked_msg_ids[0]
@pytest.mark.asyncio
async def test_fork_conversation_new_system_message(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that a forked conversation gets a newly compiled system message."""
# Create source conversation
source = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Source"),
actor=default_user,
)
# Fork the conversation
forked = await conversation_manager.fork_conversation(
conversation_id=source.id,
actor=default_user,
)
# Get the system messages from both conversations
source_msgs = await conversation_manager.get_messages_for_conversation(
conversation_id=source.id,
actor=default_user,
)
forked_msgs = await conversation_manager.get_messages_for_conversation(
conversation_id=forked.id,
actor=default_user,
)
# Both should have at least the system message
assert len(source_msgs) >= 1
assert len(forked_msgs) >= 1
# First message in both should be system role
assert source_msgs[0].role == "system"
assert forked_msgs[0].role == "system"
# System message IDs should be different (fork gets its own)
assert source_msgs[0].id != forked_msgs[0].id
@pytest.mark.asyncio
async def test_fork_conversation_preserves_model_settings(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that forking preserves model and model_settings from the source."""
from letta.schemas.model import OpenAIModelSettings
source = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(
summary="Source with model",
model="openai/gpt-4o",
model_settings=OpenAIModelSettings(temperature=0.5),
),
actor=default_user,
)
forked = await conversation_manager.fork_conversation(
conversation_id=source.id,
actor=default_user,
)
# Retrieve the forked conversation to verify model settings
retrieved = await conversation_manager.get_conversation_by_id(
conversation_id=forked.id,
actor=default_user,
)
assert retrieved.model == "openai/gpt-4o"
assert retrieved.model_settings is not None
assert retrieved.model_settings.temperature == 0.5
@pytest.mark.asyncio
async def test_fork_conversation_not_found(conversation_manager, server: SyncServer, default_user):
"""Test that forking a non-existent conversation raises an error."""
from letta.orm.errors import NoResultFound
with pytest.raises(NoResultFound):
await conversation_manager.fork_conversation(
conversation_id="conv-nonexistent",
actor=default_user,
)
@pytest.mark.asyncio
async def test_fork_conversation_shared_messages_survive_source_delete(
conversation_manager, server: SyncServer, sarah_agent, default_user
):
"""Test that deleting the source conversation does not delete messages shared with forks."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create source conversation with messages
source = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Source"),
actor=default_user,
)
user_msg = PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Shared message")],
conversation_id=source.id,
)
created = await server.message_manager.create_many_messages_async([user_msg], actor=default_user)
shared_msg_id = created[0].id
await conversation_manager.add_messages_to_conversation(
conversation_id=source.id,
agent_id=sarah_agent.id,
message_ids=[shared_msg_id],
actor=default_user,
)
# Fork the conversation
forked = await conversation_manager.fork_conversation(
conversation_id=source.id,
actor=default_user,
)
# Delete the source conversation
await conversation_manager.delete_conversation(
conversation_id=source.id,
actor=default_user,
)
# The shared message should still be accessible since the fork references it
still_visible = await server.message_manager.get_message_by_id_async(shared_msg_id, actor=default_user)
assert still_visible is not None
# The fork should still have its messages
forked_msg_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=forked.id,
actor=default_user,
)
assert shared_msg_id in forked_msg_ids
@pytest.mark.asyncio
async def test_fork_conversation_delete_fork_preserves_source(
conversation_manager, server: SyncServer, sarah_agent, default_user
):
"""Test that deleting a forked conversation does not affect the source."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create source conversation with messages
source = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Source"),
actor=default_user,
)
user_msg = PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Shared message")],
conversation_id=source.id,
)
created = await server.message_manager.create_many_messages_async([user_msg], actor=default_user)
shared_msg_id = created[0].id
await conversation_manager.add_messages_to_conversation(
conversation_id=source.id,
agent_id=sarah_agent.id,
message_ids=[shared_msg_id],
actor=default_user,
)
# Fork and then delete the fork
forked = await conversation_manager.fork_conversation(
conversation_id=source.id,
actor=default_user,
)
await conversation_manager.delete_conversation(
conversation_id=forked.id,
actor=default_user,
)
# Source messages should be unaffected
source_msg_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=source.id,
actor=default_user,
)
assert shared_msg_id in source_msg_ids
# Shared message should still be visible
still_visible = await server.message_manager.get_message_by_id_async(shared_msg_id, actor=default_user)
assert still_visible is not None
@pytest.mark.asyncio
async def test_fork_empty_conversation(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test forking a conversation that only has a system message."""
source = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Empty source"),
actor=default_user,
)
forked = await conversation_manager.fork_conversation(
conversation_id=source.id,
actor=default_user,
)
# Forked conversation should have only its own system message
forked_msg_ids = await conversation_manager.get_message_ids_for_conversation(
conversation_id=forked.id,
actor=default_user,
)
assert len(forked_msg_ids) == 1 # Just the new system message
@pytest.mark.asyncio
async def test_fork_conversation_messages_appear_in_list(conversation_manager, server: SyncServer, sarah_agent, default_user):
"""Test that shared messages appear when listing forked conversation messages."""
from letta.schemas.letta_message_content import TextContent
from letta.schemas.message import Message as PydanticMessage
# Create source conversation with messages
source = await conversation_manager.create_conversation(
agent_id=sarah_agent.id,
conversation_create=CreateConversation(summary="Source"),
actor=default_user,
)
pydantic_messages = [
PydanticMessage(
agent_id=sarah_agent.id,
role="user",
content=[TextContent(text="Hello from source!")],
),
PydanticMessage(
agent_id=sarah_agent.id,
role="assistant",
content=[TextContent(text="Hi there!")],
),
]
messages = await server.message_manager.create_many_messages_async(pydantic_messages, actor=default_user)
await conversation_manager.add_messages_to_conversation(
conversation_id=source.id,
agent_id=sarah_agent.id,
message_ids=[m.id for m in messages],
actor=default_user,
)
# Fork the conversation
forked = await conversation_manager.fork_conversation(
conversation_id=source.id,
actor=default_user,
)
# List messages in the forked conversation
letta_messages = await conversation_manager.list_conversation_messages(
conversation_id=forked.id,
actor=default_user,
)
# Should have system + user + assistant = 3 messages
assert len(letta_messages) == 3
message_types = [m.message_type for m in letta_messages]
assert "system_message" in message_types
assert "user_message" in message_types
assert "assistant_message" in message_types