diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py index 24a54ef..b27952f 100644 --- a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py @@ -1,6 +1,9 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. from .environment_utils import get_observability_authentication_scope +from .operation_error import OperationError +from .operation_result import OperationResult from .power_platform_api_discovery import ClusterCategory, PowerPlatformApiDiscovery from .utility import Utility @@ -9,6 +12,8 @@ "PowerPlatformApiDiscovery", "ClusterCategory", "Utility", + "OperationError", + "OperationResult", ] __path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_error.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_error.py new file mode 100644 index 0000000..a0581dd --- /dev/null +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_error.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Encapsulates an error from an operation. +""" + + +class OperationError: + """ + Represents an error that occurred during an operation. + + This class wraps an exception and provides a consistent interface for + accessing error information. + """ + + def __init__(self, exception: Exception): + """ + Initialize a new instance of the OperationError class. + + Args: + exception: The exception associated with the error. + + Raises: + ValueError: If exception is None. + """ + if exception is None: + raise ValueError("exception cannot be None") + self._exception = exception + + @property + def exception(self) -> Exception: + """ + Get the exception associated with the error. + + Returns: + Exception: The exception associated with the error. + """ + return self._exception + + @property + def message(self) -> str: + """ + Get the message associated with the error. + + Returns: + str: The error message from the exception. + """ + return str(self._exception) + + def __str__(self) -> str: + """ + Return a string representation of the error. + + Returns: + str: A string representation of the error. + """ + return str(self._exception) diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_result.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_result.py new file mode 100644 index 0000000..e51e8c1 --- /dev/null +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_result.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Represents the result of an operation. +""" + +from typing import List, Optional + +from .operation_error import OperationError + + +class OperationResult: + """ + Represents the result of an operation. + + This class encapsulates the success or failure state of an operation along + with any associated errors. + """ + + _success_instance: Optional["OperationResult"] = None + + def __init__(self, succeeded: bool, errors: Optional[List[OperationError]] = None): + """ + Initialize a new instance of the OperationResult class. + + Args: + succeeded: Flag indicating whether the operation succeeded. + errors: Optional list of errors that occurred during the operation. + """ + self._succeeded = succeeded + self._errors = errors if errors is not None else [] + + @property + def succeeded(self) -> bool: + """ + Get a flag indicating whether the operation succeeded. + + Returns: + bool: True if the operation succeeded, otherwise False. + """ + return self._succeeded + + @property + def errors(self) -> List[OperationError]: + """ + Get the list of errors that occurred during the operation. + + Note: + This property returns a defensive copy of the internal error list + to prevent external modifications, which is especially important for + protecting the singleton instance returned by success(). + + Returns: + List[OperationError]: A copy of the list of operation errors. + """ + return list(self._errors) + + @staticmethod + def success() -> "OperationResult": + """ + Return an OperationResult indicating a successful operation. + + Returns: + OperationResult: An OperationResult indicating a successful operation. + """ + return OperationResult._success_instance + + @staticmethod + def failed(*errors: OperationError) -> "OperationResult": + """ + Create an OperationResult indicating a failed operation. + + Args: + *errors: Variable number of OperationError instances. + + Returns: + OperationResult: An OperationResult indicating a failed operation. + """ + error_list = list(errors) if errors else [] + return OperationResult(succeeded=False, errors=error_list) + + def __str__(self) -> str: + """ + Convert the value of the current OperationResult object to its string representation. + + Returns: + str: A string representation of the current OperationResult object. + """ + if self._succeeded: + return "Succeeded" + else: + error_messages = ", ".join(str(error.message) for error in self._errors) + return f"Failed: {error_messages}" if error_messages else "Failed" + + +# Module-level eager initialization (thread-safe by Python's import lock) +OperationResult._success_instance = OperationResult(succeeded=True) diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/__init__.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/__init__.py index 2378596..15e502e 100644 --- a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/__init__.py +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Common models for MCP tooling. @@ -6,7 +7,9 @@ This module defines data models used across the MCP tooling framework. """ +from .chat_history_message import ChatHistoryMessage +from .chat_message_request import ChatMessageRequest from .mcp_server_config import MCPServerConfig from .tool_options import ToolOptions -__all__ = ["MCPServerConfig", "ToolOptions"] +__all__ = ["MCPServerConfig", "ToolOptions", "ChatHistoryMessage", "ChatMessageRequest"] diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_history_message.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_history_message.py new file mode 100644 index 0000000..0520186 --- /dev/null +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_history_message.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Chat History Message model. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict + + +@dataclass +class ChatHistoryMessage: + """ + Represents a single message in the chat history. + + This class is used to send chat history to the MCP platform for real-time + threat protection analysis. + """ + + #: The unique identifier for the chat message. + id: str + + #: The role of the message sender (e.g., "user", "assistant", "system"). + role: str + + #: The content of the chat message. + content: str + + #: The timestamp of when the message was sent. + timestamp: datetime + + def __post_init__(self): + """ + Validate the message after initialization. + + Ensures that all required fields are present and non-empty. + + Raises: + ValueError: If id, role, or content is empty or whitespace-only, + or if timestamp is None. + """ + if not self.id or not self.id.strip(): + raise ValueError("id cannot be empty or whitespace-only") + if not self.role or not self.role.strip(): + raise ValueError("role cannot be empty or whitespace-only") + if not self.content or not self.content.strip(): + raise ValueError("content cannot be empty or whitespace-only") + if self.timestamp is None: + raise ValueError("timestamp cannot be None") + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the message to a dictionary for JSON serialization. + + Returns: + Dict[str, Any]: Dictionary representation of the message. + """ + return { + "id": self.id, + "role": self.role, + "content": self.content, + "timestamp": self.timestamp.isoformat(), + } diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_message_request.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_message_request.py new file mode 100644 index 0000000..1df8ebd --- /dev/null +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_message_request.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Chat Message Request model. +""" + +from dataclasses import dataclass +from typing import Any, Dict, List + +from .chat_history_message import ChatHistoryMessage + + +@dataclass +class ChatMessageRequest: + """ + Represents the request payload for a real-time threat protection check on a chat message. + + This class encapsulates the information needed to send chat history to the MCP platform + for threat analysis. + """ + + #: The unique identifier for the conversation. + conversation_id: str + + #: The unique identifier for the message within the conversation. + message_id: str + + #: The content of the user's message. + user_message: str + + #: The chat history messages. + chat_history: List[ChatHistoryMessage] + + def __post_init__(self): + """ + Validate the request after initialization. + + Ensures that all required fields are present and non-empty. + + Raises: + ValueError: If conversation_id, message_id, or user_message is empty + or whitespace-only, or if chat_history is None or empty. + """ + if not self.conversation_id or not self.conversation_id.strip(): + raise ValueError("conversation_id cannot be empty") + if not self.message_id or not self.message_id.strip(): + raise ValueError("message_id cannot be empty") + if not self.user_message or not self.user_message.strip(): + raise ValueError("user_message cannot be empty") + if self.chat_history is None or len(self.chat_history) == 0: + raise ValueError("chat_history cannot be empty") + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the request to a dictionary for JSON serialization. + + Returns: + Dict[str, Any]: Dictionary representation of the request. + """ + return { + "conversationId": self.conversation_id, + "messageId": self.message_id, + "userMessage": self.user_message, + "chatHistory": [msg.to_dict() for msg in self.chat_history], + } diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/mcp_tool_server_configuration_service.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/mcp_tool_server_configuration_service.py index aec53f2..056c0a0 100644 --- a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/mcp_tool_server_configuration_service.py +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/mcp_tool_server_configuration_service.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ MCP Tool Server Configuration Service. @@ -17,25 +18,44 @@ # ============================================================================== # Standard library imports +import asyncio import json import logging import os import sys from pathlib import Path from typing import Any, Dict, List, Optional +from urllib.parse import urlparse # Third-party imports import aiohttp +from microsoft_agents.hosting.core import TurnContext # Local imports -from ..models import MCPServerConfig, ToolOptions +from ..models import ChatHistoryMessage, ChatMessageRequest, MCPServerConfig, ToolOptions from ..utils import Constants -from ..utils.utility import get_tooling_gateway_for_digital_worker, build_mcp_server_url +from ..utils.utility import ( + get_tooling_gateway_for_digital_worker, + build_mcp_server_url, + get_chat_history_endpoint, +) # Runtime Imports +from microsoft_agents_a365.runtime import OperationError, OperationResult from microsoft_agents_a365.runtime.utility import Utility as RuntimeUtility +# ============================================================================== +# CONSTANTS +# ============================================================================== + +# HTTP timeout in seconds for request operations +DEFAULT_REQUEST_TIMEOUT_SECONDS = 30 + +# HTTP status code for successful response +HTTP_STATUS_OK = 200 + + # ============================================================================== # MAIN SERVICE CLASS # ============================================================================== @@ -492,3 +512,145 @@ def _validate_server_strings(self, name: Optional[str], unique_name: Optional[st True if both strings are valid, False otherwise. """ return name is not None and name.strip() and unique_name is not None and unique_name.strip() + + # -------------------------------------------------------------------------- + # SEND CHAT HISTORY + # -------------------------------------------------------------------------- + + async def send_chat_history( + self, + turn_context: TurnContext, + chat_history_messages: List[ChatHistoryMessage], + options: Optional[ToolOptions] = None, + ) -> OperationResult: + """ + Sends chat history to the MCP platform for real-time threat protection. + + Args: + turn_context: TurnContext from the Agents SDK containing conversation information. + Must have a valid activity with conversation.id, activity.id, and + activity.text. + chat_history_messages: List of ChatHistoryMessage objects representing the chat + history. Must be non-empty. + options: Optional ToolOptions instance containing optional parameters. + + Returns: + OperationResult: An OperationResult indicating success or failure. + On success, returns OperationResult.success(). + On failure, returns OperationResult.failed() with error details. + + Raises: + ValueError: If turn_context is None, chat_history_messages is None or empty, + turn_context.activity is None, or any of the required fields + (conversation.id, activity.id, activity.text) are missing or empty. + + Example: + >>> from datetime import datetime, timezone + >>> from microsoft_agents_a365.tooling.models import ChatHistoryMessage + >>> + >>> history = [ + ... ChatHistoryMessage("msg-1", "user", "Hello", datetime.now(timezone.utc)), + ... ChatHistoryMessage("msg-2", "assistant", "Hi!", datetime.now(timezone.utc)) + ... ] + >>> + >>> service = McpToolServerConfigurationService() + >>> result = await service.send_chat_history(turn_context, history) + >>> if result.succeeded: + ... print("Chat history sent successfully") + """ + # Validate input parameters + if turn_context is None: + raise ValueError("turn_context cannot be None") + if chat_history_messages is None or len(chat_history_messages) == 0: + raise ValueError("chat_history_messages cannot be None or empty") + + # Extract required information from turn context + if not turn_context.activity: + raise ValueError("turn_context.activity cannot be None") + + conversation_id: Optional[str] = ( + turn_context.activity.conversation.id if turn_context.activity.conversation else None + ) + message_id: Optional[str] = turn_context.activity.id + user_message: Optional[str] = turn_context.activity.text + + if conversation_id is None or ( + isinstance(conversation_id, str) and not conversation_id.strip() + ): + raise ValueError( + "conversation_id cannot be empty or None (from turn_context.activity.conversation.id)" + ) + if message_id is None or (isinstance(message_id, str) and not message_id.strip()): + raise ValueError("message_id cannot be empty or None (from turn_context.activity.id)") + if user_message is None or (isinstance(user_message, str) and not user_message.strip()): + raise ValueError( + "user_message cannot be empty or None (from turn_context.activity.text)" + ) + + # Use default options if none provided + if options is None: + options = ToolOptions(orchestrator_name=None) + + # Get the endpoint URL + endpoint = get_chat_history_endpoint() + + # Log only the URL path to avoid accidentally exposing sensitive data in query strings + parsed_url = urlparse(endpoint) + self._logger.debug(f"Sending chat history to endpoint path: {parsed_url.path}") + + # Create the request payload + request = ChatMessageRequest( + conversation_id=conversation_id, + message_id=message_id, + user_message=user_message, + chat_history=chat_history_messages, + ) + + try: + # Prepare headers (no authentication required) + headers = { + Constants.Headers.USER_AGENT: RuntimeUtility.get_user_agent_header( + options.orchestrator_name + ), + "Content-Type": "application/json", + } + + # Convert request to JSON + json_data = json.dumps(request.to_dict()) + + # Send POST request with timeout to prevent indefinite hangs + timeout = aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(endpoint, headers=headers, data=json_data) as response: + if response.status == HTTP_STATUS_OK: + self._logger.info("Successfully sent chat history to MCP platform") + return OperationResult.success() + else: + error_text = await response.text() + self._logger.error( + f"HTTP error sending chat history: HTTP {response.status}. " + f"Response: {error_text[:500]}" + ) + # Use ClientResponseError for consistent error handling + http_error = aiohttp.ClientResponseError( + request_info=response.request_info, + history=response.history, + status=response.status, + message=error_text, + headers=response.headers, + ) + return OperationResult.failed(OperationError(http_error)) + + except asyncio.TimeoutError as timeout_ex: + # Catch TimeoutError before ClientError since aiohttp.ServerTimeoutError + # inherits from both asyncio.TimeoutError and aiohttp.ClientError + self._logger.error( + f"Request timeout sending chat history to '{endpoint}': {str(timeout_ex)}" + ) + return OperationResult.failed(OperationError(timeout_ex)) + except aiohttp.ClientError as http_ex: + self._logger.error(f"HTTP error sending chat history to '{endpoint}': {str(http_ex)}") + return OperationResult.failed(OperationError(http_ex)) + except Exception as ex: + self._logger.error(f"Failed to send chat history to '{endpoint}': {str(ex)}") + return OperationResult.failed(OperationError(ex)) diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/utils/utility.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/utils/utility.py index d5666f5..28e8f19 100644 --- a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/utils/utility.py +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/utils/utility.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Provides utility functions for the Tooling components. @@ -10,6 +11,9 @@ # Constants for base URLs MCP_PLATFORM_PROD_BASE_URL = "https://agent365.svc.cloud.microsoft" +# API endpoint paths +CHAT_HISTORY_ENDPOINT_PATH = "/agents/real-time-threat-protection/chat-message" + PPAPI_TOKEN_SCOPE = "https://api.powerplatform.com" PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default" @@ -89,3 +93,13 @@ def get_mcp_platform_authentication_scope(): return [envScope] return [PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE] + + +def get_chat_history_endpoint() -> str: + """ + Gets the chat history endpoint URL for sending chat history to the MCP platform. + + Returns: + str: The chat history endpoint URL. + """ + return f"{_get_mcp_platform_base_url()}{CHAT_HISTORY_ENDPOINT_PATH}" diff --git a/libraries/microsoft-agents-a365-tooling/pyproject.toml b/libraries/microsoft-agents-a365-tooling/pyproject.toml index 5c67a62..be140e0 100644 --- a/libraries/microsoft-agents-a365-tooling/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling/pyproject.toml @@ -25,6 +25,7 @@ license = {text = "MIT"} dependencies = [ "pydantic >= 2.0.0", "typing-extensions >= 4.0.0", + "microsoft-agents-hosting-core >= 0.4.0, < 0.6.0", ] [project.urls] diff --git a/tests/runtime/test_operation_error.py b/tests/runtime/test_operation_error.py new file mode 100644 index 0000000..af02c9a --- /dev/null +++ b/tests/runtime/test_operation_error.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for OperationError class.""" + +import pytest +from microsoft_agents_a365.runtime import OperationError + + +class TestOperationError: + """Tests for OperationError class.""" + + def test_operation_error_can_be_instantiated(self): + """Test that OperationError can be instantiated with an exception.""" + # Arrange + exception = Exception("Test error") + + # Act + error = OperationError(exception) + + # Assert + assert error is not None + assert error.exception == exception + assert error.message == "Test error" + + def test_operation_error_requires_exception(self): + """Test that OperationError requires an exception.""" + # Act & Assert + with pytest.raises(ValueError, match="exception cannot be None"): + OperationError(None) + + def test_operation_error_string_representation(self): + """Test that OperationError has correct string representation.""" + # Arrange + exception = Exception("Test error message") + error = OperationError(exception) + + # Act + result = str(error) + + # Assert + assert "Test error message" in result + + def test_operation_error_with_different_exception_types(self): + """Test that OperationError works with different exception types.""" + # Arrange & Act + value_error = OperationError(ValueError("Invalid value")) + type_error = OperationError(TypeError("Invalid type")) + runtime_error = OperationError(RuntimeError("Runtime issue")) + + # Assert + assert value_error.message == "Invalid value" + assert type_error.message == "Invalid type" + assert runtime_error.message == "Runtime issue" diff --git a/tests/runtime/test_operation_result.py b/tests/runtime/test_operation_result.py new file mode 100644 index 0000000..61edbed --- /dev/null +++ b/tests/runtime/test_operation_result.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for OperationResult class.""" + +from microsoft_agents_a365.runtime import OperationError, OperationResult + + +class TestOperationResult: + """Tests for OperationResult class.""" + + def test_operation_result_success(self): + """Test that OperationResult.success() returns a successful result.""" + # Act + result = OperationResult.success() + + # Assert + assert result is not None + assert result.succeeded is True + assert len(result.errors) == 0 + + def test_operation_result_success_returns_singleton(self): + """Test that OperationResult.success() returns the same instance.""" + # Act + result1 = OperationResult.success() + result2 = OperationResult.success() + + # Assert + assert result1 is result2 + + def test_operation_result_failed_with_no_errors(self): + """Test that OperationResult.failed() without errors returns a failed result.""" + # Act + result = OperationResult.failed() + + # Assert + assert result is not None + assert result.succeeded is False + assert len(result.errors) == 0 + + def test_operation_result_failed_with_single_error(self): + """Test that OperationResult.failed() with a single error works correctly.""" + # Arrange + exception = Exception("Test error") + error = OperationError(exception) + + # Act + result = OperationResult.failed(error) + + # Assert + assert result is not None + assert result.succeeded is False + assert len(result.errors) == 1 + assert result.errors[0] == error + + def test_operation_result_failed_with_multiple_errors(self): + """Test that OperationResult.failed() with multiple errors works correctly.""" + # Arrange + error1 = OperationError(Exception("Error 1")) + error2 = OperationError(Exception("Error 2")) + error3 = OperationError(Exception("Error 3")) + + # Act + result = OperationResult.failed(error1, error2, error3) + + # Assert + assert result is not None + assert result.succeeded is False + assert len(result.errors) == 3 + assert result.errors[0] == error1 + assert result.errors[1] == error2 + assert result.errors[2] == error3 + + def test_operation_result_success_string_representation(self): + """Test that successful OperationResult has correct string representation.""" + # Act + result = OperationResult.success() + + # Assert + assert str(result) == "Succeeded" + + def test_operation_result_failed_string_representation_no_errors(self): + """Test that failed OperationResult without errors has correct string representation.""" + # Act + result = OperationResult.failed() + + # Assert + assert str(result) == "Failed" + + def test_operation_result_failed_string_representation_with_errors(self): + """Test that failed OperationResult with errors has correct string representation.""" + # Arrange + error1 = OperationError(Exception("Error 1")) + error2 = OperationError(Exception("Error 2")) + + # Act + result = OperationResult.failed(error1, error2) + + # Assert + result_str = str(result) + assert "Failed" in result_str + assert "Error 1" in result_str + assert "Error 2" in result_str diff --git a/tests/tooling/__init__.py b/tests/tooling/__init__.py new file mode 100644 index 0000000..59e481e --- /dev/null +++ b/tests/tooling/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/tooling/models/__init__.py b/tests/tooling/models/__init__.py new file mode 100644 index 0000000..59e481e --- /dev/null +++ b/tests/tooling/models/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/tooling/models/test_chat_history_message.py b/tests/tooling/models/test_chat_history_message.py new file mode 100644 index 0000000..6a71a2a --- /dev/null +++ b/tests/tooling/models/test_chat_history_message.py @@ -0,0 +1,141 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for ChatHistoryMessage class.""" + +from datetime import datetime, timezone + +import pytest +from microsoft_agents_a365.tooling.models import ChatHistoryMessage + + +class TestChatHistoryMessage: + """Tests for ChatHistoryMessage class.""" + + def test_chat_history_message_can_be_instantiated(self): + """Test that ChatHistoryMessage can be instantiated with valid parameters.""" + # Arrange & Act + timestamp = datetime.now(timezone.utc) + message = ChatHistoryMessage("msg-123", "user", "Hello, world!", timestamp) + + # Assert + assert message is not None + assert message.id == "msg-123" + assert message.role == "user" + assert message.content == "Hello, world!" + assert message.timestamp == timestamp + + def test_chat_history_message_to_dict(self): + """Test that ChatHistoryMessage converts to dictionary correctly.""" + # Arrange + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + message = ChatHistoryMessage("msg-456", "assistant", "How can I help you?", timestamp) + + # Act + result = message.to_dict() + + # Assert + assert result["id"] == "msg-456" + assert result["role"] == "assistant" + assert result["content"] == "How can I help you?" + assert result["timestamp"] == "2024-01-15T10:30:00+00:00" + + def test_chat_history_message_requires_non_empty_id(self): + """Test that ChatHistoryMessage requires a non-empty id.""" + # Arrange + timestamp = datetime.now(timezone.utc) + + # Act & Assert + with pytest.raises(ValueError, match="id cannot be empty"): + ChatHistoryMessage("", "user", "Test content", timestamp) + + def test_chat_history_message_requires_non_empty_role(self): + """Test that ChatHistoryMessage requires a non-empty role.""" + # Arrange + timestamp = datetime.now(timezone.utc) + + # Act & Assert + with pytest.raises(ValueError, match="role cannot be empty"): + ChatHistoryMessage("msg-001", "", "Test content", timestamp) + + def test_chat_history_message_requires_non_empty_content(self): + """Test that ChatHistoryMessage requires a non-empty content.""" + # Arrange + timestamp = datetime.now(timezone.utc) + + # Act & Assert + with pytest.raises(ValueError, match="content cannot be empty"): + ChatHistoryMessage("msg-001", "user", "", timestamp) + + def test_chat_history_message_requires_timestamp(self): + """Test that ChatHistoryMessage requires a timestamp.""" + # Act & Assert + with pytest.raises(ValueError, match="timestamp cannot be None"): + ChatHistoryMessage("msg-001", "user", "Test content", None) + + def test_chat_history_message_supports_system_role(self): + """Test that ChatHistoryMessage supports system role.""" + # Arrange & Act + timestamp = datetime.now(timezone.utc) + message = ChatHistoryMessage("sys-001", "system", "You are a helpful assistant.", timestamp) + + # Assert + assert message.role == "system" + + def test_chat_history_message_preserves_timestamp_precision(self): + """Test that ChatHistoryMessage preserves timestamp precision.""" + # Arrange + timestamp = datetime(2024, 1, 15, 10, 30, 45, 123000, tzinfo=timezone.utc) + message = ChatHistoryMessage("msg-001", "user", "Test", timestamp) + + # Act + message_dict = message.to_dict() + + # Assert + assert message.timestamp == timestamp + assert "2024-01-15T10:30:45.123000" in message_dict["timestamp"] + + def test_chat_history_message_rejects_whitespace_only_id(self): + """Test that ChatHistoryMessage rejects whitespace-only id.""" + # Arrange + timestamp = datetime.now(timezone.utc) + + # Act & Assert + with pytest.raises(ValueError, match="id cannot be empty"): + ChatHistoryMessage(" ", "user", "Content", timestamp) + + def test_chat_history_message_rejects_whitespace_only_role(self): + """Test that ChatHistoryMessage rejects whitespace-only role.""" + # Arrange + timestamp = datetime.now(timezone.utc) + + # Act & Assert + with pytest.raises(ValueError, match="role cannot be empty"): + ChatHistoryMessage("msg-1", " ", "Content", timestamp) + + def test_chat_history_message_rejects_whitespace_only_content(self): + """Test that ChatHistoryMessage rejects whitespace-only content.""" + # Arrange + timestamp = datetime.now(timezone.utc) + + # Act & Assert + with pytest.raises(ValueError, match="content cannot be empty"): + ChatHistoryMessage("msg-1", "user", " ", timestamp) + + def test_chat_history_message_rejects_tab_only_id(self): + """Test that ChatHistoryMessage rejects tab-only id.""" + # Arrange + timestamp = datetime.now(timezone.utc) + + # Act & Assert + with pytest.raises(ValueError, match="id cannot be empty"): + ChatHistoryMessage("\t", "user", "Content", timestamp) + + def test_chat_history_message_rejects_newline_only_content(self): + """Test that ChatHistoryMessage rejects newline-only content.""" + # Arrange + timestamp = datetime.now(timezone.utc) + + # Act & Assert + with pytest.raises(ValueError, match="content cannot be empty"): + ChatHistoryMessage("msg-1", "user", "\n\n", timestamp) diff --git a/tests/tooling/models/test_chat_message_request.py b/tests/tooling/models/test_chat_message_request.py new file mode 100644 index 0000000..6b0e7cf --- /dev/null +++ b/tests/tooling/models/test_chat_message_request.py @@ -0,0 +1,151 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for ChatMessageRequest class.""" + +from datetime import datetime, timezone + +import pytest +from microsoft_agents_a365.tooling.models import ChatHistoryMessage, ChatMessageRequest + + +class TestChatMessageRequest: + """Tests for ChatMessageRequest class.""" + + def test_chat_message_request_can_be_instantiated(self): + """Test that ChatMessageRequest can be instantiated with valid parameters.""" + # Arrange + timestamp = datetime.now(timezone.utc) + message1 = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + message2 = ChatHistoryMessage("msg-2", "assistant", "Hi there!", timestamp) + chat_history = [message1, message2] + + # Act + request = ChatMessageRequest("conv-123", "msg-456", "How are you?", chat_history) + + # Assert + assert request is not None + assert request.conversation_id == "conv-123" + assert request.message_id == "msg-456" + assert request.user_message == "How are you?" + assert request.chat_history == chat_history + + def test_chat_message_request_to_dict(self): + """Test that ChatMessageRequest converts to dictionary correctly.""" + # Arrange + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + message = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + request = ChatMessageRequest("conv-123", "msg-456", "How are you?", [message]) + + # Act + result = request.to_dict() + + # Assert + assert result["conversationId"] == "conv-123" + assert result["messageId"] == "msg-456" + assert result["userMessage"] == "How are you?" + assert len(result["chatHistory"]) == 1 + assert result["chatHistory"][0]["id"] == "msg-1" + assert result["chatHistory"][0]["role"] == "user" + assert result["chatHistory"][0]["content"] == "Hello" + + def test_chat_message_request_requires_non_empty_conversation_id(self): + """Test that ChatMessageRequest requires a non-empty conversation_id.""" + # Arrange + timestamp = datetime.now(timezone.utc) + message = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + + # Act & Assert + with pytest.raises(ValueError, match="conversation_id cannot be empty"): + ChatMessageRequest("", "msg-456", "How are you?", [message]) + + def test_chat_message_request_requires_non_empty_message_id(self): + """Test that ChatMessageRequest requires a non-empty message_id.""" + # Arrange + timestamp = datetime.now(timezone.utc) + message = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + + # Act & Assert + with pytest.raises(ValueError, match="message_id cannot be empty"): + ChatMessageRequest("conv-123", "", "How are you?", [message]) + + def test_chat_message_request_requires_non_empty_user_message(self): + """Test that ChatMessageRequest requires a non-empty user_message.""" + # Arrange + timestamp = datetime.now(timezone.utc) + message = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + + # Act & Assert + with pytest.raises(ValueError, match="user_message cannot be empty"): + ChatMessageRequest("conv-123", "msg-456", "", [message]) + + def test_chat_message_request_requires_non_empty_chat_history(self): + """Test that ChatMessageRequest requires a non-empty chat_history.""" + # Act & Assert + with pytest.raises(ValueError, match="chat_history cannot be empty"): + ChatMessageRequest("conv-123", "msg-456", "How are you?", []) + + def test_chat_message_request_with_multiple_messages(self): + """Test that ChatMessageRequest handles multiple messages correctly.""" + # Arrange + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + message1 = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + message2 = ChatHistoryMessage("msg-2", "assistant", "Hi!", timestamp) + message3 = ChatHistoryMessage("msg-3", "user", "How are you?", timestamp) + chat_history = [message1, message2, message3] + + # Act + request = ChatMessageRequest("conv-123", "msg-456", "What can you do?", chat_history) + result = request.to_dict() + + # Assert + assert len(result["chatHistory"]) == 3 + assert result["chatHistory"][0]["id"] == "msg-1" + assert result["chatHistory"][1]["id"] == "msg-2" + assert result["chatHistory"][2]["id"] == "msg-3" + + def test_chat_message_request_rejects_whitespace_only_conversation_id(self): + """Test that ChatMessageRequest rejects whitespace-only conversation_id.""" + # Arrange + timestamp = datetime.now(timezone.utc) + message = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + + # Act & Assert + with pytest.raises(ValueError, match="conversation_id cannot be empty"): + ChatMessageRequest(" ", "msg-456", "How are you?", [message]) + + def test_chat_message_request_rejects_whitespace_only_message_id(self): + """Test that ChatMessageRequest rejects whitespace-only message_id.""" + # Arrange + timestamp = datetime.now(timezone.utc) + message = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + + # Act & Assert + with pytest.raises(ValueError, match="message_id cannot be empty"): + ChatMessageRequest("conv-123", " ", "How are you?", [message]) + + def test_chat_message_request_rejects_whitespace_only_user_message(self): + """Test that ChatMessageRequest rejects whitespace-only user_message.""" + # Arrange + timestamp = datetime.now(timezone.utc) + message = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + + # Act & Assert + with pytest.raises(ValueError, match="user_message cannot be empty"): + ChatMessageRequest("conv-123", "msg-456", " ", [message]) + + def test_chat_message_request_rejects_tab_only_conversation_id(self): + """Test that ChatMessageRequest rejects tab-only conversation_id.""" + # Arrange + timestamp = datetime.now(timezone.utc) + message = ChatHistoryMessage("msg-1", "user", "Hello", timestamp) + + # Act & Assert + with pytest.raises(ValueError, match="conversation_id cannot be empty"): + ChatMessageRequest("\t\t", "msg-456", "How are you?", [message]) + + def test_chat_message_request_rejects_none_chat_history(self): + """Test that ChatMessageRequest rejects None chat_history.""" + # Act & Assert + with pytest.raises(ValueError, match="chat_history cannot be empty"): + ChatMessageRequest("conv-123", "msg-456", "How are you?", None) diff --git a/tests/tooling/services/__init__.py b/tests/tooling/services/__init__.py new file mode 100644 index 0000000..59e481e --- /dev/null +++ b/tests/tooling/services/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/tooling/services/test_send_chat_history.py b/tests/tooling/services/test_send_chat_history.py new file mode 100644 index 0000000..2b4416a --- /dev/null +++ b/tests/tooling/services/test_send_chat_history.py @@ -0,0 +1,300 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for send_chat_history method in McpToolServerConfigurationService.""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents_a365.tooling.models import ChatHistoryMessage +from microsoft_agents_a365.tooling.services import McpToolServerConfigurationService + + +class TestSendChatHistory: + """Tests for send_chat_history method.""" + + @pytest.fixture + def mock_turn_context(self): + """Create a mock TurnContext with spec for stricter interface validation.""" + mock_context = Mock(spec=TurnContext) + mock_activity = Mock() + mock_conversation = Mock() + + mock_conversation.id = "conv-123" + mock_activity.conversation = mock_conversation + mock_activity.id = "msg-456" + mock_activity.text = "Hello, how are you?" + + mock_context.activity = mock_activity + return mock_context + + @pytest.fixture + def chat_history_messages(self): + """Create sample chat history messages.""" + timestamp = datetime.now(timezone.utc) + return [ + ChatHistoryMessage("msg-1", "user", "Hello", timestamp), + ChatHistoryMessage("msg-2", "assistant", "Hi there!", timestamp), + ] + + @pytest.fixture + def service(self): + """Create McpToolServerConfigurationService instance.""" + return McpToolServerConfigurationService() + + @pytest.mark.asyncio + async def test_send_chat_history_success( + self, service, mock_turn_context, chat_history_messages + ): + """Test successful send_chat_history call.""" + # Arrange + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text = AsyncMock(return_value="OK") + + # Mock aiohttp.ClientSession + with patch("aiohttp.ClientSession") as mock_session: + mock_session_instance = MagicMock() + mock_post = AsyncMock() + mock_post.__aenter__.return_value = mock_response + mock_session_instance.post.return_value = mock_post + mock_session.return_value.__aenter__.return_value = mock_session_instance + + # Act + result = await service.send_chat_history(mock_turn_context, chat_history_messages) + + # Assert + assert result.succeeded is True + assert len(result.errors) == 0 + + @pytest.mark.asyncio + async def test_send_chat_history_http_error( + self, service, mock_turn_context, chat_history_messages + ): + """Test send_chat_history with HTTP error response.""" + # Arrange + mock_response = AsyncMock() + mock_response.status = 500 + mock_response.text = AsyncMock(return_value="Internal Server Error") + + # Mock aiohttp.ClientSession + with patch("aiohttp.ClientSession") as mock_session: + mock_session_instance = MagicMock() + mock_post = AsyncMock() + mock_post.__aenter__.return_value = mock_response + mock_session_instance.post.return_value = mock_post + mock_session.return_value.__aenter__.return_value = mock_session_instance + + # Act + result = await service.send_chat_history(mock_turn_context, chat_history_messages) + + # Assert + assert result.succeeded is False + assert len(result.errors) == 1 + # Error now uses aiohttp.ClientResponseError which formats as "status, message=..." + assert "500" in str(result.errors[0].message) + assert "Internal Server Error" in str(result.errors[0].message) + + @pytest.mark.asyncio + async def test_send_chat_history_with_options( + self, service, mock_turn_context, chat_history_messages + ): + """Test send_chat_history with custom options.""" + # Arrange + from microsoft_agents_a365.tooling.models import ToolOptions + + options = ToolOptions(orchestrator_name="TestOrchestrator") + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text = AsyncMock(return_value="OK") + + # Mock aiohttp.ClientSession + with patch("aiohttp.ClientSession") as mock_session: + mock_session_instance = MagicMock() + mock_post = AsyncMock() + mock_post.__aenter__.return_value = mock_response + mock_session_instance.post.return_value = mock_post + mock_session.return_value.__aenter__.return_value = mock_session_instance + + # Act + result = await service.send_chat_history( + mock_turn_context, chat_history_messages, options + ) + + # Assert + assert result.succeeded is True + + @pytest.mark.asyncio + async def test_send_chat_history_validates_turn_context(self, service, chat_history_messages): + """Test that send_chat_history validates turn_context parameter.""" + # Act & Assert + with pytest.raises(ValueError, match="turn_context cannot be None"): + await service.send_chat_history(None, chat_history_messages) + + @pytest.mark.asyncio + async def test_send_chat_history_validates_chat_history_messages( + self, service, mock_turn_context + ): + """Test that send_chat_history validates chat_history_messages parameter.""" + # Act & Assert + with pytest.raises(ValueError, match="chat_history_messages cannot be None or empty"): + await service.send_chat_history(mock_turn_context, None) + + @pytest.mark.asyncio + async def test_send_chat_history_validates_empty_chat_history_list( + self, service, mock_turn_context + ): + """Test that send_chat_history validates empty chat_history list.""" + # Act & Assert + with pytest.raises(ValueError, match="chat_history_messages cannot be None or empty"): + await service.send_chat_history(mock_turn_context, []) + + @pytest.mark.asyncio + async def test_send_chat_history_validates_activity(self, service, chat_history_messages): + """Test that send_chat_history validates turn_context.activity.""" + # Arrange + mock_context = Mock() + mock_context.activity = None + + # Act & Assert + with pytest.raises(ValueError, match="turn_context.activity cannot be None"): + await service.send_chat_history(mock_context, chat_history_messages) + + @pytest.mark.asyncio + async def test_send_chat_history_validates_conversation_id( + self, service, chat_history_messages + ): + """Test that send_chat_history validates conversation_id from activity.""" + # Arrange + mock_context = Mock() + mock_activity = Mock() + mock_activity.conversation = None + mock_activity.id = "msg-123" + mock_activity.text = "Test message" + mock_context.activity = mock_activity + + # Act & Assert + with pytest.raises( + ValueError, + match="conversation_id cannot be empty or None.*turn_context.activity.conversation.id", + ): + await service.send_chat_history(mock_context, chat_history_messages) + + @pytest.mark.asyncio + async def test_send_chat_history_validates_message_id(self, service, chat_history_messages): + """Test that send_chat_history validates message_id from activity.""" + # Arrange + mock_context = Mock() + mock_activity = Mock() + mock_conversation = Mock() + mock_conversation.id = "conv-123" + mock_activity.conversation = mock_conversation + mock_activity.id = None + mock_activity.text = "Test message" + mock_context.activity = mock_activity + + # Act & Assert + with pytest.raises( + ValueError, match="message_id cannot be empty or None.*turn_context.activity.id" + ): + await service.send_chat_history(mock_context, chat_history_messages) + + @pytest.mark.asyncio + async def test_send_chat_history_validates_user_message(self, service, chat_history_messages): + """Test that send_chat_history validates user_message from activity.""" + # Arrange + mock_context = Mock() + mock_activity = Mock() + mock_conversation = Mock() + mock_conversation.id = "conv-123" + mock_activity.conversation = mock_conversation + mock_activity.id = "msg-123" + mock_activity.text = None + mock_context.activity = mock_activity + + # Act & Assert + with pytest.raises( + ValueError, match="user_message cannot be empty or None.*turn_context.activity.text" + ): + await service.send_chat_history(mock_context, chat_history_messages) + + @pytest.mark.asyncio + async def test_send_chat_history_handles_client_error( + self, service, mock_turn_context, chat_history_messages + ): + """Test send_chat_history handles aiohttp.ClientError.""" + # Arrange + import aiohttp + + # Mock aiohttp.ClientSession to raise ClientError + with patch("aiohttp.ClientSession") as mock_session: + mock_session_instance = MagicMock() + mock_session_instance.post.side_effect = aiohttp.ClientError("Connection failed") + mock_session.return_value.__aenter__.return_value = mock_session_instance + + # Act + result = await service.send_chat_history(mock_turn_context, chat_history_messages) + + # Assert + assert result.succeeded is False + assert len(result.errors) == 1 + assert "Connection failed" in str(result.errors[0].message) + + @pytest.mark.asyncio + async def test_send_chat_history_handles_timeout( + self, service, mock_turn_context, chat_history_messages + ): + """Test send_chat_history handles timeout.""" + # Mock aiohttp.ClientSession to raise TimeoutError + with patch("aiohttp.ClientSession") as mock_session: + mock_session_instance = AsyncMock() + mock_session.return_value.__aenter__.return_value = mock_session_instance + mock_session_instance.post.side_effect = TimeoutError() + + # Act + result = await service.send_chat_history(mock_turn_context, chat_history_messages) + + # Assert + assert result.succeeded is False + assert len(result.errors) == 1 + + @pytest.mark.asyncio + async def test_send_chat_history_sends_correct_payload( + self, service, mock_turn_context, chat_history_messages + ): + """Test that send_chat_history sends the correct payload.""" + # Arrange + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text = AsyncMock(return_value="OK") + + # Mock aiohttp.ClientSession + with patch("aiohttp.ClientSession") as mock_session: + mock_session_instance = MagicMock() + mock_post = AsyncMock() + mock_post.__aenter__.return_value = mock_response + mock_session_instance.post.return_value = mock_post + mock_session.return_value.__aenter__.return_value = mock_session_instance + + # Act + await service.send_chat_history(mock_turn_context, chat_history_messages) + + # Assert + # Verify post was called + assert mock_session_instance.post.called + call_args = mock_session_instance.post.call_args + + # Verify the endpoint + assert "real-time-threat-protection/chat-message" in call_args[0][0] + + # Verify headers + headers = call_args[1]["headers"] + assert "User-Agent" in headers or "user-agent" in str(headers).lower() + assert "Content-Type" in headers + + # Verify data is JSON + data = call_args[1]["data"] + assert data is not None