diff --git a/fern/openapi.json b/fern/openapi.json index fdf98425..98f1b815 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -8922,6 +8922,52 @@ } } } + }, + "delete": { + "tags": ["conversations"], + "summary": "Delete Conversation", + "description": "Delete a conversation (soft delete).\n\nThis marks the conversation as deleted but does not permanently remove it from the database.\nThe conversation will no longer appear in list operations.\nAny isolated blocks associated with the conversation will be permanently deleted.", + "operationId": "delete_conversation", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 41, + "pattern": "^(default|conv-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$", + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'", + "examples": [ + "default", + "conv-123e4567-e89b-42d3-8456-426614174000" + ], + "title": "Conversation Id" + }, + "description": "The conversation identifier. Either the special value 'default' or an ID in the format 'conv-'" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } } }, "/v1/conversations/{conversation_id}/messages": { diff --git a/letta/server/rest_api/routers/v1/conversations.py b/letta/server/rest_api/routers/v1/conversations.py index fef31b06..d52f54bd 100644 --- a/letta/server/rest_api/routers/v1/conversations.py +++ b/letta/server/rest_api/routers/v1/conversations.py @@ -60,7 +60,9 @@ async def create_conversation( @router.get("/", response_model=List[Conversation], operation_id="list_conversations") async def list_conversations( - agent_id: Optional[str] = Query(None, description="The agent ID to list conversations for (optional - returns all conversations if not provided)"), + agent_id: Optional[str] = Query( + None, description="The agent ID to list conversations for (optional - returns all conversations if not provided)" + ), limit: int = Query(50, description="Maximum number of conversations to return"), after: Optional[str] = Query(None, description="Cursor for pagination (conversation ID)"), summary_search: Optional[str] = Query(None, description="Search for text within conversation summaries"), @@ -108,6 +110,26 @@ async def update_conversation( ) +@router.delete("/{conversation_id}", response_model=None, operation_id="delete_conversation") +async def delete_conversation( + conversation_id: ConversationId, + server: SyncServer = Depends(get_letta_server), + headers: HeaderParams = Depends(get_headers), +): + """ + Delete a conversation (soft delete). + + This marks the conversation as deleted but does not permanently remove it from the database. + The conversation will no longer appear in list operations. + Any isolated blocks associated with the conversation will be permanently deleted. + """ + actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id) + await conversation_manager.delete_conversation( + conversation_id=conversation_id, + actor=actor, + ) + + ConversationMessagesResponse = Annotated[ List[LettaMessageUnion], Field(json_schema_extra={"type": "array", "items": {"$ref": "#/components/schemas/LettaMessageUnion"}}) ] diff --git a/letta/services/conversation_manager.py b/letta/services/conversation_manager.py index 7d374092..41111623 100644 --- a/letta/services/conversation_manager.py +++ b/letta/services/conversation_manager.py @@ -129,20 +129,16 @@ class ConversationManager: # Build where conditions conditions = [ ConversationModel.organization_id == actor.organization_id, + ConversationModel.is_deleted == False, ConversationModel.summary.isnot(None), ConversationModel.summary.contains(summary_search), ] - + # Add agent_id filter if provided if agent_id is not None: conditions.append(ConversationModel.agent_id == agent_id) - stmt = ( - select(ConversationModel) - .where(and_(*conditions)) - .order_by(ConversationModel.created_at.desc()) - .limit(limit) - ) + stmt = select(ConversationModel).where(and_(*conditions)).order_by(ConversationModel.created_at.desc()).limit(limit) if after: # Add cursor filtering @@ -162,6 +158,7 @@ class ConversationManager: db_session=session, actor=actor, agent_id=agent_id, + is_deleted=False, limit=limit, after=after, ascending=False, @@ -182,6 +179,7 @@ class ConversationManager: db_session=session, identifier=conversation_id, actor=actor, + check_is_deleted=True, ) # Set attributes on the model @@ -209,6 +207,7 @@ class ConversationManager: db_session=session, identifier=conversation_id, actor=actor, + check_is_deleted=True, ) # Get isolated blocks before modifying conversation diff --git a/tests/integration_test_conversations_sdk.py b/tests/integration_test_conversations_sdk.py index 8ba154b4..129e0ecf 100644 --- a/tests/integration_test_conversations_sdk.py +++ b/tests/integration_test_conversations_sdk.py @@ -567,6 +567,94 @@ class TestConversationsSDK: assert first_message_id not in [m.id for m in messages_after] +class TestConversationDelete: + """Tests for the conversation delete endpoint.""" + + def test_delete_conversation(self, client: Letta, agent, server_url: str): + """Test soft deleting a conversation.""" + # Create a conversation + conversation = client.conversations.create(agent_id=agent.id) + assert conversation.id is not None + + # Delete it via REST endpoint + response = requests.delete( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}" + + # Verify it's no longer accessible + response = requests.get( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 404, f"Expected 404 for deleted conversation, got {response.status_code}" + + def test_delete_conversation_removes_from_list(self, client: Letta, agent, server_url: str): + """Test that deleted conversations don't appear in list.""" + # Create two conversations + conv1 = client.conversations.create(agent_id=agent.id) + conv2 = client.conversations.create(agent_id=agent.id) + + # Verify both appear in list + conversations = client.conversations.list(agent_id=agent.id) + conv_ids = [c.id for c in conversations] + assert conv1.id in conv_ids + assert conv2.id in conv_ids + + # Delete one + response = requests.delete( + f"{server_url}/v1/conversations/{conv1.id}", + ) + assert response.status_code == 200 + + # Verify only the non-deleted one appears in list + conversations = client.conversations.list(agent_id=agent.id) + conv_ids = [c.id for c in conversations] + assert conv1.id not in conv_ids, "Deleted conversation should not appear in list" + assert conv2.id in conv_ids, "Non-deleted conversation should still appear" + + def test_delete_conversation_not_found(self, client: Letta, agent, server_url: str): + """Test that deleting a non-existent conversation returns 404.""" + fake_id = "conv-00000000-0000-0000-0000-000000000000" + response = requests.delete( + f"{server_url}/v1/conversations/{fake_id}", + ) + assert response.status_code == 404 + + def test_delete_conversation_double_delete(self, client: Letta, agent, server_url: str): + """Test that deleting an already-deleted conversation returns 404.""" + # Create and delete a conversation + conversation = client.conversations.create(agent_id=agent.id) + + # First delete should succeed + response = requests.delete( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 200 + + # Second delete should return 404 + response = requests.delete( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 404, "Double delete should return 404" + + def test_update_deleted_conversation_fails(self, client: Letta, agent, server_url: str): + """Test that updating a deleted conversation returns 404.""" + # Create and delete a conversation + conversation = client.conversations.create(agent_id=agent.id) + + response = requests.delete( + f"{server_url}/v1/conversations/{conversation.id}", + ) + assert response.status_code == 200 + + # Try to update the deleted conversation + response = requests.patch( + f"{server_url}/v1/conversations/{conversation.id}", + json={"summary": "Updated summary"}, + ) + assert response.status_code == 404, "Updating deleted conversation should return 404" + + class TestConversationCompact: """Tests for the conversation compact (summarization) endpoint.""" diff --git a/tests/managers/test_conversation_manager.py b/tests/managers/test_conversation_manager.py index 03329a9a..acb807f8 100644 --- a/tests/managers/test_conversation_manager.py +++ b/tests/managers/test_conversation_manager.py @@ -166,6 +166,110 @@ async def test_delete_conversation(conversation_manager, server: SyncServer, sar ) +@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_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_conversation_isolation_by_agent(conversation_manager, server: SyncServer, sarah_agent, charles_agent, default_user): """Test that conversations are isolated by agent."""