From 30a75cb217359d2f66fe4a06eacb6c117eceb204 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:28:23 +0000 Subject: [PATCH 01/21] Initial plan From 70b54d21a7899a2e61f8273b1764a61fab7032c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:35:50 +0000 Subject: [PATCH 02/21] Add chat history models and send_chat_history method Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- .../microsoft_agents_a365/runtime/__init__.py | 4 + .../runtime/operation_error.py | 57 +++++++++ .../runtime/operation_result.py | 90 ++++++++++++++ .../tooling/models/__init__.py | 4 +- .../tooling/models/chat_history_message.py | 55 +++++++++ .../tooling/models/chat_message_request.py | 57 +++++++++ .../mcp_tool_server_configuration_service.py | 110 ++++++++++++++++++ .../tooling/utils/utility.py | 10 ++ 8 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_error.py create mode 100644 libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_result.py create mode 100644 libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_history_message.py create mode 100644 libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_message_request.py 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 24a54ef8..1c9c6c0e 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,8 @@ # Copyright (c) Microsoft. All rights reserved. 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 +11,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 00000000..ef690ec9 --- /dev/null +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_error.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +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 00000000..82cdc05c --- /dev/null +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/operation_result.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +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. + + Returns: + List[OperationError]: List of operation errors. + """ + return self._errors + + @staticmethod + def success() -> "OperationResult": + """ + Return an OperationResult indicating a successful operation. + + Returns: + OperationResult: An OperationResult indicating a successful operation. + """ + if OperationResult._success_instance is None: + OperationResult._success_instance = OperationResult(succeeded=True) + 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}" 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 23785962..16bdeeff 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 @@ -6,7 +6,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 00000000..044e1f7a --- /dev/null +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_history_message.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Chat History Message model. +""" + +from dataclasses import dataclass +from datetime import datetime + + +@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.""" + if not self.id: + raise ValueError("id cannot be empty") + if not self.role: + raise ValueError("role cannot be empty") + if not self.content: + raise ValueError("content cannot be empty") + if not self.timestamp: + raise ValueError("timestamp cannot be empty") + + def to_dict(self): + """ + Convert the message to a dictionary for JSON serialization. + + Returns: + dict: 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 00000000..e2b45936 --- /dev/null +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_message_request.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Chat Message Request model. +""" + +from dataclasses import dataclass +from typing import 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.""" + if not self.conversation_id: + raise ValueError("conversation_id cannot be empty") + if not self.message_id: + raise ValueError("message_id cannot be empty") + if not self.user_message: + raise ValueError("user_message cannot be empty") + if not self.chat_history: + raise ValueError("chat_history cannot be empty") + + def to_dict(self): + """ + Convert the request to a dictionary for JSON serialization. + + Returns: + dict: 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 aec53f2e..7fb6b61f 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 @@ -17,6 +17,7 @@ # ============================================================================== # Standard library imports +import asyncio import json import logging import os @@ -492,3 +493,112 @@ 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, + conversation_id: str, + message_id: str, + user_message: str, + chat_history_messages: List, + auth_token: str, + options: Optional[ToolOptions] = None, + ): + """ + Sends chat history to the MCP platform for real-time threat protection. + + Args: + conversation_id: The unique identifier for the conversation. + message_id: The unique identifier for the message within the conversation. + user_message: The content of the user's message. + chat_history_messages: List of ChatHistoryMessage objects representing the chat history. + auth_token: Authentication token to access the MCP platform. + options: Optional ToolOptions instance containing optional parameters. + + Returns: + OperationResult: An OperationResult indicating success or failure. + + Raises: + ValueError: If required parameters are invalid or empty. + """ + # Import here to avoid circular dependency + from microsoft_agents_a365.runtime import OperationError, OperationResult + + from ..models import ChatMessageRequest + from ..utils.utility import get_chat_history_endpoint + + # Validate input parameters + if not conversation_id: + raise ValueError("conversation_id cannot be empty or None") + if not message_id: + raise ValueError("message_id cannot be empty or None") + if not user_message: + raise ValueError("user_message cannot be empty or None") + if not chat_history_messages: + raise ValueError("chat_history_messages cannot be empty or None") + if not auth_token: + raise ValueError("auth_token cannot be empty or None") + + # Use default options if none provided + if options is None: + options = ToolOptions(orchestrator_name=None) + + # Get the endpoint URL + endpoint = get_chat_history_endpoint() + + self._logger.info(f"Sending chat history to endpoint: {endpoint}") + + # 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 + headers = { + Constants.Headers.AUTHORIZATION: f"{Constants.Headers.BEARER_PREFIX} {auth_token}", + 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 + async with aiohttp.ClientSession() as session: + async with session.post(endpoint, headers=headers, data=json_data) as response: + if response.status == 200: + self._logger.info("Successfully sent chat history to MCP platform") + return OperationResult.success() + else: + error_text = await response.text() + error_msg = f"HTTP {response.status}: {error_text}" + self._logger.error( + f"HTTP error sending chat history to '{endpoint}': {error_msg}" + ) + return OperationResult.failed( + OperationError(Exception(f"HTTP error: {error_msg}")) + ) + + 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 asyncio.TimeoutError as timeout_ex: + self._logger.error( + f"Request timeout sending chat history to '{endpoint}': {str(timeout_ex)}" + ) + return OperationResult.failed(OperationError(timeout_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 d5666f53..b743fcd1 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 @@ -89,3 +89,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()}/agents/real-time-threat-protection/chat-message" From f7bf9751806af46315d43d576c12a6f0122f718a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:39:32 +0000 Subject: [PATCH 03/21] Add unit tests for chat history models and operation result classes Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- tests/runtime/test_operation_error.py | 53 +++++++++ tests/runtime/test_operation_result.py | 102 +++++++++++++++++ tests/tooling/__init__.py | 1 + tests/tooling/models/__init__.py | 1 + .../models/test_chat_history_message.py | 95 ++++++++++++++++ .../models/test_chat_message_request.py | 104 ++++++++++++++++++ 6 files changed, 356 insertions(+) create mode 100644 tests/runtime/test_operation_error.py create mode 100644 tests/runtime/test_operation_result.py create mode 100644 tests/tooling/__init__.py create mode 100644 tests/tooling/models/__init__.py create mode 100644 tests/tooling/models/test_chat_history_message.py create mode 100644 tests/tooling/models/test_chat_message_request.py diff --git a/tests/runtime/test_operation_error.py b/tests/runtime/test_operation_error.py new file mode 100644 index 00000000..7d7fd14b --- /dev/null +++ b/tests/runtime/test_operation_error.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""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 00000000..2c77af23 --- /dev/null +++ b/tests/runtime/test_operation_result.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""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 00000000..2a50eae8 --- /dev/null +++ b/tests/tooling/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/tests/tooling/models/__init__.py b/tests/tooling/models/__init__.py new file mode 100644 index 00000000..2a50eae8 --- /dev/null +++ b/tests/tooling/models/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. 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 00000000..d7652618 --- /dev/null +++ b/tests/tooling/models/test_chat_history_message.py @@ -0,0 +1,95 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""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 empty"): + 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"] 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 00000000..1952265b --- /dev/null +++ b/tests/tooling/models/test_chat_message_request.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""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" From 34a8605607f801ebb2920b2db7d612c69ed0f044 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:42:13 +0000 Subject: [PATCH 04/21] Fix type annotation for chat_history_messages parameter Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- .../tooling/services/mcp_tool_server_configuration_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 7fb6b61f..08a4fd15 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 @@ -29,7 +29,7 @@ import aiohttp # Local imports -from ..models import MCPServerConfig, ToolOptions +from ..models import ChatHistoryMessage, MCPServerConfig, ToolOptions from ..utils import Constants from ..utils.utility import get_tooling_gateway_for_digital_worker, build_mcp_server_url @@ -503,7 +503,7 @@ async def send_chat_history( conversation_id: str, message_id: str, user_message: str, - chat_history_messages: List, + chat_history_messages: List[ChatHistoryMessage], auth_token: str, options: Optional[ToolOptions] = None, ): From 6545d571d9bfffac30af0c97e2ad5bf336fca14b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:45:02 +0000 Subject: [PATCH 05/21] Add usage example for send_chat_history API Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- examples/send_chat_history_example.py | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 examples/send_chat_history_example.py diff --git a/examples/send_chat_history_example.py b/examples/send_chat_history_example.py new file mode 100644 index 00000000..dae410e3 --- /dev/null +++ b/examples/send_chat_history_example.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Example usage of the chat history API to send chat history to the MCP platform. + +This example demonstrates how to use the send_chat_history method to send +chat conversation history to the MCP platform for real-time threat protection analysis. +""" + +import asyncio +from datetime import datetime, timezone + +from microsoft_agents_a365.tooling.models import ChatHistoryMessage +from microsoft_agents_a365.tooling.services import McpToolServerConfigurationService + + +async def main(): + """Example of sending chat history to MCP platform.""" + + # Create the service + service = McpToolServerConfigurationService() + + # Create chat history messages + messages = [ + ChatHistoryMessage( + id="msg-1", + role="user", + content="Hello, I need help with my account", + timestamp=datetime.now(timezone.utc), + ), + ChatHistoryMessage( + id="msg-2", + role="assistant", + content="I'd be happy to help you with your account. What do you need assistance with?", + timestamp=datetime.now(timezone.utc), + ), + ChatHistoryMessage( + id="msg-3", + role="user", + content="I forgot my password", + timestamp=datetime.now(timezone.utc), + ), + ] + + # Send chat history to MCP platform + result = await service.send_chat_history( + conversation_id="conv-123456", + message_id="msg-4", + user_message="Can you help me reset it?", + chat_history_messages=messages, + auth_token="your-auth-token-here", + ) + + # Check the result + if result.succeeded: + print("✅ Chat history sent successfully!") + else: + print(f"❌ Failed to send chat history: {result}") + for error in result.errors: + print(f" - {error.message}") + + +if __name__ == "__main__": + asyncio.run(main()) From 6c49d71b0c425f20beadde6f2f09910501c9c06e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 01:15:48 +0000 Subject: [PATCH 06/21] Address PR feedback: update copyright headers, remove example file, use TurnContext, remove auth Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- examples/send_chat_history_example.py | 64 ------------------- .../microsoft_agents_a365/runtime/__init__.py | 3 +- .../runtime/operation_error.py | 3 +- .../runtime/operation_result.py | 3 +- .../tooling/models/__init__.py | 3 +- .../tooling/models/chat_history_message.py | 3 +- .../tooling/models/chat_message_request.py | 3 +- .../mcp_tool_server_configuration_service.py | 43 +++++++------ .../tooling/utils/utility.py | 3 +- tests/runtime/test_operation_error.py | 3 +- tests/runtime/test_operation_result.py | 3 +- tests/tooling/__init__.py | 3 +- tests/tooling/models/__init__.py | 3 +- .../models/test_chat_history_message.py | 3 +- .../models/test_chat_message_request.py | 3 +- 15 files changed, 51 insertions(+), 95 deletions(-) delete mode 100644 examples/send_chat_history_example.py diff --git a/examples/send_chat_history_example.py b/examples/send_chat_history_example.py deleted file mode 100644 index dae410e3..00000000 --- a/examples/send_chat_history_example.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -""" -Example usage of the chat history API to send chat history to the MCP platform. - -This example demonstrates how to use the send_chat_history method to send -chat conversation history to the MCP platform for real-time threat protection analysis. -""" - -import asyncio -from datetime import datetime, timezone - -from microsoft_agents_a365.tooling.models import ChatHistoryMessage -from microsoft_agents_a365.tooling.services import McpToolServerConfigurationService - - -async def main(): - """Example of sending chat history to MCP platform.""" - - # Create the service - service = McpToolServerConfigurationService() - - # Create chat history messages - messages = [ - ChatHistoryMessage( - id="msg-1", - role="user", - content="Hello, I need help with my account", - timestamp=datetime.now(timezone.utc), - ), - ChatHistoryMessage( - id="msg-2", - role="assistant", - content="I'd be happy to help you with your account. What do you need assistance with?", - timestamp=datetime.now(timezone.utc), - ), - ChatHistoryMessage( - id="msg-3", - role="user", - content="I forgot my password", - timestamp=datetime.now(timezone.utc), - ), - ] - - # Send chat history to MCP platform - result = await service.send_chat_history( - conversation_id="conv-123456", - message_id="msg-4", - user_message="Can you help me reset it?", - chat_history_messages=messages, - auth_token="your-auth-token-here", - ) - - # Check the result - if result.succeeded: - print("✅ Chat history sent successfully!") - else: - print(f"❌ Failed to send chat history: {result}") - for error in result.errors: - print(f" - {error.message}") - - -if __name__ == "__main__": - asyncio.run(main()) 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 1c9c6c0e..b27952f3 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,4 +1,5 @@ -# 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 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 index ef690ec9..a0581dd4 100644 --- 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 @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Encapsulates an error from an operation. 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 index 82cdc05c..19859eca 100644 --- 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 @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Represents the result of an operation. 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 16bdeeff..15e502e4 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. 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 index 044e1f7a..bd2fcf9c 100644 --- 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 @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Chat History Message model. 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 index e2b45936..bfe6320e 100644 --- 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 @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Chat Message Request model. 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 08a4fd15..2a5117cb 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. @@ -500,22 +501,16 @@ def _validate_server_strings(self, name: Optional[str], unique_name: Optional[st async def send_chat_history( self, - conversation_id: str, - message_id: str, - user_message: str, + turn_context, chat_history_messages: List[ChatHistoryMessage], - auth_token: str, options: Optional[ToolOptions] = None, ): """ Sends chat history to the MCP platform for real-time threat protection. Args: - conversation_id: The unique identifier for the conversation. - message_id: The unique identifier for the message within the conversation. - user_message: The content of the user's message. + turn_context: TurnContext from the Agents SDK containing conversation information. chat_history_messages: List of ChatHistoryMessage objects representing the chat history. - auth_token: Authentication token to access the MCP platform. options: Optional ToolOptions instance containing optional parameters. Returns: @@ -531,16 +526,29 @@ async def send_chat_history( from ..utils.utility import get_chat_history_endpoint # Validate input parameters + if not turn_context: + raise ValueError("turn_context cannot be empty or None") + if not chat_history_messages: + raise ValueError("chat_history_messages cannot be empty or None") + + # Extract required information from turn context + if not turn_context.activity: + raise ValueError("turn_context.activity cannot be None") + + conversation_id = ( + turn_context.activity.conversation.id + if turn_context.activity.conversation + else None + ) + message_id = turn_context.activity.id + user_message = turn_context.activity.text + if not conversation_id: - raise ValueError("conversation_id cannot be empty or None") + raise ValueError("conversation_id cannot be empty or None (from turn_context.activity.conversation.id)") if not message_id: - raise ValueError("message_id cannot be empty or None") + raise ValueError("message_id cannot be empty or None (from turn_context.activity.id)") if not user_message: - raise ValueError("user_message cannot be empty or None") - if not chat_history_messages: - raise ValueError("chat_history_messages cannot be empty or None") - if not auth_token: - raise ValueError("auth_token cannot be empty or None") + raise ValueError("user_message cannot be empty or None (from turn_context.activity.text)") # Use default options if none provided if options is None: @@ -560,9 +568,8 @@ async def send_chat_history( ) try: - # Prepare headers + # Prepare headers (no authentication required) headers = { - Constants.Headers.AUTHORIZATION: f"{Constants.Headers.BEARER_PREFIX} {auth_token}", Constants.Headers.USER_AGENT: RuntimeUtility.get_user_agent_header( options.orchestrator_name ), 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 b743fcd1..611e8f37 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. diff --git a/tests/runtime/test_operation_error.py b/tests/runtime/test_operation_error.py index 7d7fd14b..af02c9ac 100644 --- a/tests/runtime/test_operation_error.py +++ b/tests/runtime/test_operation_error.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """Unit tests for OperationError class.""" diff --git a/tests/runtime/test_operation_result.py b/tests/runtime/test_operation_result.py index 2c77af23..77c8a3dc 100644 --- a/tests/runtime/test_operation_result.py +++ b/tests/runtime/test_operation_result.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """Unit tests for OperationResult class.""" diff --git a/tests/tooling/__init__.py b/tests/tooling/__init__.py index 2a50eae8..59e481eb 100644 --- a/tests/tooling/__init__.py +++ b/tests/tooling/__init__.py @@ -1 +1,2 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/tooling/models/__init__.py b/tests/tooling/models/__init__.py index 2a50eae8..59e481eb 100644 --- a/tests/tooling/models/__init__.py +++ b/tests/tooling/models/__init__.py @@ -1 +1,2 @@ -# Copyright (c) Microsoft. All rights reserved. +# 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 index d7652618..bbd00cb2 100644 --- a/tests/tooling/models/test_chat_history_message.py +++ b/tests/tooling/models/test_chat_history_message.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """Unit tests for ChatHistoryMessage class.""" diff --git a/tests/tooling/models/test_chat_message_request.py b/tests/tooling/models/test_chat_message_request.py index 1952265b..4c078ed2 100644 --- a/tests/tooling/models/test_chat_message_request.py +++ b/tests/tooling/models/test_chat_message_request.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """Unit tests for ChatMessageRequest class.""" From 4660d6c8afdddc563824f112919bddcf2114e30f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 02:20:03 +0000 Subject: [PATCH 07/21] Add type annotation for turn_context parameter Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- .../services/mcp_tool_server_configuration_service.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 2a5117cb..5eeb0192 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 @@ -24,11 +24,14 @@ import os import sys from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, TYPE_CHECKING # Third-party imports import aiohttp +if TYPE_CHECKING: + from microsoft_agents.hosting.core import TurnContext + # Local imports from ..models import ChatHistoryMessage, MCPServerConfig, ToolOptions from ..utils import Constants @@ -501,7 +504,7 @@ def _validate_server_strings(self, name: Optional[str], unique_name: Optional[st async def send_chat_history( self, - turn_context, + turn_context: "TurnContext", chat_history_messages: List[ChatHistoryMessage], options: Optional[ToolOptions] = None, ): From 38206e14afd5b32266b858abb3e3e1b75fed13f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 02:30:11 +0000 Subject: [PATCH 08/21] Remove TYPE_CHECKING and always import TurnContext, add microsoft-agents-hosting-core dependency Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- .../services/mcp_tool_server_configuration_service.py | 8 +++----- libraries/microsoft-agents-a365-tooling/pyproject.toml | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) 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 5eeb0192..cd657762 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 @@ -24,13 +24,11 @@ import os import sys from pathlib import Path -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import Any, Dict, List, Optional # Third-party imports import aiohttp - -if TYPE_CHECKING: - from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core import TurnContext # Local imports from ..models import ChatHistoryMessage, MCPServerConfig, ToolOptions @@ -504,7 +502,7 @@ def _validate_server_strings(self, name: Optional[str], unique_name: Optional[st async def send_chat_history( self, - turn_context: "TurnContext", + turn_context: TurnContext, chat_history_messages: List[ChatHistoryMessage], options: Optional[ToolOptions] = None, ): diff --git a/libraries/microsoft-agents-a365-tooling/pyproject.toml b/libraries/microsoft-agents-a365-tooling/pyproject.toml index 5c67a627..be140e04 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] From 4443b7d8507b51d57e9628ece3b70f044fa3811c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:07:08 +0000 Subject: [PATCH 09/21] Add comprehensive unit tests for send_chat_history method Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- tests/tooling/services/__init__.py | 1 + .../services/test_send_chat_history.py | 285 ++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 tests/tooling/services/__init__.py create mode 100644 tests/tooling/services/test_send_chat_history.py diff --git a/tests/tooling/services/__init__.py b/tests/tooling/services/__init__.py new file mode 100644 index 00000000..48e487fd --- /dev/null +++ b/tests/tooling/services/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft Corporation.\n# 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 00000000..6712b9a9 --- /dev/null +++ b/tests/tooling/services/test_send_chat_history.py @@ -0,0 +1,285 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for send_chat_history method in McpToolServerConfigurationService.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +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.""" + mock_context = Mock() + 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(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 + assert "HTTP 500" 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 + + 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 empty or None"): + import asyncio + asyncio.run(service.send_chat_history(None, chat_history_messages)) + + 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 empty or None"): + import asyncio + asyncio.run(service.send_chat_history(mock_turn_context, None)) + + 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"): + import asyncio + asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) + + 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" + ): + import asyncio + asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) + + 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" + ): + import asyncio + asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) + + 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" + ): + import asyncio + asyncio.run(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 From daed65e5e5004be8a7bf8e2e805d10ecad6fe3cd Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Wed, 7 Jan 2026 20:31:04 -0800 Subject: [PATCH 10/21] Apply suggestion from @pontemonti --- tests/tooling/services/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/tooling/services/__init__.py b/tests/tooling/services/__init__.py index 48e487fd..59e481eb 100644 --- a/tests/tooling/services/__init__.py +++ b/tests/tooling/services/__init__.py @@ -1 +1,2 @@ -# Copyright (c) Microsoft Corporation.\n# Licensed under the MIT License. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. From 1b6f196eaa9ea805761bf249f5c027d014a97a2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:35:35 +0000 Subject: [PATCH 11/21] Run ruff format to fix code formatting Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- .../mcp_tool_server_configuration_service.py | 19 +++++++++---------- .../services/test_send_chat_history.py | 13 +++++++++---- 2 files changed, 18 insertions(+), 14 deletions(-) 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 cd657762..bcaeb12e 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 @@ -535,21 +535,23 @@ async def send_chat_history( # Extract required information from turn context if not turn_context.activity: raise ValueError("turn_context.activity cannot be None") - + conversation_id = ( - turn_context.activity.conversation.id - if turn_context.activity.conversation - else None + turn_context.activity.conversation.id if turn_context.activity.conversation else None ) message_id = turn_context.activity.id user_message = turn_context.activity.text if not conversation_id: - raise ValueError("conversation_id cannot be empty or None (from turn_context.activity.conversation.id)") + raise ValueError( + "conversation_id cannot be empty or None (from turn_context.activity.conversation.id)" + ) if not message_id: raise ValueError("message_id cannot be empty or None (from turn_context.activity.id)") if not user_message: - raise ValueError("user_message cannot be empty or None (from turn_context.activity.text)") + raise ValueError( + "user_message cannot be empty or None (from turn_context.activity.text)" + ) # Use default options if none provided if options is None: @@ -597,9 +599,7 @@ async def send_chat_history( ) except aiohttp.ClientError as http_ex: - self._logger.error( - f"HTTP error sending chat history to '{endpoint}': {str(http_ex)}" - ) + self._logger.error(f"HTTP error sending chat history to '{endpoint}': {str(http_ex)}") return OperationResult.failed(OperationError(http_ex)) except asyncio.TimeoutError as timeout_ex: self._logger.error( @@ -609,4 +609,3 @@ async def send_chat_history( 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/tests/tooling/services/test_send_chat_history.py b/tests/tooling/services/test_send_chat_history.py index 6712b9a9..b0021cab 100644 --- a/tests/tooling/services/test_send_chat_history.py +++ b/tests/tooling/services/test_send_chat_history.py @@ -129,15 +129,15 @@ def test_send_chat_history_validates_turn_context(self, service, chat_history_me # Act & Assert with pytest.raises(ValueError, match="turn_context cannot be empty or None"): import asyncio + asyncio.run(service.send_chat_history(None, chat_history_messages)) - def test_send_chat_history_validates_chat_history_messages( - self, service, mock_turn_context - ): + 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 empty or None"): import asyncio + asyncio.run(service.send_chat_history(mock_turn_context, None)) def test_send_chat_history_validates_activity(self, service, chat_history_messages): @@ -149,6 +149,7 @@ def test_send_chat_history_validates_activity(self, service, chat_history_messag # Act & Assert with pytest.raises(ValueError, match="turn_context.activity cannot be None"): import asyncio + asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) def test_send_chat_history_validates_conversation_id(self, service, chat_history_messages): @@ -163,9 +164,11 @@ def test_send_chat_history_validates_conversation_id(self, service, chat_history # Act & Assert with pytest.raises( - ValueError, match="conversation_id cannot be empty or None.*turn_context.activity.conversation.id" + ValueError, + match="conversation_id cannot be empty or None.*turn_context.activity.conversation.id", ): import asyncio + asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) def test_send_chat_history_validates_message_id(self, service, chat_history_messages): @@ -185,6 +188,7 @@ def test_send_chat_history_validates_message_id(self, service, chat_history_mess ValueError, match="message_id cannot be empty or None.*turn_context.activity.id" ): import asyncio + asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) def test_send_chat_history_validates_user_message(self, service, chat_history_messages): @@ -204,6 +208,7 @@ def test_send_chat_history_validates_user_message(self, service, chat_history_me ValueError, match="user_message cannot be empty or None.*turn_context.activity.text" ): import asyncio + asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) @pytest.mark.asyncio From a79f8d972d402b0445c372995e37ae76ed08fd16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 05:02:48 +0000 Subject: [PATCH 12/21] Use consistent datetime import style in test_send_chat_history.py Co-authored-by: pontemonti <7850950+pontemonti@users.noreply.github.com> --- tests/tooling/services/test_send_chat_history.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tooling/services/test_send_chat_history.py b/tests/tooling/services/test_send_chat_history.py index b0021cab..90a65843 100644 --- a/tests/tooling/services/test_send_chat_history.py +++ b/tests/tooling/services/test_send_chat_history.py @@ -3,7 +3,7 @@ """Unit tests for send_chat_history method in McpToolServerConfigurationService.""" -from datetime import UTC, datetime +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -32,7 +32,7 @@ def mock_turn_context(self): @pytest.fixture def chat_history_messages(self): """Create sample chat history messages.""" - timestamp = datetime.now(UTC) + timestamp = datetime.now(timezone.utc) return [ ChatHistoryMessage("msg-1", "user", "Hello", timestamp), ChatHistoryMessage("msg-2", "assistant", "Hi there!", timestamp), From e93e24e1195efd546de10fb2147a721b1f2ad7c0 Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Sat, 17 Jan 2026 13:42:06 -0800 Subject: [PATCH 13/21] Code review fixes/pr 105 (#114) * Fix inconsistent string representation for failed result with no errors Improves the __str__ method of OperationResult to return "Failed" instead of "Failed : " when there are no errors. Also removes the extra space before the colon for consistency. Addresses code review Comment 1. Co-Authored-By: Claude * Return defensive copy of errors list to protect singleton The errors property now returns a copy of the internal errors list to protect the singleton instance returned by success() from accidental modification. Updated docstring to document this behavior. Addresses code review Comment 2. Co-Authored-By: Claude * Use explicit None check for timestamp validation Changed timestamp validation from falsy check (if not self.timestamp) to explicit None check (if self.timestamp is None) for safer and more intentional validation behavior. Updated error message and test accordingly. Addresses code review Comment 3. Co-Authored-By: Claude * Move local imports to top of file Moved OperationError, OperationResult, ChatMessageRequest, and get_chat_history_endpoint imports from inside the send_chat_history method to the top of the file with the other imports. Removed the misleading comment about circular dependencies as there is no cycle in the import graph. Addresses code review Comment 4. Co-Authored-By: Claude * Change endpoint URL log level from INFO to DEBUG Detailed operational information like endpoint URLs should be logged at DEBUG level rather than INFO level. INFO level is reserved for higher-level operation status messages. Addresses code review Comment 5. Co-Authored-By: Claude * Use consistent async test pattern for validation tests Converted validation tests from synchronous methods using asyncio.run() to async methods with @pytest.mark.asyncio decorator for consistency with the other tests in the test suite. Addresses code review Comment 7. Co-Authored-By: Claude --------- Co-authored-by: Johan Broberg Co-authored-by: Claude --- .../runtime/operation_result.py | 8 ++-- .../tooling/models/chat_history_message.py | 4 +- .../mcp_tool_server_configuration_service.py | 17 ++++--- tests/runtime/test_operation_result.py | 2 +- .../models/test_chat_history_message.py | 2 +- .../services/test_send_chat_history.py | 46 +++++++++---------- 6 files changed, 39 insertions(+), 40 deletions(-) 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 index 19859eca..5a0187b0 100644 --- 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 @@ -47,9 +47,11 @@ def errors(self) -> List[OperationError]: Get the list of errors that occurred during the operation. Returns: - List[OperationError]: List of operation errors. + List[OperationError]: A copy of the list of operation errors. + The returned list is a defensive copy to protect the singleton + instance returned by success() from accidental modification. """ - return self._errors + return list(self._errors) @staticmethod def success() -> "OperationResult": @@ -88,4 +90,4 @@ def __str__(self) -> str: return "Succeeded" else: error_messages = ", ".join(str(error.message) for error in self._errors) - return f"Failed : {error_messages}" + return f"Failed: {error_messages}" if error_messages else "Failed" 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 index bd2fcf9c..56aeaa89 100644 --- 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 @@ -38,8 +38,8 @@ def __post_init__(self): raise ValueError("role cannot be empty") if not self.content: raise ValueError("content cannot be empty") - if not self.timestamp: - raise ValueError("timestamp cannot be empty") + if self.timestamp is None: + raise ValueError("timestamp cannot be None") def to_dict(self): """ 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 bcaeb12e..ace0b466 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 @@ -31,11 +31,16 @@ from microsoft_agents.hosting.core import TurnContext # Local imports -from ..models import ChatHistoryMessage, 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 @@ -520,12 +525,6 @@ async def send_chat_history( Raises: ValueError: If required parameters are invalid or empty. """ - # Import here to avoid circular dependency - from microsoft_agents_a365.runtime import OperationError, OperationResult - - from ..models import ChatMessageRequest - from ..utils.utility import get_chat_history_endpoint - # Validate input parameters if not turn_context: raise ValueError("turn_context cannot be empty or None") @@ -560,7 +559,7 @@ async def send_chat_history( # Get the endpoint URL endpoint = get_chat_history_endpoint() - self._logger.info(f"Sending chat history to endpoint: {endpoint}") + self._logger.debug(f"Sending chat history to endpoint: {endpoint}") # Create the request payload request = ChatMessageRequest( diff --git a/tests/runtime/test_operation_result.py b/tests/runtime/test_operation_result.py index 77c8a3dc..61edbed9 100644 --- a/tests/runtime/test_operation_result.py +++ b/tests/runtime/test_operation_result.py @@ -85,7 +85,7 @@ def test_operation_result_failed_string_representation_no_errors(self): result = OperationResult.failed() # Assert - assert str(result) == "Failed : " + assert str(result) == "Failed" def test_operation_result_failed_string_representation_with_errors(self): """Test that failed OperationResult with errors has correct string representation.""" diff --git a/tests/tooling/models/test_chat_history_message.py b/tests/tooling/models/test_chat_history_message.py index bbd00cb2..570e6141 100644 --- a/tests/tooling/models/test_chat_history_message.py +++ b/tests/tooling/models/test_chat_history_message.py @@ -70,7 +70,7 @@ def test_chat_history_message_requires_non_empty_content(self): def test_chat_history_message_requires_timestamp(self): """Test that ChatHistoryMessage requires a timestamp.""" # Act & Assert - with pytest.raises(ValueError, match="timestamp cannot be empty"): + 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): diff --git a/tests/tooling/services/test_send_chat_history.py b/tests/tooling/services/test_send_chat_history.py index 90a65843..1783c34c 100644 --- a/tests/tooling/services/test_send_chat_history.py +++ b/tests/tooling/services/test_send_chat_history.py @@ -124,23 +124,24 @@ async def test_send_chat_history_with_options( # Assert assert result.succeeded is True - def test_send_chat_history_validates_turn_context(self, service, chat_history_messages): + @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 empty or None"): - import asyncio - - asyncio.run(service.send_chat_history(None, chat_history_messages)) + await service.send_chat_history(None, chat_history_messages) - def test_send_chat_history_validates_chat_history_messages(self, service, mock_turn_context): + @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 empty or None"): - import asyncio + await service.send_chat_history(mock_turn_context, None) - asyncio.run(service.send_chat_history(mock_turn_context, None)) - - def test_send_chat_history_validates_activity(self, service, chat_history_messages): + @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() @@ -148,11 +149,12 @@ def test_send_chat_history_validates_activity(self, service, chat_history_messag # Act & Assert with pytest.raises(ValueError, match="turn_context.activity cannot be None"): - import asyncio - - asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) + await service.send_chat_history(mock_context, chat_history_messages) - def test_send_chat_history_validates_conversation_id(self, service, 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() @@ -167,11 +169,10 @@ def test_send_chat_history_validates_conversation_id(self, service, chat_history ValueError, match="conversation_id cannot be empty or None.*turn_context.activity.conversation.id", ): - import asyncio + await service.send_chat_history(mock_context, chat_history_messages) - asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) - - def test_send_chat_history_validates_message_id(self, service, 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() @@ -187,11 +188,10 @@ def test_send_chat_history_validates_message_id(self, service, chat_history_mess with pytest.raises( ValueError, match="message_id cannot be empty or None.*turn_context.activity.id" ): - import asyncio + await service.send_chat_history(mock_context, chat_history_messages) - asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) - - def test_send_chat_history_validates_user_message(self, service, 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() @@ -207,9 +207,7 @@ def test_send_chat_history_validates_user_message(self, service, chat_history_me with pytest.raises( ValueError, match="user_message cannot be empty or None.*turn_context.activity.text" ): - import asyncio - - asyncio.run(service.send_chat_history(mock_context, chat_history_messages)) + await service.send_chat_history(mock_context, chat_history_messages) @pytest.mark.asyncio async def test_send_chat_history_handles_client_error( From 440549b63d948b2c56debe91f8ebba569af866de Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Sat, 17 Jan 2026 19:12:29 -0800 Subject: [PATCH 14/21] fix(tooling): add whitespace validation and type hints to model classes Add .strip() checks to string validation in ChatHistoryMessage and ChatMessageRequest to reject whitespace-only values (CRM-001, CRM-002). Add missing return type annotations on to_dict() methods (CRM-005, CRM-006). Improve __post_init__ docstrings with validation details (CRM-015). Co-Authored-By: Claude Opus 4.5 --- .../tooling/models/chat_history_message.py | 21 +++++++++++----- .../tooling/models/chat_message_request.py | 24 ++++++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) 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 index 56aeaa89..06f819c3 100644 --- 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 @@ -7,6 +7,7 @@ from dataclasses import dataclass from datetime import datetime +from typing import Any, Dict @dataclass @@ -31,22 +32,30 @@ class ChatHistoryMessage: timestamp: datetime def __post_init__(self): - """Validate the message after initialization.""" - if not self.id: + """ + 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") - if not self.role: + if not self.role or not self.role.strip(): raise ValueError("role cannot be empty") - if not self.content: + if not self.content or not self.content.strip(): raise ValueError("content cannot be empty") if self.timestamp is None: raise ValueError("timestamp cannot be None") - def to_dict(self): + def to_dict(self) -> Dict[str, Any]: """ Convert the message to a dictionary for JSON serialization. Returns: - dict: Dictionary representation of the message. + Dict[str, Any]: Dictionary representation of the message. """ return { "id": self.id, 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 index bfe6320e..1df8ebd8 100644 --- 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 @@ -6,7 +6,7 @@ """ from dataclasses import dataclass -from typing import List +from typing import Any, Dict, List from .chat_history_message import ChatHistoryMessage @@ -33,22 +33,30 @@ class ChatMessageRequest: chat_history: List[ChatHistoryMessage] def __post_init__(self): - """Validate the request after initialization.""" - if not self.conversation_id: + """ + 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: + if not self.message_id or not self.message_id.strip(): raise ValueError("message_id cannot be empty") - if not self.user_message: + if not self.user_message or not self.user_message.strip(): raise ValueError("user_message cannot be empty") - if not self.chat_history: + if self.chat_history is None or len(self.chat_history) == 0: raise ValueError("chat_history cannot be empty") - def to_dict(self): + def to_dict(self) -> Dict[str, Any]: """ Convert the request to a dictionary for JSON serialization. Returns: - dict: Dictionary representation of the request. + Dict[str, Any]: Dictionary representation of the request. """ return { "conversationId": self.conversation_id, From 22c37608e2f89e0cd0241d2245d52f2d48173035 Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Sat, 17 Jan 2026 19:13:38 -0800 Subject: [PATCH 15/21] fix(tooling): improve send_chat_history validation and error handling - Add explicit None checks for turn_context and chat_history_messages (CRM-003) - Add return type annotation OperationResult to send_chat_history (CRM-004) - Use explicit None and .strip() checks for field validation (CRM-007) - Add 30 second HTTP timeout to prevent indefinite hangs (CRM-008) - Log only URL path to avoid potential PII exposure (CRM-009) - Use aiohttp.ClientResponseError for consistent error handling (CRM-010) - Enhance docstring with detailed validation requirements (CRM-013) Co-Authored-By: Claude Opus 4.5 --- .../mcp_tool_server_configuration_service.py | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) 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 ace0b466..4e13ecd1 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 @@ -25,6 +25,7 @@ import sys from pathlib import Path from typing import Any, Dict, List, Optional +from urllib.parse import urlparse # Third-party imports import aiohttp @@ -510,26 +511,33 @@ async def send_chat_history( 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. - chat_history_messages: List of ChatHistoryMessage objects representing the chat history. + 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 required parameters are invalid or empty. + 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. """ # Validate input parameters - if not turn_context: - raise ValueError("turn_context cannot be empty or None") - if not chat_history_messages: - raise ValueError("chat_history_messages cannot be empty or None") + 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: @@ -541,13 +549,13 @@ async def send_chat_history( message_id = turn_context.activity.id user_message = turn_context.activity.text - if not conversation_id: + 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 not message_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 not user_message: + 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)" ) @@ -559,7 +567,9 @@ async def send_chat_history( # Get the endpoint URL endpoint = get_chat_history_endpoint() - self._logger.debug(f"Sending chat history to endpoint: {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( @@ -581,21 +591,27 @@ async def send_chat_history( # Convert request to JSON json_data = json.dumps(request.to_dict()) - # Send POST request - async with aiohttp.ClientSession() as session: + # Send POST request with timeout to prevent indefinite hangs + timeout = aiohttp.ClientTimeout(total=30) # 30 second timeout + async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(endpoint, headers=headers, data=json_data) as response: if response.status == 200: self._logger.info("Successfully sent chat history to MCP platform") return OperationResult.success() else: error_text = await response.text() - error_msg = f"HTTP {response.status}: {error_text}" self._logger.error( - f"HTTP error sending chat history to '{endpoint}': {error_msg}" + f"HTTP error sending chat history: HTTP {response.status}" ) - return OperationResult.failed( - OperationError(Exception(f"HTTP error: {error_msg}")) + # 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 aiohttp.ClientError as http_ex: self._logger.error(f"HTTP error sending chat history to '{endpoint}': {str(http_ex)}") From 6f22ddea623d96d1e100f6f3c4fbe263e8f6adc8 Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Sat, 17 Jan 2026 19:14:08 -0800 Subject: [PATCH 16/21] refactor(tooling): extract endpoint path to constant Extract hardcoded endpoint path to CHAT_HISTORY_ENDPOINT_PATH constant for better maintainability and discoverability (CRM-017). Co-Authored-By: Claude Opus 4.5 --- .../microsoft_agents_a365/tooling/utils/utility.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 611e8f37..28e8f190 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 @@ -11,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" @@ -99,4 +102,4 @@ def get_chat_history_endpoint() -> str: Returns: str: The chat history endpoint URL. """ - return f"{_get_mcp_platform_base_url()}/agents/real-time-threat-protection/chat-message" + return f"{_get_mcp_platform_base_url()}{CHAT_HISTORY_ENDPOINT_PATH}" From 8f9b0d4bb1f6d604feebc8f077b3a77261db9926 Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Sat, 17 Jan 2026 19:15:34 -0800 Subject: [PATCH 17/21] test(tooling): add whitespace and empty list validation tests Add tests for whitespace-only string validation in ChatHistoryMessage and ChatMessageRequest (CRM-012). Add test for empty chat_history list validation in send_chat_history (CRM-011). Update HTTP error test to match new aiohttp.ClientResponseError format. Co-Authored-By: Claude Opus 4.5 --- .../models/test_chat_history_message.py | 45 ++++++++++++++++++ .../models/test_chat_message_request.py | 46 +++++++++++++++++++ .../services/test_send_chat_history.py | 17 +++++-- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/tests/tooling/models/test_chat_history_message.py b/tests/tooling/models/test_chat_history_message.py index 570e6141..6a71a2a4 100644 --- a/tests/tooling/models/test_chat_history_message.py +++ b/tests/tooling/models/test_chat_history_message.py @@ -94,3 +94,48 @@ def test_chat_history_message_preserves_timestamp_precision(self): # 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 index 4c078ed2..6b0e7cf7 100644 --- a/tests/tooling/models/test_chat_message_request.py +++ b/tests/tooling/models/test_chat_message_request.py @@ -103,3 +103,49 @@ def test_chat_message_request_with_multiple_messages(self): 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/test_send_chat_history.py b/tests/tooling/services/test_send_chat_history.py index 1783c34c..741fb2b2 100644 --- a/tests/tooling/services/test_send_chat_history.py +++ b/tests/tooling/services/test_send_chat_history.py @@ -92,7 +92,9 @@ async def test_send_chat_history_http_error( # Assert assert result.succeeded is False assert len(result.errors) == 1 - assert "HTTP 500" in str(result.errors[0].message) + # 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( @@ -128,7 +130,7 @@ async def test_send_chat_history_with_options( 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 empty or None"): + with pytest.raises(ValueError, match="turn_context cannot be None"): await service.send_chat_history(None, chat_history_messages) @pytest.mark.asyncio @@ -137,9 +139,18 @@ async def test_send_chat_history_validates_chat_history_messages( ): """Test that send_chat_history validates chat_history_messages parameter.""" # Act & Assert - with pytest.raises(ValueError, match="chat_history_messages cannot be empty or None"): + 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.""" From b3e88ff7bf3d36e0232ced7a870add4871471776 Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Sat, 17 Jan 2026 20:28:42 -0800 Subject: [PATCH 18/21] Code review fixes/pr 105 v3 (#115) * fix(runtime): implement thread-safe singleton with eager initialization Convert OperationResult.success() singleton from lazy to eager initialization at module level. Python's import lock ensures thread-safe initialization, eliminating the race condition in the previous check-and-create pattern. Addresses CRM-001 from code review. Co-Authored-By: Claude Opus 4.5 * fix(tooling): include error response body in HTTP error log message Add truncated error_text (max 500 chars) to the log message when HTTP errors occur in send_chat_history. This improves debugging by showing the actual error response from the MCP platform. Addresses CRM-002 from code review. Co-Authored-By: Claude Opus 4.5 * fix(tooling): reorder exception handlers for proper timeout handling Move asyncio.TimeoutError handler before aiohttp.ClientError to ensure timeouts are caught correctly. Since aiohttp.ServerTimeoutError inherits from both exceptions, the previous order could misclassify timeouts. Addresses CRM-003 from code review. Co-Authored-By: Claude Opus 4.5 * refactor(tooling): add type hints for local variables in send_chat_history Add Optional[str] type annotations for conversation_id, message_id, and user_message variables to improve code clarity and IDE support. Addresses CRM-004 from code review. Co-Authored-By: Claude Opus 4.5 * docs(tooling): improve validation error messages in ChatHistoryMessage Update error messages to say "cannot be empty or whitespace-only" instead of just "cannot be empty" for clearer feedback when whitespace validation fails. Addresses CRM-005 from code review. Co-Authored-By: Claude Opus 4.5 * refactor(tooling): extract timeout and HTTP status code to constants Add DEFAULT_REQUEST_TIMEOUT_SECONDS and HTTP_STATUS_OK module-level constants to replace magic values. Improves maintainability and makes configuration easier to modify. Addresses CRM-006 from code review. Co-Authored-By: Claude Opus 4.5 * docs(runtime): enhance defensive copy docstring in OperationResult.errors Move the defensive copy rationale into a prominent Note section in the docstring to make it more visible to developers. This clarifies why the property returns a copy rather than the internal list. Addresses CRM-007 from code review. Co-Authored-By: Claude Opus 4.5 * test(tooling): use Mock(spec=TurnContext) for stricter interface validation Import TurnContext and use it as a spec for the mock fixture. This ensures the mock matches the actual TurnContext interface, catching potential issues if the API changes. Addresses CRM-008 from code review. Co-Authored-By: Claude Opus 4.5 * docs(tooling): add usage example to send_chat_history docstring Add an Example section showing how to create ChatHistoryMessage objects and call send_chat_history with proper error handling. This helps developers understand the intended usage pattern. Addresses CRM-009 from code review. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Johan Broberg Co-authored-by: Claude Opus 4.5 --- .../runtime/operation_result.py | 13 +++-- .../tooling/models/chat_history_message.py | 6 +-- .../mcp_tool_server_configuration_service.py | 50 +++++++++++++++---- .../services/test_send_chat_history.py | 5 +- 4 files changed, 55 insertions(+), 19 deletions(-) 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 index 5a0187b0..e51e8c12 100644 --- 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 @@ -46,10 +46,13 @@ 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. - The returned list is a defensive copy to protect the singleton - instance returned by success() from accidental modification. """ return list(self._errors) @@ -61,8 +64,6 @@ def success() -> "OperationResult": Returns: OperationResult: An OperationResult indicating a successful operation. """ - if OperationResult._success_instance is None: - OperationResult._success_instance = OperationResult(succeeded=True) return OperationResult._success_instance @staticmethod @@ -91,3 +92,7 @@ def __str__(self) -> str: 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/chat_history_message.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/chat_history_message.py index 06f819c3..05201861 100644 --- 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 @@ -42,11 +42,11 @@ def __post_init__(self): or if timestamp is None. """ if not self.id or not self.id.strip(): - raise ValueError("id cannot be empty") + raise ValueError("id cannot be empty or whitespace-only") if not self.role or not self.role.strip(): - raise ValueError("role cannot be empty") + raise ValueError("role cannot be empty or whitespace-only") if not self.content or not self.content.strip(): - raise ValueError("content cannot be empty") + raise ValueError("content cannot be empty or whitespace-only") if self.timestamp is None: raise ValueError("timestamp cannot be None") 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 4e13ecd1..056c0a05 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 @@ -45,6 +45,17 @@ 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 # ============================================================================== @@ -532,6 +543,20 @@ async def send_chat_history( 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: @@ -543,13 +568,15 @@ async def send_chat_history( if not turn_context.activity: raise ValueError("turn_context.activity cannot be None") - conversation_id = ( + conversation_id: Optional[str] = ( turn_context.activity.conversation.id if turn_context.activity.conversation else None ) - message_id = turn_context.activity.id - user_message = turn_context.activity.text + 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()): + 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)" ) @@ -592,16 +619,17 @@ async def send_chat_history( json_data = json.dumps(request.to_dict()) # Send POST request with timeout to prevent indefinite hangs - timeout = aiohttp.ClientTimeout(total=30) # 30 second timeout + 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 == 200: + 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"HTTP error sending chat history: HTTP {response.status}. " + f"Response: {error_text[:500]}" ) # Use ClientResponseError for consistent error handling http_error = aiohttp.ClientResponseError( @@ -613,14 +641,16 @@ async def send_chat_history( ) return OperationResult.failed(OperationError(http_error)) - 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 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/tests/tooling/services/test_send_chat_history.py b/tests/tooling/services/test_send_chat_history.py index 741fb2b2..2b4416a8 100644 --- a/tests/tooling/services/test_send_chat_history.py +++ b/tests/tooling/services/test_send_chat_history.py @@ -7,6 +7,7 @@ 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 @@ -16,8 +17,8 @@ class TestSendChatHistory: @pytest.fixture def mock_turn_context(self): - """Create a mock TurnContext.""" - mock_context = Mock() + """Create a mock TurnContext with spec for stricter interface validation.""" + mock_context = Mock(spec=TurnContext) mock_activity = Mock() mock_conversation = Mock() From 971ee1d3c4093888b9df8783c3654147e405e74b Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Mon, 19 Jan 2026 21:53:23 -0800 Subject: [PATCH 19/21] Update model classes to use pydantic instead of `@dataclass`. --- .../tooling/models/chat_history_message.py | 90 +++++----- .../tooling/models/chat_message_request.py | 98 ++++++----- .../microsoft_agents_a365/tooling/py.typed | 0 .../mcp_tool_server_configuration_service.py | 4 +- .../tooling/utils/utility.py | 15 +- .../pyproject.toml | 1 + .../models/test_chat_history_message.py | 154 +++++++++++------- .../models/test_chat_message_request.py | 139 +++++++++++----- .../services/test_send_chat_history.py | 4 +- 9 files changed, 293 insertions(+), 212 deletions(-) create mode 100644 libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/py.typed 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 index 05201861..707a7705 100644 --- 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 @@ -1,65 +1,49 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -""" -Chat History Message model. -""" +"""Chat history message model.""" -from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict +from typing import Literal, Optional +from pydantic import BaseModel, ConfigDict, Field, field_validator -@dataclass -class ChatHistoryMessage: + +class ChatHistoryMessage(BaseModel): """ 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. + This model is used to capture individual messages exchanged between + users and the AI assistant for threat protection analysis and + compliance monitoring. + + Attributes: + id: Optional unique identifier for the message. + role: The role of the message sender (user, assistant, or system). + content: The text content of the message. + timestamp: Optional timestamp when the message was created. + + Example: + >>> message = ChatHistoryMessage(role="user", content="Hello, how can you help?") + >>> print(message.role) + 'user' + >>> print(message.content) + 'Hello, how can you help?' """ - #: 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(), - } + model_config = ConfigDict(populate_by_name=True) + + id: Optional[str] = Field(default=None, description="Unique message identifier") + role: Literal["user", "assistant", "system"] = Field( + ..., description="The role of the message sender" + ) + content: str = Field(..., description="The message content") + timestamp: Optional[datetime] = Field(default=None, description="When the message was created") + + @field_validator("content") + @classmethod + def content_not_empty(cls, v: str) -> str: + """Validate that content is not empty or whitespace-only.""" + if not v or not v.strip(): + raise ValueError("content cannot be empty or whitespace") + return v 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 index 1df8ebd8..ed30e751 100644 --- 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 @@ -1,66 +1,64 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -""" -Chat Message Request model. -""" +"""Chat message request model.""" -from dataclasses import dataclass -from typing import Any, Dict, List +from typing import List -from .chat_history_message import ChatHistoryMessage +from pydantic import BaseModel, ConfigDict, Field, field_validator +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. +class ChatMessageRequest(BaseModel): """ + Request payload for sending chat history to MCP platform. - #: The unique identifier for the conversation. - conversation_id: str + This model represents the complete request body sent to the MCP platform's + chat history endpoint for threat protection analysis. It includes the + current conversation context and historical messages. - #: The unique identifier for the message within the conversation. - message_id: str + The model uses field aliases to serialize to camelCase JSON format + as required by the MCP platform API. - #: The content of the user's message. - user_message: str + Attributes: + conversation_id: Unique identifier for the conversation. + message_id: Unique identifier for the current message. + user_message: The current user message being processed. + chat_history: List of previous messages in the conversation. - #: 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. + Example: + >>> from microsoft_agents_a365.tooling.models import ChatHistoryMessage + >>> request = ChatMessageRequest( + ... conversation_id="conv-123", + ... message_id="msg-456", + ... user_message="What is the weather today?", + ... chat_history=[ + ... ChatHistoryMessage(role="user", content="Hello"), + ... ChatHistoryMessage(role="assistant", content="Hi there!"), + ... ] + ... ) + >>> # Serialize to camelCase JSON + >>> json_dict = request.model_dump(by_alias=True) + >>> print(json_dict["conversationId"]) + 'conv-123' + """ - 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") + model_config = ConfigDict(populate_by_name=True) - def to_dict(self) -> Dict[str, Any]: - """ - Convert the request to a dictionary for JSON serialization. + conversation_id: str = Field( + ..., alias="conversationId", description="Unique conversation identifier" + ) + message_id: str = Field(..., alias="messageId", description="Current message identifier") + user_message: str = Field(..., alias="userMessage", description="The current user message") + chat_history: List[ChatHistoryMessage] = Field( + ..., alias="chatHistory", description="Previous messages in the conversation" + ) - 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], - } + @field_validator("conversation_id", "message_id", "user_message") + @classmethod + def not_empty(cls, v: str) -> str: + """Validate that string fields are not empty or whitespace-only.""" + if not v or not v.strip(): + raise ValueError("Field cannot be empty or whitespace") + return v diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/py.typed b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/py.typed new file mode 100644 index 00000000..e69de29b 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 056c0a05..df1ae5b2 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 @@ -615,8 +615,8 @@ async def send_chat_history( "Content-Type": "application/json", } - # Convert request to JSON - json_data = json.dumps(request.to_dict()) + # Convert request to JSON (using Pydantic's model_dump with aliases for camelCase) + json_data = json.dumps(request.model_dump(by_alias=True, mode="json")) # Send POST request with timeout to prevent indefinite hangs timeout = aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS) 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 28e8f190..c552a100 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 @@ -74,23 +74,24 @@ def _get_mcp_platform_base_url() -> str: Returns: str: The base URL for MCP platform. """ - if os.getenv("MCP_PLATFORM_ENDPOINT") is not None: - return os.getenv("MCP_PLATFORM_ENDPOINT") + endpoint = os.getenv("MCP_PLATFORM_ENDPOINT") + if endpoint is not None: + return endpoint return MCP_PLATFORM_PROD_BASE_URL -def get_mcp_platform_authentication_scope(): +def get_mcp_platform_authentication_scope() -> list[str]: """ Gets the MCP platform authentication scope. Returns: - list: A list containing the appropriate MCP platform authentication scope. + list[str]: A list containing the appropriate MCP platform authentication scope. """ - envScope = os.getenv("MCP_PLATFORM_AUTHENTICATION_SCOPE", "") + env_scope = os.getenv("MCP_PLATFORM_AUTHENTICATION_SCOPE", "") - if envScope: - return [envScope] + if env_scope: + return [env_scope] return [PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE] diff --git a/libraries/microsoft-agents-a365-tooling/pyproject.toml b/libraries/microsoft-agents-a365-tooling/pyproject.toml index be140e04..354480c2 100644 --- a/libraries/microsoft-agents-a365-tooling/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling/pyproject.toml @@ -52,6 +52,7 @@ include-package-data = true [tool.setuptools.package-data] "*" = ["../../LICENSE"] +"microsoft_agents_a365.tooling" = ["py.typed"] [tool.black] line-length = 100 diff --git a/tests/tooling/models/test_chat_history_message.py b/tests/tooling/models/test_chat_history_message.py index 6a71a2a4..641b8f25 100644 --- a/tests/tooling/models/test_chat_history_message.py +++ b/tests/tooling/models/test_chat_history_message.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone import pytest +from pydantic import ValidationError from microsoft_agents_a365.tooling.models import ChatHistoryMessage @@ -16,7 +17,12 @@ 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) + message = ChatHistoryMessage( + id="msg-123", + role="user", + content="Hello, world!", + timestamp=timestamp, + ) # Assert assert message is not None @@ -29,34 +35,32 @@ 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) + message = ChatHistoryMessage( + id="msg-456", + role="assistant", + content="How can I help you?", + timestamp=timestamp, + ) # Act - result = message.to_dict() + result = message.model_dump(mode="json") # 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" + assert result["timestamp"] == "2024-01-15T10:30:00Z" - 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) + def test_chat_history_message_with_optional_id_none(self): + """Test that ChatHistoryMessage allows None for optional id field.""" + # Arrange & Act + message = ChatHistoryMessage( + role="user", + content="Test content", + ) - # Act & Assert - with pytest.raises(ValueError, match="role cannot be empty"): - ChatHistoryMessage("msg-001", "", "Test content", timestamp) + # Assert + assert message.id is None def test_chat_history_message_requires_non_empty_content(self): """Test that ChatHistoryMessage requires a non-empty content.""" @@ -64,20 +68,35 @@ def test_chat_history_message_requires_non_empty_content(self): timestamp = datetime.now(timezone.utc) # Act & Assert - with pytest.raises(ValueError, match="content cannot be empty"): - ChatHistoryMessage("msg-001", "user", "", timestamp) + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatHistoryMessage( + id="msg-001", + role="user", + content="", + timestamp=timestamp, + ) + + def test_chat_history_message_with_optional_timestamp_none(self): + """Test that ChatHistoryMessage allows None for optional timestamp field.""" + # Arrange & Act + message = ChatHistoryMessage( + role="user", + content="Test content", + ) - 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) + # Assert + assert message.timestamp is 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) + message = ChatHistoryMessage( + id="sys-001", + role="system", + content="You are a helpful assistant.", + timestamp=timestamp, + ) # Assert assert message.role == "system" @@ -86,32 +105,19 @@ 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) + message = ChatHistoryMessage( + id="msg-001", + role="user", + content="Test", + timestamp=timestamp, + ) # Act - message_dict = message.to_dict() + message_dict = message.model_dump(mode="json") # 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) + assert "2024-01-15T10:30:45.123" in message_dict["timestamp"] def test_chat_history_message_rejects_whitespace_only_content(self): """Test that ChatHistoryMessage rejects whitespace-only content.""" @@ -119,23 +125,51 @@ def test_chat_history_message_rejects_whitespace_only_content(self): timestamp = datetime.now(timezone.utc) # Act & Assert - with pytest.raises(ValueError, match="content cannot be empty"): - ChatHistoryMessage("msg-1", "user", " ", timestamp) + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatHistoryMessage( + id="msg-1", + role="user", + content=" ", + timestamp=timestamp, + ) - def test_chat_history_message_rejects_tab_only_id(self): - """Test that ChatHistoryMessage rejects tab-only id.""" + 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="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.""" + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatHistoryMessage( + id="msg-1", + role="user", + content="\n\n", + timestamp=timestamp, + ) + + def test_chat_history_message_rejects_invalid_role(self): + """Test that ChatHistoryMessage rejects invalid role values.""" # Arrange timestamp = datetime.now(timezone.utc) # Act & Assert - with pytest.raises(ValueError, match="content cannot be empty"): - ChatHistoryMessage("msg-1", "user", "\n\n", timestamp) + with pytest.raises(ValidationError, match="Input should be 'user', 'assistant' or 'system'"): + ChatHistoryMessage( + id="msg-1", + role="invalid_role", + content="Test content", + timestamp=timestamp, + ) + + def test_chat_history_message_supports_all_valid_roles(self): + """Test that ChatHistoryMessage supports all valid role values.""" + timestamp = datetime.now(timezone.utc) + + for role in ["user", "assistant", "system"]: + message = ChatHistoryMessage( + id=f"msg-{role}", + role=role, + content="Test content", + timestamp=timestamp, + ) + assert message.role == role diff --git a/tests/tooling/models/test_chat_message_request.py b/tests/tooling/models/test_chat_message_request.py index 6b0e7cf7..47c5e9ab 100644 --- a/tests/tooling/models/test_chat_message_request.py +++ b/tests/tooling/models/test_chat_message_request.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone import pytest +from pydantic import ValidationError from microsoft_agents_a365.tooling.models import ChatHistoryMessage, ChatMessageRequest @@ -16,12 +17,17 @@ 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) + message1 = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) + message2 = ChatHistoryMessage(id="msg-2", role="assistant", content="Hi there!", timestamp=timestamp) chat_history = [message1, message2] # Act - request = ChatMessageRequest("conv-123", "msg-456", "How are you?", chat_history) + request = ChatMessageRequest( + conversation_id="conv-123", + message_id="msg-456", + user_message="How are you?", + chat_history=chat_history, + ) # Assert assert request is not None @@ -34,11 +40,16 @@ 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]) + message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) + request = ChatMessageRequest( + conversation_id="conv-123", + message_id="msg-456", + user_message="How are you?", + chat_history=[message], + ) # Act - result = request.to_dict() + result = request.model_dump(by_alias=True) # Assert assert result["conversationId"] == "conv-123" @@ -53,50 +64,77 @@ 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) + message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert - with pytest.raises(ValueError, match="conversation_id cannot be empty"): - ChatMessageRequest("", "msg-456", "How are you?", [message]) + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatMessageRequest( + conversation_id="", + message_id="msg-456", + user_message="How are you?", + chat_history=[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) + message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert - with pytest.raises(ValueError, match="message_id cannot be empty"): - ChatMessageRequest("conv-123", "", "How are you?", [message]) + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatMessageRequest( + conversation_id="conv-123", + message_id="", + user_message="How are you?", + chat_history=[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) + message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert - with pytest.raises(ValueError, match="user_message cannot be empty"): - ChatMessageRequest("conv-123", "msg-456", "", [message]) + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatMessageRequest( + conversation_id="conv-123", + message_id="msg-456", + user_message="", + chat_history=[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?", []) + # Act & Assert - Pydantic accepts empty list but service validates it + # This test verifies the model can be created with an empty list + # (validation of non-empty happens at the service layer) + request = ChatMessageRequest( + conversation_id="conv-123", + message_id="msg-456", + user_message="How are you?", + chat_history=[], + ) + assert request.chat_history == [] 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) + message1 = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) + message2 = ChatHistoryMessage(id="msg-2", role="assistant", content="Hi!", timestamp=timestamp) + message3 = ChatHistoryMessage(id="msg-3", role="user", content="How are you?", timestamp=timestamp) chat_history = [message1, message2, message3] # Act - request = ChatMessageRequest("conv-123", "msg-456", "What can you do?", chat_history) - result = request.to_dict() + request = ChatMessageRequest( + conversation_id="conv-123", + message_id="msg-456", + user_message="What can you do?", + chat_history=chat_history, + ) + result = request.model_dump(by_alias=True) # Assert assert len(result["chatHistory"]) == 3 @@ -108,44 +146,69 @@ 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) + message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert - with pytest.raises(ValueError, match="conversation_id cannot be empty"): - ChatMessageRequest(" ", "msg-456", "How are you?", [message]) + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatMessageRequest( + conversation_id=" ", + message_id="msg-456", + user_message="How are you?", + chat_history=[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) + message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert - with pytest.raises(ValueError, match="message_id cannot be empty"): - ChatMessageRequest("conv-123", " ", "How are you?", [message]) + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatMessageRequest( + conversation_id="conv-123", + message_id=" ", + user_message="How are you?", + chat_history=[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) + message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert - with pytest.raises(ValueError, match="user_message cannot be empty"): - ChatMessageRequest("conv-123", "msg-456", " ", [message]) + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatMessageRequest( + conversation_id="conv-123", + message_id="msg-456", + user_message=" ", + chat_history=[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) + message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert - with pytest.raises(ValueError, match="conversation_id cannot be empty"): - ChatMessageRequest("\t\t", "msg-456", "How are you?", [message]) + with pytest.raises(ValidationError, match="cannot be empty or whitespace"): + ChatMessageRequest( + conversation_id="\t\t", + message_id="msg-456", + user_message="How are you?", + chat_history=[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) + # Act & Assert - Pydantic raises ValidationError for None when List is expected + with pytest.raises(ValidationError): + ChatMessageRequest( + conversation_id="conv-123", + message_id="msg-456", + user_message="How are you?", + chat_history=None, + ) diff --git a/tests/tooling/services/test_send_chat_history.py b/tests/tooling/services/test_send_chat_history.py index 2b4416a8..ef869811 100644 --- a/tests/tooling/services/test_send_chat_history.py +++ b/tests/tooling/services/test_send_chat_history.py @@ -35,8 +35,8 @@ 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), + ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp), + ChatHistoryMessage(id="msg-2", role="assistant", content="Hi there!", timestamp=timestamp), ] @pytest.fixture From 1a7c827c0faa4a9c1ef26a92fe5231adfe6ee377 Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Mon, 19 Jan 2026 22:04:52 -0800 Subject: [PATCH 20/21] chore: add changelog for new chat history API features --- .../microsoft-agents-a365-tooling/CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 libraries/microsoft-agents-a365-tooling/CHANGELOG.md diff --git a/libraries/microsoft-agents-a365-tooling/CHANGELOG.md b/libraries/microsoft-agents-a365-tooling/CHANGELOG.md new file mode 100644 index 00000000..a6a769c7 --- /dev/null +++ b/libraries/microsoft-agents-a365-tooling/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to the `microsoft-agents-a365-tooling` package will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Added `send_chat_history` method to `McpToolServerConfigurationService` for sending chat conversation history to the MCP platform for real-time threat protection analysis +- Added `ChatHistoryMessage` Pydantic model for representing individual messages in chat history +- Added `ChatMessageRequest` Pydantic model for the chat history API request payload +- Added `py.typed` marker for PEP 561 compliance, enabling type checker support From c3c77dcc051c8aaeddb4fdcbdb1bb2c4ce79a031 Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Mon, 19 Jan 2026 22:13:03 -0800 Subject: [PATCH 21/21] style: format ChatHistoryMessage instantiation for improved readability --- tests/tooling/models/test_chat_history_message.py | 4 +++- tests/tooling/models/test_chat_message_request.py | 12 +++++++++--- tests/tooling/services/test_send_chat_history.py | 4 +++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/tooling/models/test_chat_history_message.py b/tests/tooling/models/test_chat_history_message.py index 641b8f25..12578590 100644 --- a/tests/tooling/models/test_chat_history_message.py +++ b/tests/tooling/models/test_chat_history_message.py @@ -153,7 +153,9 @@ def test_chat_history_message_rejects_invalid_role(self): timestamp = datetime.now(timezone.utc) # Act & Assert - with pytest.raises(ValidationError, match="Input should be 'user', 'assistant' or 'system'"): + with pytest.raises( + ValidationError, match="Input should be 'user', 'assistant' or 'system'" + ): ChatHistoryMessage( id="msg-1", role="invalid_role", diff --git a/tests/tooling/models/test_chat_message_request.py b/tests/tooling/models/test_chat_message_request.py index 47c5e9ab..e3cd8375 100644 --- a/tests/tooling/models/test_chat_message_request.py +++ b/tests/tooling/models/test_chat_message_request.py @@ -18,7 +18,9 @@ def test_chat_message_request_can_be_instantiated(self): # Arrange timestamp = datetime.now(timezone.utc) message1 = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) - message2 = ChatHistoryMessage(id="msg-2", role="assistant", content="Hi there!", timestamp=timestamp) + message2 = ChatHistoryMessage( + id="msg-2", role="assistant", content="Hi there!", timestamp=timestamp + ) chat_history = [message1, message2] # Act @@ -123,8 +125,12 @@ def test_chat_message_request_with_multiple_messages(self): # Arrange timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) message1 = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) - message2 = ChatHistoryMessage(id="msg-2", role="assistant", content="Hi!", timestamp=timestamp) - message3 = ChatHistoryMessage(id="msg-3", role="user", content="How are you?", timestamp=timestamp) + message2 = ChatHistoryMessage( + id="msg-2", role="assistant", content="Hi!", timestamp=timestamp + ) + message3 = ChatHistoryMessage( + id="msg-3", role="user", content="How are you?", timestamp=timestamp + ) chat_history = [message1, message2, message3] # Act diff --git a/tests/tooling/services/test_send_chat_history.py b/tests/tooling/services/test_send_chat_history.py index ef869811..67f0e4e3 100644 --- a/tests/tooling/services/test_send_chat_history.py +++ b/tests/tooling/services/test_send_chat_history.py @@ -36,7 +36,9 @@ def chat_history_messages(self): timestamp = datetime.now(timezone.utc) return [ ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp), - ChatHistoryMessage(id="msg-2", role="assistant", content="Hi there!", timestamp=timestamp), + ChatHistoryMessage( + id="msg-2", role="assistant", content="Hi there!", timestamp=timestamp + ), ] @pytest.fixture