import json import logging import os import random import re import string import time import uuid from datetime import datetime, timedelta, timezone from typing import List from unittest.mock import AsyncMock, Mock, patch import pytest from _pytest.python_api import approx from anthropic.types.beta import BetaMessage from anthropic.types.beta.messages import BetaMessageBatchIndividualResponse, BetaMessageBatchSucceededResult # Import shared fixtures and constants from conftest from conftest import ( CREATE_DELAY_SQLITE, DEFAULT_EMBEDDING_CONFIG, USING_SQLITE, ) from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall, Function as OpenAIFunction from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError, InvalidRequestError from sqlalchemy.orm.exc import StaleDataError from letta.config import LettaConfig from letta.constants import ( BASE_MEMORY_TOOLS, BASE_SLEEPTIME_TOOLS, BASE_TOOLS, BASE_VOICE_SLEEPTIME_CHAT_TOOLS, BASE_VOICE_SLEEPTIME_TOOLS, BUILTIN_TOOLS, DEFAULT_ORG_ID, DEFAULT_ORG_NAME, FILES_TOOLS, LETTA_TOOL_EXECUTION_DIR, LETTA_TOOL_SET, LOCAL_ONLY_MULTI_AGENT_TOOLS, MCP_TOOL_TAG_NAME_PREFIX, MULTI_AGENT_TOOLS, ) from letta.data_sources.redis_client import NoopAsyncRedisClient, get_redis_client from letta.errors import LettaAgentNotFoundError, LettaInvalidArgumentError from letta.functions.functions import derive_openai_json_schema, parse_source_code from letta.functions.mcp_client.types import MCPTool from letta.helpers import ToolRulesSolver from letta.helpers.datetime_helpers import AsyncTimer from letta.jobs.types import ItemUpdateInfo, RequestStatusUpdateInfo, StepStatusUpdateInfo from letta.orm import Base, Block from letta.orm.block_history import BlockHistory from letta.orm.errors import NoResultFound, UniqueConstraintViolationError from letta.orm.file import FileContent as FileContentModel, FileMetadata as FileMetadataModel from letta.schemas.agent import CreateAgent, UpdateAgent from letta.schemas.block import Block as PydanticBlock, BlockUpdate, CreateBlock from letta.schemas.embedding_config import EmbeddingConfig from letta.schemas.enums import ( ActorType, AgentStepStatus, FileProcessingStatus, JobStatus, JobType, MessageRole, ProviderType, SandboxType, StepStatus, TagMatchMode, ToolType, VectorDBProvider, ) from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate, SandboxEnvironmentVariableUpdate from letta.schemas.file import FileMetadata, FileMetadata as PydanticFileMetadata from letta.schemas.identity import IdentityCreate, IdentityProperty, IdentityPropertyType, IdentityType, IdentityUpdate, IdentityUpsert from letta.schemas.job import BatchJob, Job, Job as PydanticJob, JobUpdate, LettaRequestConfig from letta.schemas.letta_message import UpdateAssistantMessage, UpdateReasoningMessage, UpdateSystemMessage, UpdateUserMessage from letta.schemas.letta_message_content import TextContent from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType from letta.schemas.llm_batch_job import AgentStepState, LLMBatchItem from letta.schemas.llm_config import LLMConfig from letta.schemas.message import Message as PydanticMessage, MessageCreate, MessageUpdate from letta.schemas.openai.chat_completion_response import UsageStatistics from letta.schemas.organization import Organization, Organization as PydanticOrganization, OrganizationUpdate from letta.schemas.passage import Passage as PydanticPassage from letta.schemas.pip_requirement import PipRequirement from letta.schemas.run import Run as PydanticRun from letta.schemas.sandbox_config import E2BSandboxConfig, LocalSandboxConfig, SandboxConfigCreate, SandboxConfigUpdate from letta.schemas.source import Source as PydanticSource, SourceUpdate from letta.schemas.tool import Tool as PydanticTool, ToolCreate, ToolUpdate from letta.schemas.tool_rule import InitToolRule from letta.schemas.user import User as PydanticUser, UserUpdate from letta.server.db import db_registry from letta.server.server import SyncServer from letta.services.block_manager import BlockManager from letta.services.helpers.agent_manager_helper import calculate_base_tools, calculate_multi_agent_tools, validate_agent_exists_async from letta.services.step_manager import FeedbackType from letta.settings import settings, tool_settings from letta.utils import calculate_file_defaults_based_on_context_window from tests.helpers.utils import comprehensive_agent_checks, validate_context_window_overview from tests.utils import random_string # ====================================================================================================================== # AgentManager Tests - Blocks Relationship # ====================================================================================================================== @pytest.mark.asyncio async def test_attach_block(server: SyncServer, sarah_agent, default_block, default_user): """Test attaching a block to an agent.""" # Attach block await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=default_block.id, actor=default_user) # Verify attachment agent = await server.agent_manager.get_agent_by_id_async(sarah_agent.id, actor=default_user) assert len(agent.memory.blocks) == 1 assert agent.memory.blocks[0].id == default_block.id assert agent.memory.blocks[0].label == default_block.label # Test should work with both SQLite and PostgreSQL @pytest.mark.asyncio async def test_attach_block_duplicate_label(server: SyncServer, sarah_agent, default_block, other_block, default_user): """Test attempting to attach a block with a duplicate label.""" # Set up both blocks with same label await server.block_manager.update_block_async(default_block.id, BlockUpdate(label="same_label"), actor=default_user) await server.block_manager.update_block_async(other_block.id, BlockUpdate(label="same_label"), actor=default_user) # Attach first block await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=default_block.id, actor=default_user) # Attempt to attach second block with same label with pytest.raises(UniqueConstraintViolationError): await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=other_block.id, actor=default_user) @pytest.mark.asyncio async def test_detach_block(server: SyncServer, sarah_agent, default_block, default_user): """Test detaching a block by ID.""" # Set up: attach block await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=default_block.id, actor=default_user) # Detach block await server.agent_manager.detach_block_async(agent_id=sarah_agent.id, block_id=default_block.id, actor=default_user) # Verify detachment agent = await server.agent_manager.get_agent_by_id_async(sarah_agent.id, actor=default_user) assert len(agent.memory.blocks) == 0 # Check that block still exists block = await server.block_manager.get_block_by_id_async(block_id=default_block.id, actor=default_user) assert block @pytest.mark.asyncio async def test_detach_nonexistent_block(server: SyncServer, sarah_agent, default_user): """Test detaching a block that isn't attached.""" with pytest.raises(NoResultFound): await server.agent_manager.detach_block_async(agent_id=sarah_agent.id, block_id="nonexistent-block-id", actor=default_user) @pytest.mark.asyncio async def test_update_block_label(server: SyncServer, sarah_agent, default_block, default_user): """Test updating a block's label updates the relationship.""" # Attach block await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=default_block.id, actor=default_user) # Update block label new_label = "new_label" await server.block_manager.update_block_async(default_block.id, BlockUpdate(label=new_label), actor=default_user) # Verify relationship is updated agent = await server.agent_manager.get_agent_by_id_async(sarah_agent.id, actor=default_user) block = agent.memory.blocks[0] assert block.id == default_block.id assert block.label == new_label @pytest.mark.asyncio async def test_update_block_label_multiple_agents(server: SyncServer, sarah_agent, charles_agent, default_block, default_user): """Test updating a block's label updates relationships for all agents.""" # Attach block to both agents await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=default_block.id, actor=default_user) await server.agent_manager.attach_block_async(agent_id=charles_agent.id, block_id=default_block.id, actor=default_user) # Update block label new_label = "new_label" await server.block_manager.update_block_async(default_block.id, BlockUpdate(label=new_label), actor=default_user) # Verify both relationships are updated for agent_id in [sarah_agent.id, charles_agent.id]: agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor=default_user) # Find our specific block by ID block = next(b for b in agent.memory.blocks if b.id == default_block.id) assert block.label == new_label @pytest.mark.asyncio async def test_get_block_with_label(server: SyncServer, sarah_agent, default_block, default_user): """Test retrieving a block by its label.""" # Attach block await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=default_block.id, actor=default_user) # Get block by label block = await server.agent_manager.get_block_with_label_async( agent_id=sarah_agent.id, block_label=default_block.label, actor=default_user ) assert block.id == default_block.id assert block.label == default_block.label @pytest.mark.asyncio async def test_refresh_memory_async(server: SyncServer, default_user): block = await server.block_manager.create_or_update_block_async( PydanticBlock( label="test", value="test", limit=1000, ), actor=default_user, ) block_human = await server.block_manager.create_or_update_block_async( PydanticBlock( label="human", value="name: caren", limit=1000, ), actor=default_user, ) agent = await server.agent_manager.create_agent_async( CreateAgent( name="test", agent_type="memgpt_v2_agent", llm_config=LLMConfig.default_config("gpt-4o-mini"), embedding_config=EmbeddingConfig.default_config(provider="openai"), include_base_tools=False, block_ids=[block.id, block_human.id], ), actor=default_user, ) block = await server.block_manager.update_block_async( block_id=block.id, block_update=BlockUpdate( value="test2", ), actor=default_user, ) assert len(agent.memory.blocks) == 2 agent = await server.agent_manager.refresh_memory_async(agent_state=agent, actor=default_user) assert len(agent.memory.blocks) == 2 assert any([block.value == "test2" for block in agent.memory.blocks]) # ====================================================================================================================== # Block Manager Tests - Basic # ====================================================================================================================== @pytest.mark.asyncio async def test_create_block(server: SyncServer, default_user): block_manager = BlockManager() block_create = PydanticBlock( label="human", is_template=True, value="Sample content", template_name="sample_template_name", template_id="sample_template", description="A test block", limit=1000, metadata={"example": "data"}, ) block = await block_manager.create_or_update_block_async(block_create, actor=default_user) # Assertions to ensure the created block matches the expected values assert block.label == block_create.label assert block.is_template == block_create.is_template assert block.value == block_create.value assert block.template_name == block_create.template_name assert block.template_id == block_create.template_id assert block.description == block_create.description assert block.limit == block_create.limit assert block.metadata == block_create.metadata async def test_batch_create_blocks_async(server: SyncServer, default_user): """Test batch creating multiple blocks at once""" block_manager = BlockManager() # create multiple test blocks blocks_data = [] for i in range(5): block = PydanticBlock( label=f"test_block_{i}", is_template=False, value=f"Content for block {i}", description=f"Test block {i} for batch operations", limit=1000 + i * 100, # varying limits metadata={"index": i, "batch": "test"}, ) blocks_data.append(block) # batch create all blocks at once created_blocks = await block_manager.batch_create_blocks_async(blocks_data, default_user) # verify all blocks were created assert len(created_blocks) == 5 assert all(b.label.startswith("test_block_") for b in created_blocks) # verify block properties were preserved for i, block in enumerate(created_blocks): assert block.label == f"test_block_{i}" assert block.value == f"Content for block {i}" assert block.description == f"Test block {i} for batch operations" assert block.limit == 1000 + i * 100 assert block.metadata["index"] == i assert block.metadata["batch"] == "test" assert block.id is not None # should have generated ids # blocks have organization_id at the orm level, not in the pydantic model # verify blocks can be retrieved individually for created_block in created_blocks: retrieved = await block_manager.get_block_by_id_async(created_block.id, default_user) assert retrieved.id == created_block.id assert retrieved.label == created_block.label assert retrieved.value == created_block.value # test with empty list empty_result = await block_manager.batch_create_blocks_async([], default_user) assert empty_result == [] # test creating blocks with same labels (should create separate blocks since no unique constraint) duplicate_blocks = [ PydanticBlock(label="duplicate_label", value="Block 1"), PydanticBlock(label="duplicate_label", value="Block 2"), PydanticBlock(label="duplicate_label", value="Block 3"), ] created_duplicates = await block_manager.batch_create_blocks_async(duplicate_blocks, default_user) assert len(created_duplicates) == 3 assert all(b.label == "duplicate_label" for b in created_duplicates) # all should have different ids ids = [b.id for b in created_duplicates] assert len(set(ids)) == 3 # all unique ids # but different values values = [b.value for b in created_duplicates] assert set(values) == {"Block 1", "Block 2", "Block 3"} @pytest.mark.asyncio async def test_get_blocks(server, default_user): block_manager = BlockManager() # Create blocks to retrieve later await block_manager.create_or_update_block_async(PydanticBlock(label="human", value="Block 1"), actor=default_user) await block_manager.create_or_update_block_async(PydanticBlock(label="persona", value="Block 2"), actor=default_user) # Retrieve blocks by different filters all_blocks = await block_manager.get_blocks_async(actor=default_user) assert len(all_blocks) == 2 human_blocks = await block_manager.get_blocks_async(actor=default_user, label="human") assert len(human_blocks) == 1 assert human_blocks[0].label == "human" persona_blocks = await block_manager.get_blocks_async(actor=default_user, label="persona") assert len(persona_blocks) == 1 assert persona_blocks[0].label == "persona" @pytest.mark.asyncio async def test_get_blocks_comprehensive(server, default_user, other_user_different_org): def random_label(prefix="label"): return f"{prefix}_{''.join(random.choices(string.ascii_lowercase, k=6))}" def random_value(): return "".join(random.choices(string.ascii_letters + string.digits, k=12)) block_manager = BlockManager() # Create 10 blocks for default_user default_user_blocks = [] for _ in range(10): label = random_label("default") value = random_value() await block_manager.create_or_update_block_async(PydanticBlock(label=label, value=value), actor=default_user) default_user_blocks.append((label, value)) # Create 3 blocks for other_user other_user_blocks = [] for _ in range(3): label = random_label("other") value = random_value() await block_manager.create_or_update_block_async(PydanticBlock(label=label, value=value), actor=other_user_different_org) other_user_blocks.append((label, value)) # Check default_user sees only their blocks retrieved_default_blocks = await block_manager.get_blocks_async(actor=default_user) assert len(retrieved_default_blocks) == 10 retrieved_labels = {b.label for b in retrieved_default_blocks} for label, value in default_user_blocks: assert label in retrieved_labels # Check individual filtering for default_user for label, value in default_user_blocks: filtered = await block_manager.get_blocks_async(actor=default_user, label=label) assert len(filtered) == 1 assert filtered[0].label == label assert filtered[0].value == value # Check other_user sees only their blocks retrieved_other_blocks = await block_manager.get_blocks_async(actor=other_user_different_org) assert len(retrieved_other_blocks) == 3 retrieved_labels = {b.label for b in retrieved_other_blocks} for label, value in other_user_blocks: assert label in retrieved_labels # Other user shouldn't see default_user's blocks for label, _ in default_user_blocks: assert (await block_manager.get_blocks_async(actor=other_user_different_org, label=label)) == [] # Default user shouldn't see other_user's blocks for label, _ in other_user_blocks: assert (await block_manager.get_blocks_async(actor=default_user, label=label)) == [] # ====================================================================================================================== # BlockManager Pagination Tests # ====================================================================================================================== @pytest.mark.asyncio async def test_get_blocks_pagination_with_after_cursor(server, default_user): """Test cursor-based pagination using the 'after' parameter.""" block_manager = BlockManager() # Use a unique label prefix to isolate this test's blocks test_label = f"pagination_after_{uuid.uuid4().hex[:8]}" # Create 5 blocks with delays to ensure distinct timestamps created_blocks = [] for i in range(5): if USING_SQLITE and i > 0: time.sleep(CREATE_DELAY_SQLITE) block = await block_manager.create_or_update_block_async(PydanticBlock(label=test_label, value=f"Block {i}"), actor=default_user) created_blocks.append(block) # Get first page (ascending order - oldest first), filtered by label page1 = await block_manager.get_blocks_async(actor=default_user, label=test_label, limit=2, ascending=True) assert len(page1) == 2 # Should be the first 2 blocks created (oldest) assert page1[0].id == created_blocks[0].id assert page1[1].id == created_blocks[1].id # Get second page using 'after' cursor page2 = await block_manager.get_blocks_async(actor=default_user, label=test_label, limit=2, after=page1[1].id, ascending=True) assert len(page2) == 2 assert page2[0].id == created_blocks[2].id assert page2[1].id == created_blocks[3].id # Get third page (should have 1 block left) page3 = await block_manager.get_blocks_async(actor=default_user, label=test_label, limit=2, after=page2[1].id, ascending=True) assert len(page3) == 1 assert page3[0].id == created_blocks[4].id # All blocks should appear exactly once across all pages all_paginated_ids = {b.id for b in page1 + page2 + page3} all_created_ids = {b.id for b in created_blocks} assert all_paginated_ids == all_created_ids @pytest.mark.asyncio async def test_get_blocks_pagination_with_before_cursor(server, default_user): """Test cursor-based pagination using the 'before' parameter. The 'before' cursor returns items that would appear before the cursor in the sorted order, starting from the beginning of that subset. This is useful for filtering, not for "previous page" navigation (which would require reversing the sort order). """ block_manager = BlockManager() # Use a unique label prefix to isolate this test's blocks test_label = f"pagination_before_{uuid.uuid4().hex[:8]}" # Create 5 blocks with delays to ensure distinct timestamps created_blocks = [] for i in range(5): if USING_SQLITE and i > 0: time.sleep(CREATE_DELAY_SQLITE) block = await block_manager.create_or_update_block_async(PydanticBlock(label=test_label, value=f"Block {i}"), actor=default_user) created_blocks.append(block) # Using the last block as cursor with ascending order # Returns items that come before block 4 in ascending order: [0, 1, 2, 3] # With limit=2, we get the first 2: [0, 1] page1 = await block_manager.get_blocks_async(actor=default_user, label=test_label, limit=2, before=created_blocks[4].id, ascending=True) assert len(page1) == 2 assert page1[0].id == created_blocks[0].id assert page1[1].id == created_blocks[1].id # Get next page using 'after' cursor on last item page2 = await block_manager.get_blocks_async( actor=default_user, label=test_label, limit=2, after=page1[1].id, before=created_blocks[4].id, ascending=True ) assert len(page2) == 2 assert page2[0].id == created_blocks[2].id assert page2[1].id == created_blocks[3].id @pytest.mark.asyncio async def test_get_blocks_pagination_descending_order(server, default_user): """Test pagination with descending order (newest first).""" block_manager = BlockManager() # Use a unique label prefix to isolate this test's blocks test_label = f"pagination_desc_{uuid.uuid4().hex[:8]}" # Create 4 blocks with delays created_blocks = [] for i in range(4): if USING_SQLITE and i > 0: time.sleep(CREATE_DELAY_SQLITE) block = await block_manager.create_or_update_block_async(PydanticBlock(label=test_label, value=f"Block {i}"), actor=default_user) created_blocks.append(block) # Get first page in descending order (newest first) page1 = await block_manager.get_blocks_async(actor=default_user, label=test_label, limit=2, ascending=False) assert len(page1) == 2 # Should be the last 2 blocks created (newest) assert page1[0].id == created_blocks[3].id assert page1[1].id == created_blocks[2].id # Get second page using 'after' cursor page2 = await block_manager.get_blocks_async(actor=default_user, label=test_label, limit=2, after=page1[1].id, ascending=False) assert len(page2) == 2 assert page2[0].id == created_blocks[1].id assert page2[1].id == created_blocks[0].id @pytest.mark.asyncio async def test_get_blocks_pagination_all_blocks_found(server, default_user): """Test that pagination finds all blocks across multiple pages without skipping any.""" block_manager = BlockManager() # Use a unique label prefix to isolate this test's blocks test_label = f"pagination_allfound_{uuid.uuid4().hex[:8]}" # Create 10 blocks created_blocks = [] for i in range(10): if USING_SQLITE and i > 0: time.sleep(CREATE_DELAY_SQLITE) block = await block_manager.create_or_update_block_async(PydanticBlock(label=test_label, value=f"Block {i}"), actor=default_user) created_blocks.append(block) # Paginate through all blocks with page size of 3 all_paginated = [] cursor = None page_count = 0 while True: page = await block_manager.get_blocks_async(actor=default_user, label=test_label, limit=3, after=cursor, ascending=True) if not page: break all_paginated.extend(page) cursor = page[-1].id page_count += 1 # Should have 4 pages: 3 + 3 + 3 + 1 assert page_count == 4, f"Expected 4 pages but got {page_count}" # All 10 blocks should be found assert len(all_paginated) == 10, f"Expected 10 blocks but found {len(all_paginated)}" # All created block IDs should be present paginated_ids = {b.id for b in all_paginated} created_ids = {b.id for b in created_blocks} assert paginated_ids == created_ids, "Not all blocks were found through pagination" @pytest.mark.asyncio async def test_get_agents_for_block_pagination(server: SyncServer, sarah_agent, charles_agent, default_user): """Test pagination for get_agents_for_block_async.""" block_manager = BlockManager() # Use a unique label to isolate this test's block test_label = f"pagination_agents_{uuid.uuid4().hex[:8]}" # Create a shared block shared_block = await block_manager.create_or_update_block_async( PydanticBlock(label=test_label, value="Shared block content"), actor=default_user ) # Attach the block to both agents await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=shared_block.id, actor=default_user) await server.agent_manager.attach_block_async(agent_id=charles_agent.id, block_id=shared_block.id, actor=default_user) # Get first page of agents page1 = await block_manager.get_agents_for_block_async(block_id=shared_block.id, actor=default_user, limit=1, ascending=True) assert len(page1) == 1 # Get second page using 'after' cursor page2 = await block_manager.get_agents_for_block_async( block_id=shared_block.id, actor=default_user, limit=1, after=page1[0].id, ascending=True ) assert len(page2) == 1 # Verify both agents were found and they're different assert page1[0].id != page2[0].id agent_ids = {page1[0].id, page2[0].id} expected_ids = {sarah_agent.id, charles_agent.id} assert agent_ids == expected_ids @pytest.mark.asyncio async def test_update_block(server: SyncServer, default_user): block_manager = BlockManager() block = await block_manager.create_or_update_block_async(PydanticBlock(label="persona", value="Original Content"), actor=default_user) # Update block's content update_data = BlockUpdate(value="Updated Content", description="Updated description") await block_manager.update_block_async(block_id=block.id, block_update=update_data, actor=default_user) # Retrieve the updated block updated_block = await block_manager.get_block_by_id_async(actor=default_user, block_id=block.id) # Assertions to verify the update assert updated_block.value == "Updated Content" assert updated_block.description == "Updated description" @pytest.mark.asyncio async def test_update_block_limit(server: SyncServer, default_user): block_manager = BlockManager() block = await block_manager.create_or_update_block_async(PydanticBlock(label="persona", value="Original Content"), actor=default_user) limit = len("Updated Content") * 2000 update_data = BlockUpdate(value="Updated Content" * 2000, description="Updated description") # Check that exceeding the block limit raises an exception with pytest.raises(LettaInvalidArgumentError): await block_manager.update_block_async(block_id=block.id, block_update=update_data, actor=default_user) # Ensure the update works when within limits update_data = BlockUpdate(value="Updated Content" * 2000, description="Updated description", limit=limit) await block_manager.update_block_async(block_id=block.id, block_update=update_data, actor=default_user) # Retrieve the updated block and validate the update updated_block = await block_manager.get_block_by_id_async(actor=default_user, block_id=block.id) assert updated_block.value == "Updated Content" * 2000 assert updated_block.description == "Updated description" @pytest.mark.asyncio async def test_update_block_limit_does_not_reset(server: SyncServer, default_user): block_manager = BlockManager() new_content = "Updated Content" * 2000 limit = len(new_content) block = await block_manager.create_or_update_block_async( PydanticBlock(label="persona", value="Original Content", limit=limit), actor=default_user ) # Ensure the update works update_data = BlockUpdate(value=new_content) await block_manager.update_block_async(block_id=block.id, block_update=update_data, actor=default_user) # Retrieve the updated block and validate the update updated_block = await block_manager.get_block_by_id_async(actor=default_user, block_id=block.id) assert updated_block.value == new_content @pytest.mark.asyncio async def test_update_nonexistent_block(server: SyncServer, default_user): """Test that updating a non-existent block raises NoResultFound (which maps to 404).""" block_manager = BlockManager() # Try to update a block that doesn't exist nonexistent_block_id = "block-7d73d0a7-6e86-4db7-b53a-411c11ed958a" update_data = BlockUpdate(value="Updated Content") with pytest.raises(NoResultFound): await block_manager.update_block_async(block_id=nonexistent_block_id, block_update=update_data, actor=default_user) @pytest.mark.asyncio async def test_delete_block(server: SyncServer, default_user): block_manager = BlockManager() # Create and delete a block block = await block_manager.create_or_update_block_async(PydanticBlock(label="human", value="Sample content"), actor=default_user) await block_manager.delete_block_async(block_id=block.id, actor=default_user) # Verify that the block was deleted blocks = await block_manager.get_blocks_async(actor=default_user) assert len(blocks) == 0 @pytest.mark.asyncio async def test_delete_block_detaches_from_agent(server: SyncServer, sarah_agent, default_user): # Create and delete a block block = await server.block_manager.create_or_update_block_async( PydanticBlock(label="human", value="Sample content"), actor=default_user ) agent_state = await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=block.id, actor=default_user) # Check that block has been attached assert block.id in [b.id for b in agent_state.memory.blocks] # Now attempt to delete the block await server.block_manager.delete_block_async(block_id=block.id, actor=default_user) # Verify that the block was deleted blocks = await server.block_manager.get_blocks_async(actor=default_user) assert len(blocks) == 0 # Check that block has been detached too agent_state = await server.agent_manager.get_agent_by_id_async(agent_id=sarah_agent.id, actor=default_user) assert block.id not in [b.id for b in agent_state.memory.blocks] @pytest.mark.asyncio async def test_get_agents_for_block(server: SyncServer, sarah_agent, charles_agent, default_user): # Create and delete a block block = await server.block_manager.create_or_update_block_async( PydanticBlock(label="alien", value="Sample content"), actor=default_user ) sarah_agent = await server.agent_manager.attach_block_async(agent_id=sarah_agent.id, block_id=block.id, actor=default_user) charles_agent = await server.agent_manager.attach_block_async(agent_id=charles_agent.id, block_id=block.id, actor=default_user) # Check that block has been attached to both assert block.id in [b.id for b in sarah_agent.memory.blocks] assert block.id in [b.id for b in charles_agent.memory.blocks] # Get the agents for that block agent_states = await server.block_manager.get_agents_for_block_async(block_id=block.id, actor=default_user) assert len(agent_states) == 2 # Check both agents are in the list agent_state_ids = [a.id for a in agent_states] assert sarah_agent.id in agent_state_ids assert charles_agent.id in agent_state_ids @pytest.mark.asyncio async def test_batch_create_multiple_blocks(server: SyncServer, default_user): block_manager = BlockManager() num_blocks = 10 # Prepare distinct blocks blocks_to_create = [PydanticBlock(label=f"batch_label_{i}", value=f"batch_value_{i}") for i in range(num_blocks)] # Create the blocks created_blocks = await block_manager.batch_create_blocks_async(blocks_to_create, actor=default_user) assert len(created_blocks) == num_blocks # Map created blocks by label for lookup created_by_label = {blk.label: blk for blk in created_blocks} # Assert all blocks were created correctly for i in range(num_blocks): label = f"batch_label_{i}" value = f"batch_value_{i}" assert label in created_by_label, f"Missing label: {label}" blk = created_by_label[label] assert blk.value == value assert blk.id is not None # Confirm all created blocks exist in the full list from get_blocks all_labels = {blk.label for blk in await block_manager.get_blocks_async(actor=default_user)} expected_labels = {f"batch_label_{i}" for i in range(num_blocks)} assert expected_labels.issubset(all_labels) async def test_bulk_update_skips_missing_and_truncates_then_returns_none(server: SyncServer, default_user: PydanticUser, caplog): mgr = BlockManager() # create one block with a small limit b = await mgr.create_or_update_block_async( PydanticBlock(label="human", value="orig", limit=5), actor=default_user, ) # prepare updates: one real id with an over‐limit value, plus one missing id long_val = random_string(10) # length > limit==5 updates = { b.id: long_val, "nonexistent-id": "whatever", } caplog.set_level(logging.WARNING) result = await mgr.bulk_update_block_values_async(updates, actor=default_user) # default return_hydrated=False → should be None assert result is None # warnings should mention skipping the missing ID and truncation assert "skipping during bulk update" in caplog.text assert "truncating" in caplog.text # confirm the value was truncated to `limit` characters reloaded = await mgr.get_block_by_id_async(actor=default_user, block_id=b.id) assert len(reloaded.value) == 5 assert reloaded.value == long_val[:5] @pytest.mark.skip(reason="TODO: implement for async") async def test_bulk_update_return_hydrated_true(server: SyncServer, default_user: PydanticUser): mgr = BlockManager() # create a block b = await mgr.create_or_update_block_async( PydanticBlock(label="persona", value="foo", limit=20), actor=default_user, ) updates = {b.id: "new-val"} updated = await mgr.bulk_update_block_values_async(updates, actor=default_user, return_hydrated=True) # with return_hydrated=True, we get back a list of schemas assert isinstance(updated, list) and len(updated) == 1 assert updated[0].id == b.id assert updated[0].value == "new-val" async def test_bulk_update_respects_org_scoping( server: SyncServer, default_user: PydanticUser, other_user_different_org: PydanticUser, caplog ): mgr = BlockManager() # one block in each org mine = await mgr.create_or_update_block_async( PydanticBlock(label="human", value="mine", limit=100), actor=default_user, ) theirs = await mgr.create_or_update_block_async( PydanticBlock(label="human", value="theirs", limit=100), actor=other_user_different_org, ) updates = { mine.id: "updated-mine", theirs.id: "updated-theirs", } caplog.set_level(logging.WARNING) await mgr.bulk_update_block_values_async(updates, actor=default_user) # mine should be updated... reloaded_mine = await mgr.get_block_by_id_async(actor=default_user, block_id=mine.id) assert reloaded_mine.value == "updated-mine" # ...theirs should remain untouched reloaded_theirs = await mgr.get_block_by_id_async(actor=other_user_different_org, block_id=theirs.id) assert reloaded_theirs.value == "theirs" # warning should mention skipping the other-org ID assert "skipping during bulk update" in caplog.text # ====================================================================================================================== # Block Manager Tests - Checkpointing # ====================================================================================================================== @pytest.mark.asyncio async def test_checkpoint_creates_history(server: SyncServer, default_user): """ Ensures that calling checkpoint_block creates a BlockHistory row and updates the block's current_history_entry_id appropriately. """ block_manager = BlockManager() # Create a block initial_value = "Initial block content" created_block = await block_manager.create_or_update_block_async( PydanticBlock(label="test_checkpoint", value=initial_value), actor=default_user ) # Act: checkpoint it await block_manager.checkpoint_block_async(block_id=created_block.id, actor=default_user) async with db_registry.async_session() as session: # Get BlockHistory entries for this block from sqlalchemy import select stmt = select(BlockHistory).filter(BlockHistory.block_id == created_block.id) result = await session.execute(stmt) history_entries = list(result.scalars().all()) assert len(history_entries) == 1, "Exactly one history entry should be created" hist = history_entries[0] # Fetch ORM block for internal checks db_block = await session.get(Block, created_block.id) assert hist.sequence_number == 1 assert hist.value == initial_value assert hist.actor_type == ActorType.LETTA_USER assert hist.actor_id == default_user.id assert db_block.current_history_entry_id == hist.id @pytest.mark.asyncio async def test_multiple_checkpoints(server: SyncServer, default_user): block_manager = BlockManager() # Create a block block = await block_manager.create_or_update_block_async(PydanticBlock(label="test_multi_checkpoint", value="v1"), actor=default_user) # 1) First checkpoint await block_manager.checkpoint_block_async(block_id=block.id, actor=default_user) # 2) Update block content updated_block_data = PydanticBlock(**block.model_dump()) updated_block_data.value = "v2" await block_manager.create_or_update_block_async(updated_block_data, actor=default_user) # 3) Second checkpoint await block_manager.checkpoint_block_async(block_id=block.id, actor=default_user) async with db_registry.async_session() as session: from sqlalchemy import select stmt = select(BlockHistory).filter(BlockHistory.block_id == block.id).order_by(BlockHistory.sequence_number.asc()) result = await session.execute(stmt) history_entries = list(result.scalars().all()) assert len(history_entries) == 2, "Should have two history entries" # First is seq=1, value='v1' assert history_entries[0].sequence_number == 1 assert history_entries[0].value == "v1" # Second is seq=2, value='v2' assert history_entries[1].sequence_number == 2 assert history_entries[1].value == "v2" # The block should now point to the second entry db_block = await session.get(Block, block.id) assert db_block.current_history_entry_id == history_entries[1].id @pytest.mark.asyncio async def test_checkpoint_with_agent_id(server: SyncServer, default_user, sarah_agent): """ Ensures that if we pass agent_id to checkpoint_block, we get actor_type=LETTA_AGENT, actor_id= in BlockHistory. """ block_manager = BlockManager() # Create a block block = await block_manager.create_or_update_block_async( PydanticBlock(label="test_agent_checkpoint", value="Agent content"), actor=default_user ) # Checkpoint with agent_id await block_manager.checkpoint_block_async(block_id=block.id, actor=default_user, agent_id=sarah_agent.id) # Verify async with db_registry.async_session() as session: from sqlalchemy import select stmt = select(BlockHistory).filter(BlockHistory.block_id == block.id) result = await session.execute(stmt) hist_entry = result.scalar_one() assert hist_entry.actor_type == ActorType.LETTA_AGENT assert hist_entry.actor_id == sarah_agent.id @pytest.mark.asyncio async def test_checkpoint_with_no_state_change(server: SyncServer, default_user): """ If we call checkpoint_block twice without any edits, we expect two entries or only one, depending on your policy. """ block_manager = BlockManager() # Create block block = await block_manager.create_or_update_block_async(PydanticBlock(label="test_no_change", value="original"), actor=default_user) # 1) checkpoint await block_manager.checkpoint_block_async(block_id=block.id, actor=default_user) # 2) checkpoint again (no changes) await block_manager.checkpoint_block_async(block_id=block.id, actor=default_user) async with db_registry.async_session() as session: from sqlalchemy import select stmt = select(BlockHistory).filter(BlockHistory.block_id == block.id) result = await session.execute(stmt) all_hist = list(result.scalars().all()) assert len(all_hist) == 2 @pytest.mark.asyncio async def test_checkpoint_concurrency_stale(server: SyncServer, default_user): block_manager = BlockManager() # create block block = await block_manager.create_or_update_block_async( PydanticBlock(label="test_stale_checkpoint", value="hello"), actor=default_user ) # session1 loads async with db_registry.async_session() as s1: block_s1 = await s1.get(Block, block.id) # version=1 # session2 loads async with db_registry.async_session() as s2: block_s2 = await s2.get(Block, block.id) # also version=1 # session1 checkpoint => version=2 async with db_registry.async_session() as s1: block_s1 = await s1.merge(block_s1) await block_manager.checkpoint_block_async( block_id=block_s1.id, actor=default_user, use_preloaded_block=block_s1, # let manager use the object in memory ) # commits inside checkpoint_block => version goes to 2 # session2 tries to checkpoint => sees old version=1 => stale error with pytest.raises(StaleDataError): async with db_registry.async_session() as s2: block_s2 = await s2.merge(block_s2) await block_manager.checkpoint_block_async( block_id=block_s2.id, actor=default_user, use_preloaded_block=block_s2, ) @pytest.mark.asyncio async def test_checkpoint_no_future_states(server: SyncServer, default_user): """ Ensures that if the block is already at the highest sequence, creating a new checkpoint does NOT delete anything. """ block_manager = BlockManager() # 1) Create block with "v1" and checkpoint => seq=1 block_v1 = await block_manager.create_or_update_block_async(PydanticBlock(label="no_future_test", value="v1"), actor=default_user) await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # 2) Create "v2" and checkpoint => seq=2 updated_data = PydanticBlock(**block_v1.model_dump()) updated_data.value = "v2" await block_manager.create_or_update_block_async(updated_data, actor=default_user) await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # So we have seq=1: v1, seq=2: v2. No "future" states. # 3) Another checkpoint (no changes made) => should become seq=3, not delete anything await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) async with db_registry.async_session() as session: # We expect 3 rows in block_history, none removed from sqlalchemy import select stmt = select(BlockHistory).filter(BlockHistory.block_id == block_v1.id).order_by(BlockHistory.sequence_number.asc()) result = await session.execute(stmt) history_rows = list(result.scalars().all()) # Should be seq=1, seq=2, seq=3 assert len(history_rows) == 3 assert history_rows[0].value == "v1" assert history_rows[1].value == "v2" # The last is also "v2" if we didn't change it, or the same current fields assert history_rows[2].sequence_number == 3 # There's no leftover row that was deleted # ====================================================================================================================== # Block Manager Tests - Undo # ====================================================================================================================== @pytest.mark.asyncio async def test_undo_checkpoint_block(server: SyncServer, default_user): """ Verifies that we can undo to the previous checkpoint: 1) Create a block and checkpoint -> sequence_number=1 2) Update block content and checkpoint -> sequence_number=2 3) Undo -> should revert block to sequence_number=1's content """ block_manager = BlockManager() # 1) Create block initial_value = "Version 1 content" created_block = await block_manager.create_or_update_block_async( PydanticBlock(label="undo_test", value=initial_value), actor=default_user ) # 2) First checkpoint => seq=1 await block_manager.checkpoint_block_async(block_id=created_block.id, actor=default_user) # 3) Update block content to "Version 2" updated_data = PydanticBlock(**created_block.model_dump()) updated_data.value = "Version 2 content" await block_manager.create_or_update_block_async(updated_data, actor=default_user) # 4) Second checkpoint => seq=2 await block_manager.checkpoint_block_async(block_id=created_block.id, actor=default_user) # 5) Undo => revert to seq=1 undone_block = await block_manager.undo_checkpoint_block(block_id=created_block.id, actor=default_user) # 6) Verify the block is now restored to "Version 1" content assert undone_block.value == initial_value, "Block should revert to version 1 content" assert undone_block.label == "undo_test", "Label should also revert if changed (or remain the same if unchanged)" # @pytest.mark.asyncio # async def test_checkpoint_deletes_future_states_after_undo(server: SyncServer, default_user): # """ # Verifies that once we've undone to an earlier checkpoint, creating a new # checkpoint removes any leftover 'future' states that existed beyond that sequence. # """ # block_manager = BlockManager() # # # 1) Create block # block_init = PydanticBlock(label="test_truncation", value="v1") # block_v1 = await block_manager.create_or_update_block_async(block_init, actor=default_user) # # Checkpoint => seq=1 # await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # # # 2) Update to "v2", checkpoint => seq=2 # block_v2 = PydanticBlock(**block_v1.model_dump()) # block_v2.value = "v2" # await block_manager.create_or_update_block_async(block_v2, actor=default_user) # await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # # # 3) Update to "v3", checkpoint => seq=3 # block_v3 = PydanticBlock(**block_v1.model_dump()) # block_v3.value = "v3" # await block_manager.create_or_update_block_async(block_v3, actor=default_user) # await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # # # We now have three states in history: seq=1 (v1), seq=2 (v2), seq=3 (v3). # # # Undo from seq=3 -> seq=2 # block_undo_1 = await block_manager.undo_checkpoint_block(block_v1.id, actor=default_user) # assert block_undo_1.value == "v2" # # # Undo from seq=2 -> seq=1 # block_undo_2 = await block_manager.undo_checkpoint_block(block_v1.id, actor=default_user) # assert block_undo_2.value == "v1" # # # 4) Now we are at seq=1. If we checkpoint again, we should remove the old seq=2,3 # # because the new code truncates future states beyond seq=1. # # # Let's do a new edit: "v1.5" # block_v1_5 = PydanticBlock(**block_undo_2.model_dump()) # block_v1_5.value = "v1.5" # await block_manager.create_or_update_block_async(block_v1_5, actor=default_user) # # # 5) Checkpoint => new seq=2, removing the old seq=2 and seq=3 # await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # # async with db_registry.async_session() as session: # # Let's see which BlockHistory rows remain # from sqlalchemy import select # # stmt = select(BlockHistory).filter(BlockHistory.block_id == block_v1.id).order_by(BlockHistory.sequence_number.asc()) # result = await session.execute(stmt) # history_entries = list(result.scalars().all()) # # # We expect two rows: seq=1 => "v1", seq=2 => "v1.5" # assert len(history_entries) == 2, f"Expected 2 entries, got {len(history_entries)}" # assert history_entries[0].sequence_number == 1 # assert history_entries[0].value == "v1" # assert history_entries[1].sequence_number == 2 # assert history_entries[1].value == "v1.5" # # # No row should contain "v2" or "v3" # existing_values = {h.value for h in history_entries} # assert "v2" not in existing_values, "Old seq=2 should have been removed." # assert "v3" not in existing_values, "Old seq=3 should have been removed." @pytest.mark.asyncio async def test_undo_no_history(server: SyncServer, default_user): """ If a block has never been checkpointed (no current_history_entry_id), undo_checkpoint_block should raise a LettaInvalidArgumentError. """ block_manager = BlockManager() # Create a block but don't checkpoint it block = await block_manager.create_or_update_block_async(PydanticBlock(label="no_history_test", value="initial"), actor=default_user) # Attempt to undo with pytest.raises(LettaInvalidArgumentError): await block_manager.undo_checkpoint_block(block_id=block.id, actor=default_user) @pytest.mark.asyncio async def test_undo_first_checkpoint(server: SyncServer, default_user): """ If the block is at the first checkpoint (sequence_number=1), undo should fail because there's no prior checkpoint. """ block_manager = BlockManager() # 1) Create the block block_data = PydanticBlock(label="first_checkpoint", value="Version1") block = await block_manager.create_or_update_block_async(block_data, actor=default_user) # 2) First checkpoint => seq=1 await block_manager.checkpoint_block_async(block_id=block.id, actor=default_user) # Attempt undo -> expect LettaInvalidArgumentError with pytest.raises(LettaInvalidArgumentError): await block_manager.undo_checkpoint_block(block_id=block.id, actor=default_user) @pytest.mark.asyncio async def test_undo_multiple_checkpoints(server: SyncServer, default_user): """ Tests multiple checkpoints in a row, then undo repeatedly from seq=3 -> seq=2 -> seq=1, verifying each revert. """ block_manager = BlockManager() # Step 1: Create block block_data = PydanticBlock(label="multi_checkpoint", value="v1") block_v1 = await block_manager.create_or_update_block_async(block_data, actor=default_user) # checkpoint => seq=1 await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # Step 2: Update to v2, checkpoint => seq=2 block_data_v2 = PydanticBlock(**block_v1.model_dump()) block_data_v2.value = "v2" await block_manager.create_or_update_block_async(block_data_v2, actor=default_user) await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # Step 3: Update to v3, checkpoint => seq=3 block_data_v3 = PydanticBlock(**block_v1.model_dump()) block_data_v3.value = "v3" await block_manager.create_or_update_block_async(block_data_v3, actor=default_user) await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # Now we have 3 seq: v1, v2, v3 # Undo from seq=3 -> seq=2 undone_block = await block_manager.undo_checkpoint_block(block_v1.id, actor=default_user) assert undone_block.value == "v2" # Undo from seq=2 -> seq=1 undone_block = await block_manager.undo_checkpoint_block(block_v1.id, actor=default_user) assert undone_block.value == "v1" # Try once more -> fails because seq=1 is the earliest with pytest.raises(LettaInvalidArgumentError): await block_manager.undo_checkpoint_block(block_v1.id, actor=default_user) @pytest.mark.asyncio async def test_undo_concurrency_stale(server: SyncServer, default_user): """ Demonstrate concurrency: both sessions start with the block at seq=2, one session undoes first -> block now seq=1, version increments, the other session tries to undo with stale data -> StaleDataError. """ block_manager = BlockManager() # 1) create block block_data = PydanticBlock(label="concurrency_undo", value="v1") block_v1 = await block_manager.create_or_update_block_async(block_data, actor=default_user) # checkpoint => seq=1 await block_manager.checkpoint_block_async(block_v1.id, actor=default_user) # 2) update to v2 block_data_v2 = PydanticBlock(**block_v1.model_dump()) block_data_v2.value = "v2" await block_manager.create_or_update_block_async(block_data_v2, actor=default_user) # checkpoint => seq=2 await block_manager.checkpoint_block_async(block_v1.id, actor=default_user) # Now block is at seq=2 # session1 preloads the block async with db_registry.async_session() as s1: block_s1 = await s1.get(Block, block_v1.id) # version=? let's say 2 in memory # session2 also preloads the block async with db_registry.async_session() as s2: block_s2 = await s2.get(Block, block_v1.id) # also version=2 # Session1 -> undo to seq=1 await block_manager.undo_checkpoint_block( block_id=block_v1.id, actor=default_user, use_preloaded_block=block_s1, # stale object from session1 ) # This commits first => block now points to seq=1, version increments # Session2 tries the same undo, but it's stale with pytest.raises(StaleDataError): await block_manager.undo_checkpoint_block( block_id=block_v1.id, actor=default_user, use_preloaded_block=block_s2 ) # also seq=2 in memory # ====================================================================================================================== # Block Manager Tests - Redo # ====================================================================================================================== @pytest.mark.asyncio async def test_redo_checkpoint_block(server: SyncServer, default_user): """ 1) Create a block with value v1 -> checkpoint => seq=1 2) Update to v2 -> checkpoint => seq=2 3) Update to v3 -> checkpoint => seq=3 4) Undo once (seq=3 -> seq=2) 5) Redo once (seq=2 -> seq=3) """ block_manager = BlockManager() # 1) Create block, set value='v1'; checkpoint => seq=1 block_v1 = await block_manager.create_or_update_block_async(PydanticBlock(label="redo_test", value="v1"), actor=default_user) await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # 2) Update to 'v2'; checkpoint => seq=2 block_v2 = PydanticBlock(**block_v1.model_dump()) block_v2.value = "v2" await block_manager.create_or_update_block_async(block_v2, actor=default_user) await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # 3) Update to 'v3'; checkpoint => seq=3 block_v3 = PydanticBlock(**block_v1.model_dump()) block_v3.value = "v3" await block_manager.create_or_update_block_async(block_v3, actor=default_user) await block_manager.checkpoint_block_async(block_id=block_v1.id, actor=default_user) # Undo from seq=3 -> seq=2 undone_block = await block_manager.undo_checkpoint_block(block_v1.id, actor=default_user) assert undone_block.value == "v2", "After undo, block should revert to v2" # Redo from seq=2 -> seq=3 redone_block = await block_manager.redo_checkpoint_block(block_v1.id, actor=default_user) assert redone_block.value == "v3", "After redo, block should go back to v3" @pytest.mark.asyncio async def test_redo_no_history(server: SyncServer, default_user): """ If a block has no current_history_entry_id (never checkpointed), then redo_checkpoint_block should raise LettaInvalidArgumentError. """ block_manager = BlockManager() # Create block with no checkpoint block = await block_manager.create_or_update_block_async(PydanticBlock(label="redo_no_history", value="v0"), actor=default_user) # Attempt to redo => expect LettaInvalidArgumentError with pytest.raises(LettaInvalidArgumentError): await block_manager.redo_checkpoint_block(block.id, actor=default_user) @pytest.mark.asyncio async def test_redo_at_highest_checkpoint(server: SyncServer, default_user): """ If the block is at the maximum sequence number, there's no higher checkpoint to move to. redo_checkpoint_block should raise LettaInvalidArgumentError. """ block_manager = BlockManager() # 1) Create block => checkpoint => seq=1 b_init = await block_manager.create_or_update_block_async(PydanticBlock(label="redo_highest", value="v1"), actor=default_user) await block_manager.checkpoint_block_async(b_init.id, actor=default_user) # 2) Another edit => seq=2 b_next = PydanticBlock(**b_init.model_dump()) b_next.value = "v2" await block_manager.create_or_update_block_async(b_next, actor=default_user) await block_manager.checkpoint_block_async(b_init.id, actor=default_user) # We are at seq=2, which is the highest checkpoint. # Attempt redo => there's no seq=3 with pytest.raises(LettaInvalidArgumentError): await block_manager.redo_checkpoint_block(b_init.id, actor=default_user) @pytest.mark.asyncio async def test_redo_after_multiple_undo(server: SyncServer, default_user): """ 1) Create and checkpoint versions: v1 -> seq=1, v2 -> seq=2, v3 -> seq=3, v4 -> seq=4 2) Undo thrice => from seq=4 to seq=1 3) Redo thrice => from seq=1 back to seq=4 """ block_manager = BlockManager() # Step 1: create initial block => seq=1 b_init = await block_manager.create_or_update_block_async(PydanticBlock(label="redo_multi", value="v1"), actor=default_user) await block_manager.checkpoint_block_async(b_init.id, actor=default_user) # seq=2 b_v2 = PydanticBlock(**b_init.model_dump()) b_v2.value = "v2" await block_manager.create_or_update_block_async(b_v2, actor=default_user) await block_manager.checkpoint_block_async(b_init.id, actor=default_user) # seq=3 b_v3 = PydanticBlock(**b_init.model_dump()) b_v3.value = "v3" await block_manager.create_or_update_block_async(b_v3, actor=default_user) await block_manager.checkpoint_block_async(b_init.id, actor=default_user) # seq=4 b_v4 = PydanticBlock(**b_init.model_dump()) b_v4.value = "v4" await block_manager.create_or_update_block_async(b_v4, actor=default_user) await block_manager.checkpoint_block_async(b_init.id, actor=default_user) # We have 4 checkpoints: v1...v4. Current is seq=4. # 2) Undo thrice => from seq=4 -> seq=1 for expected_value in ["v3", "v2", "v1"]: undone_block = await block_manager.undo_checkpoint_block(b_init.id, actor=default_user) assert undone_block.value == expected_value, f"Undo should get us back to {expected_value}" # 3) Redo thrice => from seq=1 -> seq=4 for expected_value in ["v2", "v3", "v4"]: redone_block = await block_manager.redo_checkpoint_block(b_init.id, actor=default_user) assert redone_block.value == expected_value, f"Redo should get us forward to {expected_value}" @pytest.mark.asyncio async def test_redo_concurrency_stale(server: SyncServer, default_user): block_manager = BlockManager() # 1) Create block => checkpoint => seq=1 block = await block_manager.create_or_update_block_async(PydanticBlock(label="redo_concurrency", value="v1"), actor=default_user) await block_manager.checkpoint_block_async(block.id, actor=default_user) # 2) Another edit => checkpoint => seq=2 block_v2 = PydanticBlock(**block.model_dump()) block_v2.value = "v2" await block_manager.create_or_update_block_async(block_v2, actor=default_user) await block_manager.checkpoint_block_async(block.id, actor=default_user) # 3) Another edit => checkpoint => seq=3 block_v3 = PydanticBlock(**block.model_dump()) block_v3.value = "v3" await block_manager.create_or_update_block_async(block_v3, actor=default_user) await block_manager.checkpoint_block_async(block.id, actor=default_user) # Now the block is at seq=3 in the DB # 4) Undo from seq=3 -> seq=2 so that we have a known future state at seq=3 undone_block = await block_manager.undo_checkpoint_block(block.id, actor=default_user) assert undone_block.value == "v2" # At this point the block is physically at seq=2 in DB, # but there's a valid row for seq=3 in block_history (the 'v3' state). # 5) Simulate concurrency: two sessions each read the block at seq=2 async with db_registry.async_session() as s1: block_s1 = await s1.get(Block, block.id) async with db_registry.async_session() as s2: block_s2 = await s2.get(Block, block.id) # 6) Session1 redoes to seq=3 first -> success await block_manager.redo_checkpoint_block(block_id=block.id, actor=default_user, use_preloaded_block=block_s1) # commits => block is now seq=3 in DB, version increments # 7) Session2 tries to do the same from stale version # => we expect StaleDataError, because the second session is using # an out-of-date version of the block with pytest.raises(StaleDataError): await block_manager.redo_checkpoint_block(block_id=block.id, actor=default_user, use_preloaded_block=block_s2) # ====================================================================================================================== # Block Tags Tests # ====================================================================================================================== @pytest.mark.asyncio async def test_block_tags_create_and_update(server: SyncServer, default_user): """Test creating a block with tags and updating tags""" block_manager = BlockManager() # Create a block with tags block = PydanticBlock( label="test_tags", value="Block with tags", tags=["tag1", "tag2", "important"], ) created_block = await block_manager.create_or_update_block_async(block, actor=default_user) # Verify tags were saved assert set(created_block.tags) == {"tag1", "tag2", "important"} # Update the block with new tags from letta.schemas.block import BlockUpdate updated_block = await block_manager.update_block_async( block_id=created_block.id, block_update=BlockUpdate(tags=["tag1", "new_tag"]), actor=default_user, ) # Verify tags were updated assert set(updated_block.tags) == {"tag1", "new_tag"} # Clear all tags cleared_block = await block_manager.update_block_async( block_id=created_block.id, block_update=BlockUpdate(tags=[]), actor=default_user, ) assert cleared_block.tags == [] @pytest.mark.asyncio async def test_block_tags_filter_any(server: SyncServer, default_user): """Test filtering blocks by tags (match ANY)""" block_manager = BlockManager() # Create blocks with different tags block1 = await block_manager.create_or_update_block_async( PydanticBlock(label="b1", value="v1", tags=["alpha", "beta"]), actor=default_user, ) block2 = await block_manager.create_or_update_block_async( PydanticBlock(label="b2", value="v2", tags=["beta", "gamma"]), actor=default_user, ) block3 = await block_manager.create_or_update_block_async( PydanticBlock(label="b3", value="v3", tags=["delta"]), actor=default_user, ) # Filter by tag "beta" (match ANY) results = await block_manager.get_blocks_async(actor=default_user, tags=["beta"], match_all_tags=False) result_ids = {b.id for b in results} assert block1.id in result_ids assert block2.id in result_ids assert block3.id not in result_ids # Filter by tag "alpha" or "delta" (match ANY) results = await block_manager.get_blocks_async(actor=default_user, tags=["alpha", "delta"], match_all_tags=False) result_ids = {b.id for b in results} assert block1.id in result_ids assert block2.id not in result_ids assert block3.id in result_ids @pytest.mark.asyncio async def test_block_tags_filter_all(server: SyncServer, default_user): """Test filtering blocks by tags (match ALL)""" block_manager = BlockManager() # Create blocks with different tags block1 = await block_manager.create_or_update_block_async( PydanticBlock(label="b1", value="v1", tags=["x", "y", "z"]), actor=default_user, ) block2 = await block_manager.create_or_update_block_async( PydanticBlock(label="b2", value="v2", tags=["x", "y"]), actor=default_user, ) block3 = await block_manager.create_or_update_block_async( PydanticBlock(label="b3", value="v3", tags=["x"]), actor=default_user, ) # Filter by tags "x" AND "y" (match ALL) results = await block_manager.get_blocks_async(actor=default_user, tags=["x", "y"], match_all_tags=True) result_ids = {b.id for b in results} assert block1.id in result_ids assert block2.id in result_ids assert block3.id not in result_ids # Filter by tags "x", "y", AND "z" (match ALL) results = await block_manager.get_blocks_async(actor=default_user, tags=["x", "y", "z"], match_all_tags=True) result_ids = {b.id for b in results} assert block1.id in result_ids assert block2.id not in result_ids assert block3.id not in result_ids @pytest.mark.asyncio async def test_block_tags_count(server: SyncServer, default_user): """Test counting blocks with tag filters""" block_manager = BlockManager() # Create blocks with different tags await block_manager.create_or_update_block_async( PydanticBlock(label="c1", value="v1", tags=["count_test", "a"]), actor=default_user, ) await block_manager.create_or_update_block_async( PydanticBlock(label="c2", value="v2", tags=["count_test", "b"]), actor=default_user, ) await block_manager.create_or_update_block_async( PydanticBlock(label="c3", value="v3", tags=["other"]), actor=default_user, ) # Count blocks with tag "count_test" count = await block_manager.count_blocks_async(actor=default_user, tags=["count_test"], match_all_tags=False) assert count == 2 # Count blocks with tags "count_test" AND "a" count = await block_manager.count_blocks_async(actor=default_user, tags=["count_test", "a"], match_all_tags=True) assert count == 1