diff --git a/docs/prd/azure-ai-foundry-chat-history-api-tasks.md b/docs/prd/azure-ai-foundry-chat-history-api-tasks.md new file mode 100644 index 00000000..e05484ce --- /dev/null +++ b/docs/prd/azure-ai-foundry-chat-history-api-tasks.md @@ -0,0 +1,703 @@ +# Azure AI Foundry Chat History API - Implementation Tasks + +## Document Information + +| Field | Value | +|-------|-------| +| **PRD Reference** | [azure-ai-foundry-chat-history-api.md](azure-ai-foundry-chat-history-api.md) | +| **Target Package** | `microsoft-agents-a365-tooling-extensions-azureaifoundry` | +| **Created** | 2026-01-26 | +| **Status** | Ready for Implementation | + +--- + +## Executive Summary + +This task breakdown covers the implementation of a chat history API for the Azure AI Foundry orchestrator in the Microsoft Agent 365 Python SDK. The feature enables developers to send conversation history from Azure AI Foundry Persistent Agents to the MCP platform for real-time threat protection and compliance monitoring. + +The implementation follows established patterns from the OpenAI and Agent Framework extensions, adapting them for Azure AI Foundry's unique characteristics: thread-based message access via `AgentsClient` and message retrieval from the Azure AI Agents API rather than in-memory sessions. + +The work is organized into three phases: (1) Core implementation of the API methods and helper functions, (2) Comprehensive unit testing following existing test patterns, and (3) Documentation and code quality verification. Each task is scoped for 2-8 hours of work by a junior engineer. + +--- + +## Architecture Impact + +### Packages Affected + +| Package | Impact Type | Description | +|---------|-------------|-------------| +| `microsoft-agents-a365-tooling-extensions-azureaifoundry` | Modified | Add chat history methods to `McpToolRegistrationService` | +| `tests/tooling/extensions/azureaifoundry/` | New | Create test directory structure and unit tests | + +### Key Files + +**Modified Files:** +- `libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py` + +**New Files:** +- `tests/tooling/extensions/azureaifoundry/__init__.py` +- `tests/tooling/extensions/azureaifoundry/services/__init__.py` +- `tests/tooling/extensions/azureaifoundry/services/conftest.py` +- `tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py` + +--- + +## Task Dependency Diagram + +``` +Phase 1: Core Implementation + TASK-001 (Imports & Constants) + | + v + TASK-002 (Content Extraction Helper) + | + v + TASK-003 (Message Conversion Helper) + | + v + TASK-004 (send_chat_history_messages API) + | + v + TASK-005 (send_chat_history API) + +Phase 2: Testing (can start after TASK-003) + TASK-006 (Test Directory Setup) + | + v + TASK-007 (Test Fixtures - conftest.py) + | + +------------------+------------------+ + | | | + v v v + TASK-008 TASK-009 TASK-010 + (Validation (Conversion (Success Path + Tests) Tests) Tests) + | | | + +------------------+------------------+ + | + v + TASK-011 (Error Handling Tests) + +Phase 3: Documentation & Quality + TASK-012 (Docstrings & Examples) + | + v + TASK-013 (Final Quality Checks) +``` + +--- + +## Phase 1: Core Implementation + +### TASK-001: Add Required Imports and Constants + +**Title:** Add imports and orchestrator constant for chat history API + +**Description:** +Add the necessary imports from the Azure AI Agents SDK and internal packages to support the chat history API implementation. This establishes the foundation for subsequent implementation tasks. + +**Acceptance Criteria:** +- [ ] Import `Sequence`, `List`, `Optional` from typing module +- [ ] Import `uuid` for generating message IDs +- [ ] Import `datetime`, `timezone` from datetime module +- [ ] Import `ThreadMessage`, `MessageTextContent` from `azure.ai.agents.models` +- [ ] Import `AgentsClient` from `azure.ai.agents` +- [ ] Import `OperationError`, `OperationResult` from `microsoft_agents_a365.runtime` +- [ ] Import `ChatHistoryMessage` from `microsoft_agents_a365.tooling.models` +- [ ] Verify existing `_orchestrator_name = "AzureAIFoundry"` class constant is present +- [ ] All imports follow the existing import ordering in the file (stdlib, third-party, local) +- [ ] No linting errors when running `uv run --frozen ruff check .` + +**Technical Guidance:** +- **File:** `libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py` +- **Reference:** See imports in OpenAI extension (`libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/mcp_tool_registration_service.py` lines 1-35) +- **Pattern:** Follow existing import grouping: stdlib, Azure SDK, microsoft_agents, microsoft_agents_a365 + +**Estimated Time:** 1-2 hours + +--- + +### TASK-002: Implement Content Extraction Helper Method + +**Title:** Implement `_extract_content_from_message()` private helper method + +**Description:** +Implement a private helper method that extracts text content from a `ThreadMessage` object. Azure AI Foundry messages store content as a list of `MessageContent` items, and text must be extracted from `MessageTextContent` items and concatenated. + +**Acceptance Criteria:** +- [ ] Method signature: `def _extract_content_from_message(self, message: ThreadMessage) -> str` +- [ ] Iterates through `message.content` list +- [ ] For each item that is `MessageTextContent`, extracts `text.value` property +- [ ] Concatenates all text values with space separator +- [ ] Returns empty string if `message.content` is None or empty +- [ ] Returns empty string if no `MessageTextContent` items found +- [ ] Handles `MessageTextContent` items where `text` or `text.value` is None gracefully +- [ ] Method is private (prefixed with underscore) +- [ ] Type hints on parameters and return type +- [ ] Docstring with Args, Returns sections + +**Technical Guidance:** +- **File:** `libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py` +- **Reference .NET:** See Appendix A.2 in PRD for .NET `ExtractContentFromMessage` implementation +- **Reference Python:** See `_extract_content()` in OpenAI extension (lines 541-599) for pattern +- **Pattern:** Use `isinstance()` check to identify `MessageTextContent` items +- **Key insight:** Azure SDK uses `text.value` not `text` directly for the actual text string + +**Code Skeleton:** +```python +def _extract_content_from_message(self, message: ThreadMessage) -> str: + """ + Extract text content from a ThreadMessage's content items. + + Args: + message: Azure AI Foundry ThreadMessage object. + + Returns: + Concatenated text content as string, or empty string if no text found. + """ + if message.content is None or len(message.content) == 0: + return "" + + text_parts: List[str] = [] + + for content_item in message.content: + if isinstance(content_item, MessageTextContent): + # Extract text.value safely + if content_item.text is not None and content_item.text.value: + text_parts.append(content_item.text.value) + + return " ".join(text_parts) +``` + +**Estimated Time:** 2-3 hours + +--- + +### TASK-003: Implement Message Conversion Helper Method + +**Title:** Implement `_convert_thread_messages_to_chat_history()` private helper method + +**Description:** +Implement a private helper method that converts a sequence of Azure AI Foundry `ThreadMessage` objects to a list of `ChatHistoryMessage` objects. This method handles validation, filtering, and transformation of messages. + +**Acceptance Criteria:** +- [ ] Method signature: `def _convert_thread_messages_to_chat_history(self, messages: Sequence[ThreadMessage]) -> List[ChatHistoryMessage]` +- [ ] Filters out messages where `message` is None (logs warning) +- [ ] Filters out messages where `message.id` is None (logs warning with context) +- [ ] Filters out messages where `message.role` is None (logs warning with message ID) +- [ ] Filters out messages where extracted content is empty/whitespace (logs warning with message ID) +- [ ] Converts `message.role` enum to lowercase string ("user", "assistant", "system") +- [ ] Maps `message.id` to `ChatHistoryMessage.id` +- [ ] Maps extracted content to `ChatHistoryMessage.content` +- [ ] Maps `message.created_at` to `ChatHistoryMessage.timestamp` +- [ ] Uses `_extract_content_from_message()` for content extraction +- [ ] Returns empty list if all messages filtered (logs warning) +- [ ] Skips messages without ID (no UUID generated; logs warning) +- [ ] Type hints on parameters and return type +- [ ] Comprehensive docstring + +**Technical Guidance:** +- **File:** `libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py` +- **Reference:** See `_convert_chat_messages_to_history()` in Agent Framework extension (lines 153-212) +- **Reference:** See `_convert_openai_messages_to_chat_history()` in OpenAI extension (lines 427-451) +- **Pattern:** Iterate, validate, convert, filter; use logging for skipped messages +- **Key insight:** Azure SDK role is an enum - use `.value` to get string, then `.lower()` + +**Logging Messages (from PRD Section 5.5):** +- "Skipping null message" +- "Skipping message with null ID" +- "Skipping message with null role (ID: {id})" +- "Skipping message {id} with empty content" +- "All messages were filtered out during conversion" + +**Estimated Time:** 3-4 hours + +--- + +### TASK-004: Implement `send_chat_history_messages()` Public Method + +**Title:** Implement `send_chat_history_messages()` public API method + +**Description:** +Implement the primary public method that accepts a sequence of `ThreadMessage` objects and sends them to the MCP platform. This method handles input validation, message conversion, and delegation to the core service. + +**Acceptance Criteria:** +- [ ] Method is `async` +- [ ] Method signature matches PRD Section 4.1.1: + ```python + async def send_chat_history_messages( + self, + turn_context: TurnContext, + messages: Sequence[ThreadMessage], + tool_options: Optional[ToolOptions] = None, + ) -> OperationResult: + ``` +- [ ] Raises `ValueError` if `turn_context` is None with message "turn_context cannot be None" +- [ ] Raises `ValueError` if `messages` is None with message "messages cannot be None" +- [ ] Creates default `ToolOptions` with `orchestrator_name="AzureAIFoundry"` if not provided +- [ ] Sets orchestrator_name to "AzureAIFoundry" if options provided but orchestrator_name is None +- [ ] Converts messages using `_convert_thread_messages_to_chat_history()` +- [ ] Always delegates to `self._mcp_server_configuration_service.send_chat_history()` even for empty/filtered messages (to register current user message) +- [ ] Catches unexpected exceptions and returns `OperationResult.failed(OperationError(ex))` +- [ ] Re-raises `ValueError` exceptions (validation errors should propagate) +- [ ] Logs entry with message count at INFO level +- [ ] Logs success with message count at INFO level +- [ ] Logs failure with error at ERROR level +- [ ] Comprehensive docstring with example + +**Technical Guidance:** +- **File:** `libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py` +- **Reference:** See `send_chat_history_messages()` in OpenAI extension (lines 332-421) +- **Reference:** See `send_chat_history_messages()` in Agent Framework extension (lines 214-285) +- **Pattern:** Validate -> Convert -> Delegate -> Handle errors +- **Note:** The service already has `_mcp_server_configuration_service` initialized in `__init__` + +**Code Structure:** +```python +async def send_chat_history_messages( + self, + turn_context: TurnContext, + messages: Sequence[ThreadMessage], + tool_options: Optional[ToolOptions] = None, +) -> OperationResult: + # 1. Input validation (raise ValueError) + # 2. Log entry + # 3. Set default options + # 4. Convert messages (may result in empty list if all filtered) + # 5. Try: delegate to core service (always, even if empty) + # 6. Except ValueError: re-raise + # 7. Except Exception: log, return failed +``` + +**Estimated Time:** 3-4 hours + +--- + +### TASK-005: Implement `send_chat_history()` Public Method + +**Title:** Implement `send_chat_history()` public API method with client-based retrieval + +**Description:** +Implement the convenience method that retrieves messages from Azure AI Foundry using an `AgentsClient` and thread ID, then delegates to `send_chat_history_messages()`. This is the primary method most developers will use. + +**Acceptance Criteria:** +- [ ] Method is `async` +- [ ] Method signature matches PRD Section 4.1.2: + ```python + async def send_chat_history( + self, + agents_client: AgentsClient, + thread_id: str, + turn_context: TurnContext, + tool_options: Optional[ToolOptions] = None, + ) -> OperationResult: + ``` +- [ ] Raises `ValueError` if `agents_client` is None with message "agents_client cannot be None" +- [ ] Raises `ValueError` if `thread_id` is None or whitespace with message "thread_id cannot be empty" +- [ ] Raises `ValueError` if `turn_context` is None with message "turn_context cannot be None" +- [ ] Retrieves messages using `agents_client.messages.list(thread_id=thread_id)` +- [ ] Converts async iterable to list using `async for` or `list()` comprehension +- [ ] Logs retrieved message count at INFO level +- [ ] Delegates to `send_chat_history_messages()` with retrieved messages +- [ ] Catches Azure SDK errors and returns `OperationResult.failed(OperationError(ex))` +- [ ] Re-raises `ValueError` exceptions +- [ ] Comprehensive docstring with example + +**Technical Guidance:** +- **File:** `libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py` +- **Reference:** See `send_chat_history_from_store()` in Agent Framework extension (lines 287-331) +- **Reference:** See `send_chat_history()` in OpenAI extension (lines 253-330) +- **Key insight:** Azure SDK returns `AsyncIterable[ThreadMessage]` - must collect to list +- **Pattern:** Validate -> Retrieve -> Delegate + +**Code Structure:** +```python +async def send_chat_history( + self, + agents_client: AgentsClient, + thread_id: str, + turn_context: TurnContext, + tool_options: Optional[ToolOptions] = None, +) -> OperationResult: + # 1. Input validation (raise ValueError) + # 2. Try: retrieve messages from client + # 3. Log retrieved count + # 4. Delegate to send_chat_history_messages + # 5. Except ValueError: re-raise + # 6. Except Exception: log, return failed +``` + +**Message Retrieval Pattern:** +```python +messages: List[ThreadMessage] = [] +async for message in agents_client.messages.list(thread_id=thread_id): + messages.append(message) +self._logger.info(f"Retrieved {len(messages)} messages from thread {thread_id}") +``` + +**Estimated Time:** 2-3 hours + +--- + +## Phase 2: Testing + +### TASK-006: Create Test Directory Structure + +**Title:** Set up test directory structure for Azure AI Foundry extension + +**Description:** +Create the directory structure and `__init__.py` files for the Azure AI Foundry extension tests, following the existing pattern for other extensions. + +**Acceptance Criteria:** +- [ ] Create `tests/tooling/extensions/azureaifoundry/__init__.py` +- [ ] Create `tests/tooling/extensions/azureaifoundry/services/__init__.py` +- [ ] All `__init__.py` files include copyright header +- [ ] Directory structure matches pattern from Agent Framework extension +- [ ] Files can be imported without errors + +**Technical Guidance:** +- **Reference:** See `tests/tooling/extensions/agentframework/` structure +- **Reference:** See `tests/tooling/extensions/openai/` structure + +**File Content Template:** +```python +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +``` + +**Estimated Time:** 30 minutes + +--- + +### TASK-007: Create Test Fixtures (conftest.py) + +**Title:** Implement pytest fixtures for Azure AI Foundry chat history tests + +**Description:** +Create a `conftest.py` file with pytest fixtures that provide mock objects for Azure AI Foundry SDK types. These fixtures will be used across all test files. + +**Acceptance Criteria:** +- [ ] Create `tests/tooling/extensions/azureaifoundry/services/conftest.py` +- [ ] Copyright header present +- [ ] `mock_turn_context` fixture - creates mock `TurnContext` with valid activity +- [ ] `mock_agents_client` fixture - creates mock `AgentsClient` with async `messages.list()` +- [ ] `mock_thread_message` fixture - creates single mock `ThreadMessage` +- [ ] `sample_thread_messages` fixture - creates list of mock `ThreadMessage` objects +- [ ] `mock_message_text_content` fixture - creates mock `MessageTextContent` +- [ ] `mock_role_user`, `mock_role_assistant`, `mock_role_system` fixtures for role enums +- [ ] `service` fixture - creates `McpToolRegistrationService` with mocked core service +- [ ] All fixtures have docstrings +- [ ] Fixtures use `unittest.mock.Mock` and `AsyncMock` appropriately + +**Technical Guidance:** +- **File:** `tests/tooling/extensions/azureaifoundry/services/conftest.py` +- **Reference:** See `tests/tooling/extensions/openai/conftest.py` for patterns +- **Reference:** See `tests/tooling/extensions/agentframework/services/test_send_chat_history.py` for fixture style +- **Key insight:** Azure SDK types need careful mocking of nested attributes + +**Mock ThreadMessage Structure:** +```python +@pytest.fixture +def mock_thread_message(): + """Create a mock ThreadMessage.""" + message = Mock() + message.id = "msg-123" + message.role = Mock() + message.role.value = "user" # Enum-like behavior + message.created_at = datetime.now(timezone.utc) + + # Content is a list of MessageContent items + text_content = Mock(spec=MessageTextContent) + text_content.text = Mock() + text_content.text.value = "Hello, world!" + message.content = [text_content] + + return message +``` + +**Estimated Time:** 2-3 hours + +--- + +### TASK-008: Implement Input Validation Tests + +**Title:** Write unit tests for input validation scenarios + +**Description:** +Implement comprehensive unit tests for input validation in both `send_chat_history_messages()` and `send_chat_history()` methods. + +**Acceptance Criteria:** +- [ ] Create `tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py` +- [ ] Copyright header present +- [ ] All tests marked with `@pytest.mark.asyncio` and `@pytest.mark.unit` +- [ ] Test class: `TestInputValidation` +- [ ] Test: `test_send_chat_history_messages_validates_turn_context_none` +- [ ] Test: `test_send_chat_history_messages_validates_messages_none` +- [ ] Test: `test_send_chat_history_messages_empty_list_still_calls_core_service` +- [ ] Test: `test_send_chat_history_validates_agents_client_none` +- [ ] Test: `test_send_chat_history_validates_thread_id_none` +- [ ] Test: `test_send_chat_history_validates_thread_id_empty` +- [ ] Test: `test_send_chat_history_validates_thread_id_whitespace` +- [ ] Test: `test_send_chat_history_validates_turn_context_none` +- [ ] Each test verifies correct `ValueError` message +- [ ] Tests use fixtures from conftest.py + +**Technical Guidance:** +- **File:** `tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py` +- **Reference:** See `TestInputValidation` in `tests/tooling/extensions/openai/test_send_chat_history.py` +- **Reference:** See validation tests in Agent Framework tests +- **Pattern:** Use `pytest.raises(ValueError, match="expected message")` + +**Estimated Time:** 2-3 hours + +--- + +### TASK-009: Implement Message Conversion Tests + +**Title:** Write unit tests for message conversion logic + +**Description:** +Implement unit tests for the `_convert_thread_messages_to_chat_history()` and `_extract_content_from_message()` helper methods. + +**Acceptance Criteria:** +- [ ] Test class: `TestMessageConversion` +- [ ] Test: `test_extract_content_from_single_text_item` +- [ ] Test: `test_extract_content_from_multiple_text_items` +- [ ] Test: `test_extract_content_handles_empty_content_list` +- [ ] Test: `test_extract_content_handles_none_content` +- [ ] Test: `test_extract_content_handles_none_text_value` +- [ ] Test: `test_convert_messages_extracts_id_correctly` +- [ ] Test: `test_convert_messages_extracts_role_correctly` +- [ ] Test: `test_convert_messages_extracts_timestamp_correctly` +- [ ] Test: `test_convert_messages_filters_null_message` +- [ ] Test: `test_convert_messages_filters_null_id` +- [ ] Test: `test_convert_messages_filters_null_role` +- [ ] Test: `test_convert_messages_filters_empty_content` +- [ ] Test: `test_convert_messages_filters_whitespace_only_content` +- [ ] Test: `test_convert_messages_all_filtered_returns_empty_list` +- [ ] Test: `test_convert_messages_role_enum_to_lowercase` + +**Technical Guidance:** +- **File:** `tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py` +- **Reference:** See `tests/tooling/extensions/openai/test_message_conversion.py` +- **Pattern:** Direct method calls on service instance, verify return values + +**Estimated Time:** 3-4 hours + +--- + +### TASK-010: Implement Success Path Tests + +**Title:** Write unit tests for successful execution paths + +**Description:** +Implement unit tests that verify the happy path scenarios for both API methods, including proper delegation to the core service. + +**Acceptance Criteria:** +- [ ] Test class: `TestSuccessPath` +- [ ] Test: `test_send_chat_history_messages_success` +- [ ] Test: `test_send_chat_history_messages_with_tool_options` +- [ ] Test: `test_send_chat_history_messages_default_orchestrator_name` +- [ ] Test: `test_send_chat_history_messages_delegates_to_core_service` +- [ ] Test: `test_send_chat_history_messages_converts_messages_correctly` +- [ ] Test: `test_send_chat_history_success` +- [ ] Test: `test_send_chat_history_retrieves_from_client` +- [ ] Test: `test_send_chat_history_delegates_to_send_chat_history_messages` +- [ ] Test: `test_send_chat_history_messages_all_filtered_still_calls_core_service` +- [ ] Tests verify correct method calls with `assert_called_once()` +- [ ] Tests verify correct arguments passed to core service + +**Technical Guidance:** +- **File:** `tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py` +- **Reference:** See `TestSuccessPath` in OpenAI extension tests +- **Reference:** See delegation tests in Agent Framework tests +- **Pattern:** Mock core service, verify delegation, check arguments + +**Estimated Time:** 3-4 hours + +--- + +### TASK-011: Implement Error Handling Tests + +**Title:** Write unit tests for error handling scenarios + +**Description:** +Implement unit tests that verify proper error handling for Azure SDK errors, HTTP errors, and unexpected exceptions. + +**Acceptance Criteria:** +- [ ] Test class: `TestErrorHandling` +- [ ] Test: `test_send_chat_history_messages_handles_core_service_failure` +- [ ] Test: `test_send_chat_history_messages_handles_unexpected_exception` +- [ ] Test: `test_send_chat_history_handles_api_error` +- [ ] Test: `test_send_chat_history_handles_connection_error` +- [ ] Test: `test_send_chat_history_handles_timeout` +- [ ] Test: `test_send_chat_history_propagates_validation_error` +- [ ] Tests verify `OperationResult.succeeded is False` for failures +- [ ] Tests verify error details are captured in `OperationResult.errors` +- [ ] Tests verify `ValueError` exceptions propagate (not wrapped) + +**Technical Guidance:** +- **File:** `tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py` +- **Reference:** See `TestErrorHandling` in OpenAI extension tests +- **Pattern:** Use `side_effect` to simulate exceptions, verify result state + +**Estimated Time:** 2-3 hours + +--- + +## Phase 3: Documentation and Quality + +### TASK-012: Add Comprehensive Docstrings and Examples + +**Title:** Enhance docstrings with examples and complete documentation + +**Description:** +Review and enhance all docstrings for the new methods to include comprehensive documentation with usage examples, following Google-style docstring format. + +**Acceptance Criteria:** +- [ ] All public methods have complete docstrings +- [ ] Docstrings include: one-line summary, detailed description, Args, Returns, Raises, Example +- [ ] Example code is syntactically correct and runnable +- [ ] Private helper methods have Args and Returns documented +- [ ] Examples show realistic usage patterns with Azure AI Foundry +- [ ] Examples match patterns shown in PRD Section 7.1 + +**Technical Guidance:** +- **File:** `libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py` +- **Reference:** See docstrings in OpenAI extension methods +- **Reference:** See PRD Section 7.1 for example code + +**Example Docstring Pattern:** +```python +async def send_chat_history_messages( + self, + turn_context: TurnContext, + messages: Sequence[ThreadMessage], + tool_options: Optional[ToolOptions] = None, +) -> OperationResult: + """ + Send Azure AI Foundry chat history messages to the MCP platform. + + This method accepts a sequence of Azure AI Foundry ThreadMessage objects, + converts them to ChatHistoryMessage format, and sends them to the MCP + platform for real-time threat protection. + + Args: + turn_context: TurnContext from the Agents SDK containing conversation info. + messages: Sequence of Azure AI Foundry ThreadMessage objects to send. + tool_options: Optional configuration for the request. + + Returns: + OperationResult indicating success or failure. + + Raises: + ValueError: If turn_context or messages is None. + + Example: + >>> service = McpToolRegistrationService() + >>> messages = await agents_client.messages.list(thread_id=thread_id) + >>> result = await service.send_chat_history_messages( + ... turn_context, list(messages) + ... ) + >>> if result.succeeded: + ... print("Chat history sent successfully") + """ +``` + +**Estimated Time:** 2 hours + +--- + +### TASK-013: Final Quality Checks and PR Preparation + +**Title:** Run quality checks and prepare for code review + +**Description:** +Execute all quality checks, verify test coverage, and ensure code meets SDK standards before creating a pull request. + +**Acceptance Criteria:** +- [ ] Run `uv run --frozen ruff check .` - no errors +- [ ] Run `uv run --frozen ruff format --check .` - no formatting issues +- [ ] Run `uv run --frozen pytest tests/tooling/extensions/azureaifoundry/ -v` - all tests pass +- [ ] Run `uv run --frozen pytest tests/tooling/extensions/azureaifoundry/ --cov --cov-report=term-missing` - coverage >= 90% +- [ ] Verify copyright headers on all new/modified files +- [ ] Verify no usage of `typing.Any` in code +- [ ] Verify no usage of forbidden keyword "Kairo" +- [ ] Verify type hints on all function parameters and return types +- [ ] Verify async method names do not use `_async` suffix +- [ ] Run full test suite: `uv run --frozen pytest tests/ -v --tb=short -m "not integration"` - no regressions +- [ ] Prepare PR description with summary and test plan + +**Technical Guidance:** +- **Reference:** See CLAUDE.md Code Standards section +- **Reference:** See CI workflow in `.github/workflows/ci.yml` +- **Coverage target:** >= 90% as per PRD Section 10.2 + +**Estimated Time:** 2-3 hours + +--- + +## Testing Strategy + +### Test Categories + +| Category | Test Count | Priority | +|----------|------------|----------| +| Input Validation | ~8 tests | P0 | +| Message Conversion | ~15 tests | P0 | +| Success Path | ~10 tests | P0 | +| Error Handling | ~6 tests | P1 | + +### Mock Strategy + +All tests use `unittest.mock` to mock: +- Azure AI Foundry SDK types (`AgentsClient`, `ThreadMessage`, `MessageTextContent`) +- Core service (`McpToolServerConfigurationService`) +- `TurnContext` from Microsoft Agents SDK + +### Coverage Requirements + +- Minimum 90% line coverage for new code +- All public methods must have tests for: + - Valid inputs (success path) + - Invalid inputs (validation errors) + - Edge cases (empty lists, all filtered) + - Error conditions (exceptions) + +--- + +## Risks and Considerations + +### Technical Risks + +| Risk | Mitigation | +|------|------------| +| Azure SDK beta API changes | Pin minimum versions, use defensive coding for optional attributes | +| Async iteration differences | Test both sync and async collection patterns | +| Content structure variations | Handle unknown content types gracefully, log warnings | + +### Implementation Notes + +1. **Type Hints:** Never use `typing.Any`. Import actual types from Azure SDK or use `object` for truly unknown types. + +2. **Async Pattern:** The Azure SDK's `messages.list()` returns an async iterable. Collect to list before processing. + +3. **Role Enum:** Azure SDK role is an enum object with `.value` attribute, not a plain string. + +4. **Content Structure:** Messages can have multiple content items of different types. Only process `MessageTextContent`. + +5. **Naming:** The Azure Python SDK uses `AgentsClient` and `ThreadMessage` (not `PersistentAgentsClient` and `PersistentThreadMessage` as in .NET). + +--- + +## Summary + +| Phase | Tasks | Estimated Total Time | +|-------|-------|---------------------| +| Phase 1: Core Implementation | TASK-001 to TASK-005 | 11-16 hours | +| Phase 2: Testing | TASK-006 to TASK-011 | 13-17 hours | +| Phase 3: Documentation & Quality | TASK-012 to TASK-013 | 4-5 hours | +| **Total** | **13 tasks** | **28-38 hours** | + +All tasks are designed to be completed by a junior engineer in 2-8 hours each, with clear acceptance criteria and technical guidance. Tasks should be completed in the order specified due to dependencies between them. diff --git a/docs/prd/azure-ai-foundry-chat-history-api.md b/docs/prd/azure-ai-foundry-chat-history-api.md new file mode 100644 index 00000000..17df7ab0 --- /dev/null +++ b/docs/prd/azure-ai-foundry-chat-history-api.md @@ -0,0 +1,878 @@ +# PRD: Azure AI Foundry Chat History API + +## Document Information + +| Field | Value | +|-------|-------| +| **Feature Name** | Send Chat History API for Azure AI Foundry Orchestrator | +| **Package** | `microsoft-agents-a365-tooling-extensions-azureaifoundry` | +| **Version** | 0.3.0 | +| **Author** | Agent365 SDK Team | +| **Status** | Draft | +| **Created** | 2026-01-26 | +| **Last Updated** | 2026-01-26 | +| **Reference PR** | [.NET SDK PR #175](https://github.com/microsoft/Agent365-dotnet/pull/175) | + +--- + +## 1. Overview + +### 1.1 Feature Summary + +This PRD describes the implementation of a chat history sending API specific to the Azure AI Foundry orchestrator in the Microsoft Agent 365 Python SDK. The feature enables developers to send conversation history from Azure AI Foundry Persistent Agents to the MCP (Model Context Protocol) platform for real-time threat protection and compliance monitoring. + +### 1.2 Business Justification + +- **Parity with .NET SDK**: The .NET SDK already implements this feature (PR #175), and Python SDK customers expect equivalent functionality +- **Security Compliance**: Enables real-time threat protection by forwarding chat history to the MCP platform +- **Developer Experience**: Provides a consistent API pattern across all orchestrators (OpenAI, Agent Framework, Azure AI Foundry) +- **Enterprise Readiness**: Supports enterprise security requirements for AI agent deployments in Microsoft 365 environments + +### 1.3 Key Differentiator from Other Orchestrators + +Unlike the OpenAI and Agent Framework chat history APIs where messages are passed directly to the method, the Azure AI Foundry implementation requires: + +1. **Message Retrieval**: Messages must be retrieved from the Azure AI Foundry Persistent Agents API using a `PersistentAgentsClient` +2. **Thread-Based Access**: Messages are accessed via thread IDs rather than in-memory sessions +3. **Azure SDK Integration**: Leverages `azure-ai-agents` package for message retrieval + +--- + +## 2. Objectives + +### 2.1 Primary Objectives + +| Objective | Success Criteria | Priority | +|-----------|------------------|----------| +| Implement `send_chat_history_messages()` | Method accepts Azure AI Foundry message types and sends to MCP platform | P0 | +| Implement `send_chat_history()` | Method retrieves messages from `PersistentAgentsClient` and sends to MCP platform | P0 | +| Message Conversion | Correctly convert `ThreadMessage` to `ChatHistoryMessage` format | P0 | +| API Consistency | Match patterns used in OpenAI and Agent Framework extensions | P1 | +| Full Test Coverage | Unit test coverage >= 90% | P1 | + +### 2.2 Non-Goals + +- Modifying the core `McpToolServerConfigurationService` class +- Adding new MCP platform endpoints +- Supporting non-persistent agent types in Azure AI Foundry +- Implementing message caching or batching + +--- + +## 3. User Stories + +### 3.1 Primary User Persona: Enterprise AI Developer + +**As an** enterprise developer building AI agents with Azure AI Foundry, +**I want to** send my agent's conversation history to the MCP platform, +**So that** I can leverage real-time threat protection and ensure compliance with security policies. + +### 3.2 User Story 1: Direct Message Submission + +```gherkin +Given I have a list of PersistentThreadMessage objects from Azure AI Foundry +When I call send_chat_history_messages() with the messages and turn context +Then the messages are converted and sent to the MCP platform +And I receive an OperationResult indicating success or failure +``` + +### 3.3 User Story 2: Client-Based Message Retrieval + +```gherkin +Given I have a PersistentAgentsClient and a thread ID +When I call send_chat_history() with the client, thread ID, and turn context +Then the method retrieves all messages from the thread +And converts them to ChatHistoryMessage format +And sends them to the MCP platform +And I receive an OperationResult indicating success or failure +``` + +### 3.4 User Story 3: Error Handling + +```gherkin +Given I call send_chat_history() with an invalid thread ID +When the Azure AI Foundry API returns an error +Then I receive an OperationResult with failure status +And the error details are captured in the result +And no exception is thrown to the caller +``` + +--- + +## 4. Functional Requirements + +### 4.1 API Methods + +The following methods SHALL be added to the `McpToolRegistrationService` class in the Azure AI Foundry extension package: + +#### 4.1.1 `send_chat_history_messages()` + +Sends chat history messages directly to the MCP platform. Messages are provided by the caller. + +**Signature:** +```python +async def send_chat_history_messages( + self, + turn_context: TurnContext, + messages: Sequence[ThreadMessage], + tool_options: Optional[ToolOptions] = None, +) -> OperationResult: + """ + Send Azure AI Foundry chat history messages to the MCP platform. + + Args: + turn_context: TurnContext from the Agents SDK containing conversation info. + messages: Sequence of Azure AI Foundry ThreadMessage objects to send. + tool_options: Optional configuration for the request. + + Returns: + OperationResult indicating success or failure. + + Raises: + ValueError: If turn_context or messages is None. + """ +``` + +#### 4.1.2 `send_chat_history()` + +Retrieves messages from Azure AI Foundry and sends them to the MCP platform. + +**Signature:** +```python +async def send_chat_history( + self, + agents_client: AgentsClient, + thread_id: str, + turn_context: TurnContext, + tool_options: Optional[ToolOptions] = None, +) -> OperationResult: + """ + Retrieve and send chat history from Azure AI Foundry to the MCP platform. + + Args: + agents_client: The Azure AI Foundry AgentsClient instance. + thread_id: The thread ID containing the messages to send. + turn_context: TurnContext from the Agents SDK containing conversation info. + tool_options: Optional configuration for the request. + + Returns: + OperationResult indicating success or failure. + + Raises: + ValueError: If agents_client, thread_id, or turn_context is None/empty. + """ +``` + +### 4.2 Input Validation Requirements + +| Parameter | Validation Rule | Error Type | +|-----------|-----------------|------------| +| `turn_context` | Must not be None | `ValueError` | +| `messages` | Must not be None (empty list allowed) | `ValueError` | +| `agents_client` | Must not be None | `ValueError` | +| `thread_id` | Must not be None or whitespace | `ValueError` | +| `tool_options` | None allowed (uses defaults) | N/A | + +### 4.3 Message Conversion Requirements + +The following fields SHALL be mapped from Azure AI Foundry `ThreadMessage` to `ChatHistoryMessage`: + +| Source (ThreadMessage) | Target (ChatHistoryMessage) | Transformation | +|------------------------|----------------------------|----------------| +| `id` | `id` | Direct copy (skip if None) | +| `role` | `role` | Convert enum to lowercase string ("user", "assistant", "system") | +| `content` | `content` | Extract text from `MessageContent` items | +| `created_at` | `timestamp` | Direct copy (DateTimeOffset to datetime) | + +**Content Extraction Logic:** +- Iterate through `content` list +- For each `MessageTextContent` item, extract the `text.value` property +- Concatenate all text values with space separator +- Skip messages with empty/whitespace-only content (log warning) + +### 4.4 Message Filtering Requirements + +Messages SHALL be filtered (skipped with warning log) if: +- Message is None +- Message `id` is None +- Message `role` is None +- Extracted content is empty or whitespace-only + +### 4.5 Default Behavior + +| Aspect | Default Value | +|--------|---------------| +| Orchestrator Name | `"AzureAIFoundry"` | +| Empty message list | Still send request to MCP platform (registers current user message) | +| All messages filtered | Still send request to MCP platform with empty chat history | + +--- + +## 5. Technical Requirements + +### 5.1 Architecture + +```mermaid +sequenceDiagram + participant Dev as Developer + participant Svc as McpToolRegistrationService
(Azure AI Foundry) + participant AIF as Azure AI Foundry
AgentsClient + participant Core as McpToolServerConfigurationService + participant MCP as MCP Platform + + Note over Dev,MCP: Option 1: Direct Message Submission + Dev->>Svc: send_chat_history_messages(turn_context, messages) + Svc->>Svc: Validate inputs + Svc->>Svc: Convert ThreadMessage[] to ChatHistoryMessage[] + Svc->>Core: send_chat_history(turn_context, chat_messages, options) + Core->>MCP: POST /chathistory + MCP-->>Core: 200 OK + Core-->>Svc: OperationResult.success() + Svc-->>Dev: OperationResult.success() + + Note over Dev,MCP: Option 2: Client-Based Retrieval + Dev->>Svc: send_chat_history(client, thread_id, turn_context) + Svc->>Svc: Validate inputs + Svc->>AIF: messages.list(thread_id) + AIF-->>Svc: AsyncIterable[ThreadMessage] + Svc->>Svc: Collect messages to list + Svc->>Svc: Delegate to send_chat_history_messages() + Svc->>Core: send_chat_history(...) + Core->>MCP: POST /chathistory + MCP-->>Core: 200 OK + Core-->>Svc: OperationResult.success() + Svc-->>Dev: OperationResult.success() +``` + +### 5.2 Package Dependencies + +The following dependencies are required (already present in `pyproject.toml`): + +```toml +dependencies = [ + "microsoft-agents-a365-tooling >= 0.0.0", + "azure-ai-projects >= 2.0.0b1", + "azure-ai-agents >= 1.0.0b251001", + "azure-identity >= 1.12.0", +] +``` + +### 5.3 Azure AI Foundry SDK Types + +The implementation SHALL use the following types from the Azure AI Agents SDK: + +| Type | Package | Usage | +|------|---------|-------| +| `AgentsClient` | `azure.ai.agents` | Client for Azure AI Foundry Agents API | +| `ThreadMessage` | `azure.ai.agents.models` | Message retrieved from a thread | +| `MessageTextContent` | `azure.ai.agents.models` | Text content within a message | +| `MessageRole` | `azure.ai.agents.models` | Enum for message roles | + +**Note:** The Python SDK uses `AgentsClient` and `ThreadMessage` (not `PersistentAgentsClient` and `PersistentThreadMessage` as in .NET). This is due to differences in the Azure AI SDK naming between Python and .NET. + +### 5.4 Error Handling Strategy + +| Error Type | Handling | Result | +|------------|----------|--------| +| `ValueError` (validation) | Re-raise to caller | Exception propagated | +| Azure SDK errors | Catch, log, wrap in `OperationError` | `OperationResult.failed()` | +| HTTP errors from MCP | Delegated to core service | `OperationResult.failed()` | +| Unexpected exceptions | Catch, log, wrap in `OperationError` | `OperationResult.failed()` | + +### 5.5 Logging Requirements + +| Event | Log Level | Message Template | +|-------|-----------|------------------| +| Method entry | INFO | "Sending {count} Azure AI Foundry messages as chat history" | +| Message retrieval | INFO | "Retrieved {count} messages from thread {thread_id}" | +| Message skipped (null) | WARNING | "Skipping null message" | +| Message skipped (null ID) | WARNING | "Skipping message with null ID" | +| Message skipped (null role) | WARNING | "Skipping message with null role (ID: {id})" | +| Message skipped (empty content) | WARNING | "Skipping message {id} with empty content" | +| All messages filtered | WARNING | "All messages were filtered out during conversion" | +| Success | INFO | "Chat history sent successfully with {count} messages" | +| Failure | ERROR | "Failed to send chat history: {error}" | + +--- + +## 6. Package Impact Analysis + +### 6.1 Modified Files + +| File | Change Type | Description | +|------|-------------|-------------| +| `libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py` | Modified | Add new methods | +| `libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/__init__.py` | No Change | Exports unchanged (class already exported) | + +### 6.2 New Files + +| File | Description | +|------|-------------| +| `tests/tooling/extensions/azureaifoundry/__init__.py` | Test package init | +| `tests/tooling/extensions/azureaifoundry/services/__init__.py` | Test services subpackage init | +| `tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py` | Unit tests for chat history API | + +### 6.3 Version Impact + +| Package | Current Version | New Version | Reason | +|---------|-----------------|-------------|--------| +| `microsoft-agents-a365-tooling-extensions-azureaifoundry` | 0.2.1 | 0.3.0 | New feature (minor version bump) | + +--- + +## 7. API Design + +### 7.1 Class Structure + +```python +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +MCP Tool Registration Service implementation for Azure AI Foundry. +""" + +import logging +import uuid +from datetime import datetime, timezone +from typing import List, Optional, Sequence + +from azure.ai.agents import AgentsClient +from azure.ai.agents.models import ThreadMessage, MessageTextContent +from microsoft_agents.hosting.core import TurnContext + +from microsoft_agents_a365.runtime import OperationError, OperationResult +from microsoft_agents_a365.tooling.models import ChatHistoryMessage, ToolOptions +from microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service import ( + McpToolServerConfigurationService, +) + + +class McpToolRegistrationService: + """ + Provides MCP tool registration services for Azure AI Foundry agents. + """ + + _orchestrator_name: str = "AzureAIFoundry" + + # ... existing methods ... + + # -------------------------------------------------------------------------- + # SEND CHAT HISTORY - Azure AI Foundry-specific implementations + # -------------------------------------------------------------------------- + + async def send_chat_history_messages( + self, + turn_context: TurnContext, + messages: Sequence[ThreadMessage], + tool_options: Optional[ToolOptions] = None, + ) -> OperationResult: + """ + Send Azure AI Foundry chat history messages to the MCP platform. + + This method accepts a sequence of Azure AI Foundry ThreadMessage objects, + converts them to ChatHistoryMessage format, and sends them to the MCP + platform for real-time threat protection. + + Args: + turn_context: TurnContext from the Agents SDK containing conversation info. + messages: Sequence of Azure AI Foundry ThreadMessage objects to send. + tool_options: Optional configuration for the request. + + Returns: + OperationResult indicating success or failure. + + Raises: + ValueError: If turn_context or messages is None. + + Example: + >>> service = McpToolRegistrationService() + >>> messages = await agents_client.messages.list(thread_id=thread_id) + >>> result = await service.send_chat_history_messages( + ... turn_context, list(messages) + ... ) + >>> if result.succeeded: + ... print("Chat history sent successfully") + """ + ... + + async def send_chat_history( + self, + agents_client: AgentsClient, + thread_id: str, + turn_context: TurnContext, + tool_options: Optional[ToolOptions] = None, + ) -> OperationResult: + """ + Retrieve and send chat history from Azure AI Foundry to the MCP platform. + + This method retrieves messages from the Azure AI Foundry Agents API using + the provided client and thread ID, converts them to ChatHistoryMessage + format, and sends them to the MCP platform. + + Args: + agents_client: The Azure AI Foundry AgentsClient instance. + thread_id: The thread ID containing the messages to send. + turn_context: TurnContext from the Agents SDK containing conversation info. + tool_options: Optional configuration for the request. + + Returns: + OperationResult indicating success or failure. + + Raises: + ValueError: If agents_client, thread_id, or turn_context is None/empty. + + Example: + >>> from azure.ai.agents import AgentsClient + >>> from azure.identity import DefaultAzureCredential + >>> + >>> client = AgentsClient(endpoint, credential=DefaultAzureCredential()) + >>> service = McpToolRegistrationService() + >>> result = await service.send_chat_history( + ... client, thread_id, turn_context + ... ) + """ + ... + + # -------------------------------------------------------------------------- + # PRIVATE HELPER METHODS - Message Conversion + # -------------------------------------------------------------------------- + + def _convert_thread_messages_to_chat_history( + self, + messages: Sequence[ThreadMessage], + ) -> List[ChatHistoryMessage]: + """ + Convert Azure AI Foundry ThreadMessage objects to ChatHistoryMessage format. + """ + ... + + def _extract_content_from_message(self, message: ThreadMessage) -> str: + """ + Extract text content from a ThreadMessage's content items. + """ + ... +``` + +### 7.2 Data Flow + +```mermaid +flowchart TD + A[ThreadMessage] --> B{Validate} + B -->|id is None| C[Skip with warning] + B -->|role is None| C + B -->|Valid| D[Extract Content] + D --> E{Content Empty?} + E -->|Yes| C + E -->|No| F[Create ChatHistoryMessage] + F --> G[id: message.id] + F --> H[role: message.role.value.lower] + F --> I[content: extracted text] + F --> J[timestamp: message.created_at] + G & H & I & J --> K[ChatHistoryMessage] + K --> L[Send to Core Service] +``` + +--- + +## 8. Observability + +### 8.1 Tracing + +The implementation SHALL NOT add new spans. All tracing is handled by the core `McpToolServerConfigurationService.send_chat_history()` method. + +### 8.2 Metrics + +No new metrics are required. Existing metrics from the core service apply. + +### 8.3 Logging + +See Section 5.5 for logging requirements. + +--- + +## 9. Testing Strategy + +### 9.1 Unit Test Categories + +| Category | Test Count | Description | +|----------|------------|-------------| +| Input Validation | 6 | Validate None checks and empty inputs | +| Message Conversion | 8 | Test ThreadMessage to ChatHistoryMessage conversion | +| Content Extraction | 5 | Test extraction from various content types | +| Success Path | 5 | Test successful operations | +| Error Handling | 6 | Test error scenarios | +| Integration Delegation | 3 | Test delegation to core service | + +### 9.2 Test Structure + +``` +tests/tooling/extensions/azureaifoundry/ + __init__.py + services/ + __init__.py + test_send_chat_history.py + conftest.py # Fixtures for mock objects +``` + +### 9.3 Key Test Cases + +#### 9.3.1 Input Validation Tests + +```python +@pytest.mark.asyncio +@pytest.mark.unit +async def test_send_chat_history_messages_validates_turn_context_none(service): + """Test that ValueError is raised when turn_context is None.""" + with pytest.raises(ValueError, match="turn_context cannot be None"): + await service.send_chat_history_messages(None, []) + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_send_chat_history_messages_validates_messages_none(service, mock_turn_context): + """Test that ValueError is raised when messages is None.""" + with pytest.raises(ValueError, match="messages cannot be None"): + await service.send_chat_history_messages(mock_turn_context, None) + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_send_chat_history_validates_client_none(service, mock_turn_context): + """Test that ValueError is raised when agents_client is None.""" + with pytest.raises(ValueError, match="agents_client cannot be None"): + await service.send_chat_history(None, "thread-123", mock_turn_context) + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_send_chat_history_validates_thread_id_empty( + service, mock_agents_client, mock_turn_context +): + """Test that ValueError is raised when thread_id is empty.""" + with pytest.raises(ValueError, match="thread_id cannot be empty"): + await service.send_chat_history(mock_agents_client, " ", mock_turn_context) +``` + +#### 9.3.2 Message Conversion Tests + +```python +@pytest.mark.asyncio +@pytest.mark.unit +async def test_convert_thread_message_extracts_text_content(service): + """Test that text content is correctly extracted from ThreadMessage.""" + # Arrange + message = create_mock_thread_message( + id="msg-1", + role="user", + content=[MockMessageTextContent(text="Hello, world!")], + created_at=datetime.now(timezone.utc) + ) + + # Act + result = service._convert_thread_messages_to_chat_history([message]) + + # Assert + assert len(result) == 1 + assert result[0].content == "Hello, world!" + assert result[0].role == "user" + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_convert_thread_message_concatenates_multiple_content_items(service): + """Test that multiple content items are concatenated.""" + # Arrange + message = create_mock_thread_message( + id="msg-1", + role="assistant", + content=[ + MockMessageTextContent(text="Part 1"), + MockMessageTextContent(text="Part 2"), + ], + created_at=datetime.now(timezone.utc) + ) + + # Act + result = service._convert_thread_messages_to_chat_history([message]) + + # Assert + assert result[0].content == "Part 1 Part 2" +``` + +#### 9.3.3 Error Handling Tests + +```python +@pytest.mark.asyncio +@pytest.mark.unit +async def test_send_chat_history_handles_api_error( + service, mock_agents_client, mock_turn_context +): + """Test that Azure API errors are caught and returned as OperationResult.failed().""" + # Arrange + mock_agents_client.messages.list.side_effect = Exception("API Error") + + # Act + result = await service.send_chat_history( + mock_agents_client, "thread-123", mock_turn_context + ) + + # Assert + assert result.succeeded is False + assert len(result.errors) == 1 + assert "API Error" in str(result.errors[0].message) +``` + +### 9.4 Mock Strategy + +Use `unittest.mock` to mock Azure AI Foundry SDK types: + +```python +@pytest.fixture +def mock_thread_message(): + """Create a mock ThreadMessage.""" + message = Mock() + message.id = "msg-123" + message.role = Mock() + message.role.value = "user" + message.created_at = datetime.now(timezone.utc) + message.content = [Mock(spec=MessageTextContent)] + message.content[0].text = Mock() + message.content[0].text.value = "Test message" + return message + +@pytest.fixture +def mock_agents_client(): + """Create a mock AgentsClient.""" + client = Mock(spec=AgentsClient) + client.messages = Mock() + client.messages.list = AsyncMock(return_value=[]) + return client +``` + +--- + +## 10. Acceptance Criteria + +### 10.1 Functional Acceptance Criteria + +- [ ] `send_chat_history_messages()` accepts `Sequence[ThreadMessage]` and returns `OperationResult` +- [ ] `send_chat_history()` retrieves messages via `AgentsClient` and sends them +- [ ] Messages with null ID, role, or empty content are filtered with warning logs +- [ ] Empty message lists still call MCP platform (to register current user message) +- [ ] Default orchestrator name is "AzureAIFoundry" +- [ ] `ToolOptions` parameter is optional with sensible defaults +- [ ] All validation errors raise `ValueError` with descriptive messages + +### 10.2 Quality Acceptance Criteria + +- [ ] Unit test coverage >= 90% +- [ ] All tests pass on Python 3.11 and 3.12 +- [ ] Ruff linting passes with no errors +- [ ] Type hints present on all public methods +- [ ] Docstrings follow Google style with examples +- [ ] Copyright header present on all new files + +### 10.3 Documentation Acceptance Criteria + +- [ ] README.md updated with chat history API examples +- [ ] Docstrings include usage examples +- [ ] API matches patterns in OpenAI and Agent Framework extensions + +--- + +## 11. Non-Functional Requirements + +### 11.1 Performance + +| Metric | Requirement | +|--------|-------------| +| Message conversion | < 1ms per message | +| API call timeout | Inherit from core service (30s default) | +| Memory | No accumulation of messages beyond request scope | + +### 11.2 Security + +| Requirement | Implementation | +|-------------|----------------| +| No credential logging | Log message IDs only, never content | +| Token handling | Delegated to core service | +| Input sanitization | Validate all inputs before processing | + +### 11.3 Compatibility + +| Python Version | Support Level | +|----------------|---------------| +| 3.11 | Full support (primary) | +| 3.12 | Full support (tested in CI) | +| 3.10 and earlier | Not supported | + +--- + +## 12. Dependencies + +### 12.1 Internal Dependencies + +| Package | Version | Purpose | +|---------|---------|---------| +| `microsoft-agents-a365-tooling` | >= 0.0.0 | Core tooling service | +| `microsoft-agents-a365-runtime` | >= 0.0.0 | OperationResult, OperationError | + +### 12.2 External Dependencies + +| Package | Version | Purpose | +|---------|---------|---------| +| `azure-ai-agents` | >= 1.0.0b251001 | Azure AI Foundry Agents SDK | +| `azure-ai-projects` | >= 2.0.0b1 | Azure AI Projects client | +| `azure-identity` | >= 1.12.0 | Azure authentication | + +--- + +## 13. Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| Azure SDK breaking changes (beta) | Medium | High | Pin minimum versions, monitor Azure SDK releases | +| Message format changes | Low | Medium | Defensive content extraction, handle unknown content types | +| Large message volumes | Medium | Low | Pagination handled by Azure SDK, no in-memory accumulation beyond single request | +| API differences between .NET and Python SDKs | Low | Medium | Use Python SDK types (`AgentsClient` vs `PersistentAgentsClient`) | + +--- + +## 14. Open Questions + +| Question | Owner | Status | Decision | +|----------|-------|--------|----------| +| Should we support message pagination/limits for `send_chat_history()`? | SDK Team | Open | TBD - Consider adding `limit` parameter in future iteration | +| Should we expose message filtering callback for custom filtering logic? | SDK Team | Open | TBD - Consider for v2 based on customer feedback | +| Should cancellation tokens be supported (like .NET)? | SDK Team | Closed | No - Python async uses different patterns; methods are already async and can be cancelled via task cancellation | + +--- + +## 15. Implementation Checklist + +### Phase 1: Core Implementation + +- [ ] Add `send_chat_history_messages()` method to `McpToolRegistrationService` +- [ ] Add `send_chat_history()` method to `McpToolRegistrationService` +- [ ] Implement `_convert_thread_messages_to_chat_history()` helper +- [ ] Implement `_extract_content_from_message()` helper +- [ ] Add comprehensive logging + +### Phase 2: Testing + +- [ ] Create test directory structure +- [ ] Implement mock fixtures for Azure SDK types +- [ ] Write input validation tests +- [ ] Write message conversion tests +- [ ] Write success path tests +- [ ] Write error handling tests +- [ ] Verify test coverage >= 90% + +### Phase 3: Documentation & Review + +- [ ] Update package README.md +- [ ] Add docstrings with examples +- [ ] Run linting and type checking +- [ ] Create PR for review + +--- + +## Appendix A: Reference Implementation (.NET) + +The following code snippets from the .NET SDK PR #175 provide reference for the Python implementation: + +### A.1 Interface Definition (.NET) + +```csharp +Task SendChatHistoryAsync( + ITurnContext turnContext, + PersistentThreadMessage[] messages, + CancellationToken cancellationToken = default); + +Task SendChatHistoryAsync( + PersistentAgentsClient agentClient, + string threadId, + ITurnContext turnContext, + CancellationToken cancellationToken = default); +``` + +### A.2 Content Extraction (.NET) + +```csharp +private string ExtractContentFromMessage(PersistentThreadMessage message) +{ + if (message.ContentItems == null || message.ContentItems.Count == 0) + { + return string.Empty; + } + + var textContent = new System.Text.StringBuilder(); + + foreach (var textContentItem in message.ContentItems.OfType()) + { + if (!string.IsNullOrEmpty(textContentItem.Text)) + { + if (textContent.Length > 0) + { + textContent.Append(" "); + } + textContent.Append(textContentItem.Text); + } + } + + return textContent.ToString(); +} +``` + +--- + +## Appendix B: Existing Python Implementations + +### B.1 OpenAI Extension Pattern + +```python +# From microsoft-agents-a365-tooling-extensions-openai +async def send_chat_history_messages( + self, + turn_context: TurnContext, + messages: List[TResponseInputItem], + options: Optional[ToolOptions] = None, +) -> OperationResult: + # Validate inputs + if turn_context is None: + raise ValueError("turn_context cannot be None") + if messages is None: + raise ValueError("messages cannot be None") + + # Handle empty list as no-op + if len(messages) == 0: + return OperationResult.success() + + # Convert and delegate + chat_history_messages = self._convert_openai_messages_to_chat_history(messages) + return await self.config_service.send_chat_history( + turn_context=turn_context, + chat_history_messages=chat_history_messages, + options=options, + ) +``` + +### B.2 Agent Framework Extension Pattern + +```python +# From microsoft-agents-a365-tooling-extensions-agentframework +async def send_chat_history_from_store( + self, + chat_message_store: ChatMessageStoreProtocol, + turn_context: TurnContext, + tool_options: Optional[ToolOptions] = None, +) -> OperationResult: + if chat_message_store is None: + raise ValueError("chat_message_store cannot be None") + if turn_context is None: + raise ValueError("turn_context cannot be None") + + messages = await chat_message_store.list_messages() + return await self.send_chat_history_messages( + chat_messages=messages, + turn_context=turn_context, + tool_options=tool_options, + ) +``` diff --git a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py index 5677c72c..c235996a 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py @@ -11,18 +11,21 @@ # Standard library imports import logging -from typing import Optional, List, Tuple +from typing import List, Optional, Sequence, Tuple # Third-party imports - Azure AI +from azure.ai.agents import AgentsClient +from azure.ai.agents.models import McpTool, ThreadMessage, ToolResources from azure.ai.projects import AIProjectClient from azure.identity import DefaultAzureCredential -from azure.ai.agents.models import McpTool, ToolResources from microsoft_agents.hosting.core import Authorization, TurnContext + +from microsoft_agents_a365.runtime import OperationError, OperationResult from microsoft_agents_a365.runtime.utility import Utility +from microsoft_agents_a365.tooling.models import ChatHistoryMessage, ToolOptions from microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service import ( McpToolServerConfigurationService, ) -from microsoft_agents_a365.tooling.models import ToolOptions from microsoft_agents_a365.tooling.utils.constants import Constants from microsoft_agents_a365.tooling.utils.utility import get_mcp_platform_authentication_scope @@ -217,3 +220,253 @@ async def _get_mcp_tool_definitions_and_resources( ) return (tool_definitions, combined_tool_resources) + + # ============================================================================ + # Public Methods - Chat History API + # ============================================================================ + + async def send_chat_history_messages( + self, + turn_context: TurnContext, + messages: Sequence[ThreadMessage], + tool_options: Optional[ToolOptions] = None, + ) -> OperationResult: + """ + Send Azure AI Foundry chat history messages to the MCP platform. + + This method accepts a sequence of Azure AI Foundry ThreadMessage objects, + converts them to ChatHistoryMessage format, and sends them to the MCP + platform for real-time threat protection. + + Args: + turn_context: TurnContext from the Agents SDK containing conversation info. + messages: Sequence of Azure AI Foundry ThreadMessage objects to send. + tool_options: Optional configuration for the request. + + Returns: + OperationResult indicating success or failure. + + Raises: + ValueError: If turn_context or messages is None. + + Example: + >>> service = McpToolRegistrationService() + >>> messages = await agents_client.messages.list(thread_id=thread_id) + >>> result = await service.send_chat_history_messages( + ... turn_context, list(messages) + ... ) + >>> if result.succeeded: + ... print("Chat history sent successfully") + """ + # Input validation + if turn_context is None: + raise ValueError("turn_context cannot be None") + if messages is None: + raise ValueError("messages cannot be None") + + self._logger.info(f"Sending {len(messages)} Azure AI Foundry messages as chat history") + + # Set default options with orchestrator name + if tool_options is None: + tool_options = ToolOptions(orchestrator_name=self._orchestrator_name) + elif tool_options.orchestrator_name is None: + tool_options.orchestrator_name = self._orchestrator_name + + try: + # Convert ThreadMessage objects to ChatHistoryMessage format + chat_history_messages = self._convert_thread_messages_to_chat_history(messages) + + self._logger.debug( + f"Converted {len(chat_history_messages)} messages to ChatHistoryMessage format" + ) + + # Delegate to core service + result = await self._mcp_server_configuration_service.send_chat_history( + turn_context=turn_context, + chat_history_messages=chat_history_messages, + options=tool_options, + ) + + if result.succeeded: + self._logger.info( + f"Chat history sent successfully with {len(chat_history_messages)} messages" + ) + else: + self._logger.error(f"Failed to send chat history: {result}") + + return result + + except ValueError: + # Re-raise validation errors from the core service + raise + except Exception as ex: + self._logger.error(f"Failed to send chat history messages: {ex}") + return OperationResult.failed(OperationError(ex)) + + async def send_chat_history( + self, + agents_client: AgentsClient, + thread_id: str, + turn_context: TurnContext, + tool_options: Optional[ToolOptions] = None, + ) -> OperationResult: + """ + Retrieve and send chat history from Azure AI Foundry to the MCP platform. + + This method retrieves messages from the Azure AI Foundry Agents API using + the provided client and thread ID, converts them to ChatHistoryMessage + format, and sends them to the MCP platform. + + Args: + agents_client: The Azure AI Foundry AgentsClient instance. + thread_id: The thread ID containing the messages to send. + turn_context: TurnContext from the Agents SDK containing conversation info. + tool_options: Optional configuration for the request. + + Returns: + OperationResult indicating success or failure. + + Raises: + ValueError: If agents_client, thread_id, or turn_context is None/empty. + + Example: + >>> from azure.ai.agents import AgentsClient + >>> from azure.identity import DefaultAzureCredential + >>> + >>> client = AgentsClient(endpoint, credential=DefaultAzureCredential()) + >>> service = McpToolRegistrationService() + >>> result = await service.send_chat_history( + ... client, thread_id, turn_context + ... ) + """ + # Input validation + if agents_client is None: + raise ValueError("agents_client cannot be None") + if thread_id is None or not thread_id.strip(): + raise ValueError("thread_id cannot be empty") + if turn_context is None: + raise ValueError("turn_context cannot be None") + + try: + # Retrieve messages from the thread + messages: List[ThreadMessage] = [] + async for message in agents_client.messages.list(thread_id=thread_id): + messages.append(message) + + self._logger.info(f"Retrieved {len(messages)} messages from thread {thread_id}") + + # Delegate to send_chat_history_messages + return await self.send_chat_history_messages( + turn_context=turn_context, + messages=messages, + tool_options=tool_options, + ) + + except ValueError: + # Re-raise validation errors + raise + except Exception as ex: + self._logger.error(f"Failed to send chat history from thread {thread_id}: {ex}") + return OperationResult.failed(OperationError(ex)) + + # ============================================================================ + # Private Methods - Message Conversion Helpers + # ============================================================================ + + def _convert_thread_messages_to_chat_history( + self, + messages: Sequence[ThreadMessage], + ) -> List[ChatHistoryMessage]: + """ + Convert Azure AI Foundry ThreadMessage objects to ChatHistoryMessage format. + + This internal helper method transforms Azure AI Foundry's native ThreadMessage + objects into the ChatHistoryMessage format expected by the MCP platform's + real-time threat protection endpoint. + + Args: + messages: Sequence of ThreadMessage objects to convert. + + Returns: + List of ChatHistoryMessage objects ready for the MCP platform. + + Note: + - Messages with None id, None role, or empty content are filtered out + - Role is extracted via the .value property of the MessageRole enum + - Timestamp is taken from message.created_at + """ + history_messages: List[ChatHistoryMessage] = [] + + for message in messages: + # Skip None messages + if message is None: + self._logger.warning("Skipping null message") + continue + + # Skip messages with None id + if message.id is None: + self._logger.warning("Skipping message with null ID") + continue + + # Skip messages with None role + if message.role is None: + self._logger.warning(f"Skipping message with null role (ID: {message.id})") + continue + + # Extract content from message + content = self._extract_content_from_message(message) + + # Skip messages with empty content + if not content or not content.strip(): + self._logger.warning(f"Skipping message {message.id} with empty content") + continue + + # Convert role enum to lowercase string + role_value = message.role.value if hasattr(message.role, "value") else str(message.role) + role = role_value.lower() + + # Create ChatHistoryMessage + history_message = ChatHistoryMessage( + id=message.id, + role=role, + content=content, + timestamp=message.created_at, + ) + history_messages.append(history_message) + + self._logger.debug( + f"Converted message {message.id} with role '{role}' to ChatHistoryMessage" + ) + + if len(history_messages) == 0 and len(messages) > 0: + self._logger.warning("All messages were filtered out during conversion") + + return history_messages + + def _extract_content_from_message(self, message: ThreadMessage) -> str: + """ + Extract text content from a ThreadMessage's content items. + + This method iterates through the message's content list and extracts + text from MessageTextContent items, concatenating them with spaces. + + Args: + message: Azure AI Foundry ThreadMessage object. + + Returns: + Concatenated text content as string, or empty string if no text found. + """ + if message.content is None or len(message.content) == 0: + return "" + + text_parts: List[str] = [] + + for content_item in message.content: + # Check for MessageTextContent by duck typing (has text attribute with value) + # This handles both real SDK types and mock objects in tests + if hasattr(content_item, "text") and content_item.text is not None: + text_value = getattr(content_item.text, "value", None) + if text_value is not None and text_value: + text_parts.append(text_value) + + return " ".join(text_parts) diff --git a/tests/tooling/extensions/azureaifoundry/__init__.py b/tests/tooling/extensions/azureaifoundry/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/tests/tooling/extensions/azureaifoundry/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/tooling/extensions/azureaifoundry/services/__init__.py b/tests/tooling/extensions/azureaifoundry/services/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/tests/tooling/extensions/azureaifoundry/services/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/tooling/extensions/azureaifoundry/services/conftest.py b/tests/tooling/extensions/azureaifoundry/services/conftest.py new file mode 100644 index 00000000..4eb6449b --- /dev/null +++ b/tests/tooling/extensions/azureaifoundry/services/conftest.py @@ -0,0 +1,311 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Shared pytest fixtures for Azure AI Foundry extension tests.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +import pytest +from microsoft_agents_a365.runtime import OperationResult +from microsoft_agents_a365.tooling.extensions.azureaifoundry.services import ( + McpToolRegistrationService, +) + +# -------------------------------------------------------------------------- +# MOCK AZURE AI FOUNDRY MESSAGE CLASSES +# -------------------------------------------------------------------------- + + +class MockMessageTextContent: + """Mock Azure AI Foundry MessageTextContent for testing.""" + + def __init__(self, text_value: str): + """Initialize mock MessageTextContent. + + Args: + text_value: The text value for the content. + """ + self.text = Mock() + self.text.value = text_value + + +class MockMessageRole: + """Mock Azure AI Foundry MessageRole enum for testing.""" + + def __init__(self, value: str): + """Initialize mock MessageRole. + + Args: + value: The role value (user, assistant, system). + """ + self.value = value + + +class MockThreadMessage: + """Mock Azure AI Foundry ThreadMessage for testing.""" + + def __init__( + self, + message_id: str = "msg-123", + role: str = "user", + content_texts: list[str] = None, + created_at: datetime = None, + ): + """Initialize mock ThreadMessage. + + Args: + message_id: The message ID. + role: The message role (user, assistant, system). + content_texts: List of text content strings. + created_at: The message creation timestamp. + """ + self.id = message_id + self.role = MockMessageRole(role) if role is not None else None + self.created_at = created_at or datetime.now(UTC) + + # Build content list + if content_texts is None: + content_texts = ["Hello, world!"] + + self.content = [MockMessageTextContent(text) for text in content_texts] + + +# -------------------------------------------------------------------------- +# PYTEST FIXTURES - Turn Context +# -------------------------------------------------------------------------- + + +@pytest.fixture +def mock_turn_context(): + """Create a mock TurnContext with all required fields.""" + from microsoft_agents.hosting.core import TurnContext + + mock_context = Mock(spec=TurnContext) + mock_activity = Mock() + mock_conversation = Mock() + + mock_conversation.id = "conv-test-123" + mock_activity.conversation = mock_conversation + mock_activity.id = "msg-test-456" + mock_activity.text = "Test user message" + + mock_context.activity = mock_activity + return mock_context + + +@pytest.fixture +def mock_turn_context_no_activity(): + """Create a mock TurnContext with no activity.""" + from microsoft_agents.hosting.core import TurnContext + + mock_context = Mock(spec=TurnContext) + mock_context.activity = None + return mock_context + + +# -------------------------------------------------------------------------- +# PYTEST FIXTURES - Azure AI Foundry Mock Objects +# -------------------------------------------------------------------------- + + +@pytest.fixture +def mock_role_user(): + """Create a mock MessageRole for user.""" + return MockMessageRole("user") + + +@pytest.fixture +def mock_role_assistant(): + """Create a mock MessageRole for assistant.""" + return MockMessageRole("assistant") + + +@pytest.fixture +def mock_role_system(): + """Create a mock MessageRole for system.""" + return MockMessageRole("system") + + +@pytest.fixture +def mock_thread_message(): + """Create a single mock ThreadMessage.""" + return MockThreadMessage( + message_id="msg-123", + role="user", + content_texts=["Hello, world!"], + created_at=datetime.now(UTC), + ) + + +@pytest.fixture +def mock_thread_message_assistant(): + """Create a mock assistant ThreadMessage.""" + return MockThreadMessage( + message_id="msg-456", + role="assistant", + content_texts=["Hi there!"], + created_at=datetime.now(UTC), + ) + + +@pytest.fixture +def sample_thread_messages(): + """Create a list of sample ThreadMessage objects.""" + return [ + MockThreadMessage( + message_id="msg-1", + role="user", + content_texts=["Hello"], + created_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC), + ), + MockThreadMessage( + message_id="msg-2", + role="assistant", + content_texts=["Hi there!"], + created_at=datetime(2024, 1, 15, 10, 30, 5, tzinfo=UTC), + ), + MockThreadMessage( + message_id="msg-3", + role="user", + content_texts=["How are you?"], + created_at=datetime(2024, 1, 15, 10, 30, 10, tzinfo=UTC), + ), + ] + + +@pytest.fixture +def mock_thread_message_multiple_content(): + """Create a mock ThreadMessage with multiple content items.""" + return MockThreadMessage( + message_id="msg-multi", + role="user", + content_texts=["Part 1", "Part 2", "Part 3"], + created_at=datetime.now(UTC), + ) + + +@pytest.fixture +def mock_thread_message_empty_content(): + """Create a mock ThreadMessage with empty content.""" + msg = MockThreadMessage( + message_id="msg-empty", + role="user", + content_texts=[], + ) + msg.content = [] + return msg + + +@pytest.fixture +def mock_thread_message_none_id(): + """Create a mock ThreadMessage with None ID.""" + msg = MockThreadMessage( + message_id=None, + role="user", + content_texts=["Some content"], + ) + msg.id = None + return msg + + +@pytest.fixture +def mock_thread_message_none_role(): + """Create a mock ThreadMessage with None role.""" + msg = MockThreadMessage( + message_id="msg-no-role", + role=None, + content_texts=["Some content"], + ) + msg.role = None + return msg + + +@pytest.fixture +def mock_thread_message_whitespace_content(): + """Create a mock ThreadMessage with whitespace-only content.""" + return MockThreadMessage( + message_id="msg-whitespace", + role="user", + content_texts=[" \t\n "], + ) + + +# -------------------------------------------------------------------------- +# PYTEST FIXTURES - Azure Agents Client +# -------------------------------------------------------------------------- + + +@pytest.fixture +def mock_agents_client(): + """Create a mock AgentsClient.""" + client = Mock() + client.messages = Mock() + # Default to returning empty list + client.messages.list = Mock(return_value=AsyncIteratorMock([])) + return client + + +@pytest.fixture +def mock_agents_client_with_messages(sample_thread_messages): + """Create a mock AgentsClient that returns sample messages.""" + client = Mock() + client.messages = Mock() + client.messages.list = Mock(return_value=AsyncIteratorMock(sample_thread_messages)) + return client + + +class AsyncIteratorMock: + """Mock async iterator for Azure SDK responses.""" + + def __init__(self, items: list): + """Initialize async iterator mock. + + Args: + items: List of items to iterate over. + """ + self.items = items + self.index = 0 + + def __aiter__(self): + """Return self as async iterator.""" + self.index = 0 + return self + + async def __anext__(self): + """Return next item or raise StopAsyncIteration.""" + if self.index < len(self.items): + item = self.items[self.index] + self.index += 1 + return item + raise StopAsyncIteration + + +# -------------------------------------------------------------------------- +# PYTEST FIXTURES - Service Instance +# -------------------------------------------------------------------------- + + +@pytest.fixture +def service(): + """Create McpToolRegistrationService instance with mocked core service.""" + svc = McpToolRegistrationService() + svc._mcp_server_configuration_service = Mock() + svc._mcp_server_configuration_service.send_chat_history = AsyncMock( + return_value=OperationResult.success() + ) + return svc + + +@pytest.fixture +def service_with_failing_core(): + """Create McpToolRegistrationService with core service that returns failure.""" + from microsoft_agents_a365.runtime import OperationError + + svc = McpToolRegistrationService() + svc._mcp_server_configuration_service = Mock() + error = OperationError(Exception("Core service error")) + svc._mcp_server_configuration_service.send_chat_history = AsyncMock( + return_value=OperationResult.failed(error) + ) + return svc diff --git a/tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py b/tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py new file mode 100644 index 00000000..40ad29a1 --- /dev/null +++ b/tests/tooling/extensions/azureaifoundry/services/test_send_chat_history.py @@ -0,0 +1,723 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for send_chat_history methods in McpToolRegistrationService for Azure AI Foundry.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from microsoft_agents_a365.runtime import OperationResult +from microsoft_agents_a365.tooling.models import ToolOptions + +from .conftest import AsyncIteratorMock, MockThreadMessage + +# ============================================================================= +# INPUT VALIDATION TESTS +# ============================================================================= + + +class TestInputValidation: + """Tests for input validation in send_chat_history methods.""" + + # -------------------------------------------------------------------------- + # send_chat_history_messages validation tests + # -------------------------------------------------------------------------- + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_validates_turn_context_none( + self, service, sample_thread_messages + ): + """Test that send_chat_history_messages raises ValueError when turn_context is None.""" + with pytest.raises(ValueError, match="turn_context cannot be None"): + await service.send_chat_history_messages(None, sample_thread_messages) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_validates_messages_none( + self, service, mock_turn_context + ): + """Test that send_chat_history_messages raises ValueError when messages is None.""" + with pytest.raises(ValueError, match="messages cannot be None"): + await service.send_chat_history_messages(mock_turn_context, None) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_empty_list_still_calls_core_service( + self, service, mock_turn_context + ): + """Test that empty message list still calls core service to register current user message.""" + result = await service.send_chat_history_messages(mock_turn_context, []) + + assert result.succeeded is True + assert len(result.errors) == 0 + # Core service should still be called even for empty messages + service._mcp_server_configuration_service.send_chat_history.assert_called_once() + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + assert call_args.kwargs["chat_history_messages"] == [] + + # -------------------------------------------------------------------------- + # send_chat_history validation tests + # -------------------------------------------------------------------------- + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_validates_agents_client_none(self, service, mock_turn_context): + """Test that send_chat_history raises ValueError when agents_client is None.""" + with pytest.raises(ValueError, match="agents_client cannot be None"): + await service.send_chat_history(None, "thread-123", mock_turn_context) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_validates_thread_id_none( + self, service, mock_agents_client, mock_turn_context + ): + """Test that send_chat_history raises ValueError when thread_id is None.""" + with pytest.raises(ValueError, match="thread_id cannot be empty"): + await service.send_chat_history(mock_agents_client, None, mock_turn_context) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_validates_thread_id_empty( + self, service, mock_agents_client, mock_turn_context + ): + """Test that send_chat_history raises ValueError when thread_id is empty string.""" + with pytest.raises(ValueError, match="thread_id cannot be empty"): + await service.send_chat_history(mock_agents_client, "", mock_turn_context) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_validates_thread_id_whitespace( + self, service, mock_agents_client, mock_turn_context + ): + """Test that send_chat_history raises ValueError when thread_id is whitespace only.""" + with pytest.raises(ValueError, match="thread_id cannot be empty"): + await service.send_chat_history(mock_agents_client, " ", mock_turn_context) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_validates_turn_context_none(self, service, mock_agents_client): + """Test that send_chat_history raises ValueError when turn_context is None.""" + with pytest.raises(ValueError, match="turn_context cannot be None"): + await service.send_chat_history(mock_agents_client, "thread-123", None) + + +# ============================================================================= +# MESSAGE CONVERSION TESTS +# ============================================================================= + + +class TestMessageConversion: + """Tests for message conversion logic.""" + + # -------------------------------------------------------------------------- + # Content extraction tests + # -------------------------------------------------------------------------- + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_extract_content_from_single_text_item(self, service): + """Test that text content is correctly extracted from a single content item.""" + message = MockThreadMessage( + message_id="msg-1", + role="user", + content_texts=["Hello, world!"], + ) + + content = service._extract_content_from_message(message) + + assert content == "Hello, world!" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_extract_content_from_multiple_text_items(self, service): + """Test that multiple content items are concatenated with spaces.""" + message = MockThreadMessage( + message_id="msg-1", + role="user", + content_texts=["Part 1", "Part 2", "Part 3"], + ) + + content = service._extract_content_from_message(message) + + assert content == "Part 1 Part 2 Part 3" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_extract_content_handles_empty_content_list(self, service): + """Test that empty content list returns empty string.""" + message = MockThreadMessage(message_id="msg-1", role="user", content_texts=[]) + message.content = [] + + content = service._extract_content_from_message(message) + + assert content == "" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_extract_content_handles_none_content(self, service): + """Test that None content returns empty string.""" + message = MockThreadMessage(message_id="msg-1", role="user", content_texts=[]) + message.content = None + + content = service._extract_content_from_message(message) + + assert content == "" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_extract_content_handles_none_text_value(self, service): + """Test that content item with None text.value is skipped.""" + message = MockThreadMessage(message_id="msg-1", role="user", content_texts=["Valid text"]) + # Add a content item with None text value + from .conftest import MockMessageTextContent + + invalid_content = MockMessageTextContent("") + invalid_content.text.value = None + message.content.append(invalid_content) + + content = service._extract_content_from_message(message) + + assert content == "Valid text" + + # -------------------------------------------------------------------------- + # Message conversion tests + # -------------------------------------------------------------------------- + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_extracts_id_correctly(self, service): + """Test that message ID is correctly extracted.""" + message = MockThreadMessage(message_id="unique-id-123", role="user", content_texts=["Hi"]) + + result = service._convert_thread_messages_to_chat_history([message]) + + assert len(result) == 1 + assert result[0].id == "unique-id-123" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_extracts_role_correctly(self, service): + """Test that message role is correctly extracted and lowercased.""" + message = MockThreadMessage(message_id="msg-1", role="user", content_texts=["Hi"]) + + result = service._convert_thread_messages_to_chat_history([message]) + + assert len(result) == 1 + assert result[0].role == "user" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_extracts_timestamp_correctly(self, service): + """Test that message timestamp is correctly extracted.""" + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC) + message = MockThreadMessage( + message_id="msg-1", role="user", content_texts=["Hi"], created_at=timestamp + ) + + result = service._convert_thread_messages_to_chat_history([message]) + + assert len(result) == 1 + assert result[0].timestamp == timestamp + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_filters_null_message(self, service): + """Test that null messages are filtered out.""" + messages = [ + MockThreadMessage(message_id="msg-1", role="user", content_texts=["Valid"]), + None, + MockThreadMessage(message_id="msg-3", role="assistant", content_texts=["Also valid"]), + ] + + result = service._convert_thread_messages_to_chat_history(messages) + + assert len(result) == 2 + assert result[0].id == "msg-1" + assert result[1].id == "msg-3" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_filters_null_id( + self, service, mock_thread_message_none_id, mock_thread_message + ): + """Test that messages with null ID are filtered out.""" + messages = [mock_thread_message, mock_thread_message_none_id] + + result = service._convert_thread_messages_to_chat_history(messages) + + assert len(result) == 1 + assert result[0].id == "msg-123" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_filters_null_role( + self, service, mock_thread_message_none_role, mock_thread_message + ): + """Test that messages with null role are filtered out.""" + messages = [mock_thread_message, mock_thread_message_none_role] + + result = service._convert_thread_messages_to_chat_history(messages) + + assert len(result) == 1 + assert result[0].id == "msg-123" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_filters_empty_content( + self, service, mock_thread_message_empty_content, mock_thread_message + ): + """Test that messages with empty content are filtered out.""" + messages = [mock_thread_message, mock_thread_message_empty_content] + + result = service._convert_thread_messages_to_chat_history(messages) + + assert len(result) == 1 + assert result[0].id == "msg-123" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_filters_whitespace_only_content( + self, service, mock_thread_message_whitespace_content, mock_thread_message + ): + """Test that messages with whitespace-only content are filtered out.""" + messages = [mock_thread_message, mock_thread_message_whitespace_content] + + result = service._convert_thread_messages_to_chat_history(messages) + + assert len(result) == 1 + assert result[0].id == "msg-123" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_all_filtered_returns_empty_list( + self, service, mock_thread_message_none_id, mock_thread_message_empty_content + ): + """Test that filtering all messages returns empty list.""" + messages = [mock_thread_message_none_id, mock_thread_message_empty_content] + + result = service._convert_thread_messages_to_chat_history(messages) + + assert len(result) == 0 + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_convert_messages_role_enum_to_lowercase(self, service): + """Test that different roles are properly converted to lowercase.""" + messages = [ + MockThreadMessage(message_id="msg-1", role="user", content_texts=["User msg"]), + MockThreadMessage( + message_id="msg-2", role="assistant", content_texts=["Assistant msg"] + ), + MockThreadMessage(message_id="msg-3", role="system", content_texts=["System msg"]), + ] + + result = service._convert_thread_messages_to_chat_history(messages) + + assert len(result) == 3 + assert result[0].role == "user" + assert result[1].role == "assistant" + assert result[2].role == "system" + + +# ============================================================================= +# SUCCESS PATH TESTS +# ============================================================================= + + +class TestSuccessPath: + """Tests for successful execution paths.""" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_success( + self, service, mock_turn_context, sample_thread_messages + ): + """Test successful send_chat_history_messages call.""" + result = await service.send_chat_history_messages(mock_turn_context, sample_thread_messages) + + assert result.succeeded is True + assert len(result.errors) == 0 + service._mcp_server_configuration_service.send_chat_history.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_with_tool_options( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that ToolOptions are passed correctly to core service.""" + options = ToolOptions(orchestrator_name="CustomOrchestrator") + + await service.send_chat_history_messages( + mock_turn_context, sample_thread_messages, tool_options=options + ) + + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + assert call_args.kwargs["options"].orchestrator_name == "CustomOrchestrator" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_default_orchestrator_name( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that default orchestrator name is set to 'AzureAIFoundry'.""" + await service.send_chat_history_messages(mock_turn_context, sample_thread_messages) + + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + assert call_args.kwargs["options"].orchestrator_name == "AzureAIFoundry" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_sets_orchestrator_if_none( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that orchestrator_name is set if options provided with None value.""" + options = ToolOptions(orchestrator_name=None) + + await service.send_chat_history_messages( + mock_turn_context, sample_thread_messages, tool_options=options + ) + + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + assert call_args.kwargs["options"].orchestrator_name == "AzureAIFoundry" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_delegates_to_core_service( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that send_chat_history_messages delegates to core service correctly.""" + await service.send_chat_history_messages(mock_turn_context, sample_thread_messages) + + # Verify delegation + service._mcp_server_configuration_service.send_chat_history.assert_called_once() + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + + # Check turn_context was passed + assert call_args.kwargs["turn_context"] == mock_turn_context + + # Check chat_history_messages were converted + chat_history = call_args.kwargs["chat_history_messages"] + assert len(chat_history) == len(sample_thread_messages) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_converts_messages_correctly( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that ThreadMessage objects are correctly converted to ChatHistoryMessage.""" + await service.send_chat_history_messages(mock_turn_context, sample_thread_messages) + + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + history_messages = call_args.kwargs["chat_history_messages"] + + assert len(history_messages) == 3 + + # Verify first message conversion + assert history_messages[0].id == "msg-1" + assert history_messages[0].role == "user" + assert history_messages[0].content == "Hello" + assert history_messages[0].timestamp is not None + + # Verify second message conversion + assert history_messages[1].id == "msg-2" + assert history_messages[1].role == "assistant" + assert history_messages[1].content == "Hi there!" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_all_filtered_still_calls_core_service( + self, + service, + mock_turn_context, + mock_thread_message_none_id, + mock_thread_message_empty_content, + ): + """Test that all messages filtered out still calls core service with empty list.""" + messages = [mock_thread_message_none_id, mock_thread_message_empty_content] + + result = await service.send_chat_history_messages(mock_turn_context, messages) + + assert result.succeeded is True + # Core service should still be called even when all messages are filtered + service._mcp_server_configuration_service.send_chat_history.assert_called_once() + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + assert call_args.kwargs["chat_history_messages"] == [] + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_success( + self, service, mock_turn_context, sample_thread_messages + ): + """Test successful send_chat_history call.""" + mock_client = Mock() + mock_client.messages = Mock() + mock_client.messages.list = Mock(return_value=AsyncIteratorMock(sample_thread_messages)) + + result = await service.send_chat_history(mock_client, "thread-123", mock_turn_context) + + assert result.succeeded is True + service._mcp_server_configuration_service.send_chat_history.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_retrieves_from_client( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that send_chat_history retrieves messages from the client.""" + mock_client = Mock() + mock_client.messages = Mock() + mock_client.messages.list = Mock(return_value=AsyncIteratorMock(sample_thread_messages)) + + await service.send_chat_history(mock_client, "thread-abc", mock_turn_context) + + mock_client.messages.list.assert_called_once_with(thread_id="thread-abc") + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_delegates_to_send_chat_history_messages( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that send_chat_history delegates to send_chat_history_messages.""" + mock_client = Mock() + mock_client.messages = Mock() + mock_client.messages.list = Mock(return_value=AsyncIteratorMock(sample_thread_messages)) + + with patch.object( + service, "send_chat_history_messages", new_callable=AsyncMock + ) as mock_method: + mock_method.return_value = OperationResult.success() + + await service.send_chat_history(mock_client, "thread-123", mock_turn_context) + + mock_method.assert_called_once() + call_args = mock_method.call_args + assert call_args.kwargs["turn_context"] == mock_turn_context + assert len(call_args.kwargs["messages"]) == len(sample_thread_messages) + + +# ============================================================================= +# ERROR HANDLING TESTS +# ============================================================================= + + +class TestErrorHandling: + """Tests for error handling scenarios.""" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_handles_core_service_failure( + self, service_with_failing_core, mock_turn_context, sample_thread_messages + ): + """Test send_chat_history_messages handles core service failure.""" + result = await service_with_failing_core.send_chat_history_messages( + mock_turn_context, sample_thread_messages + ) + + assert result.succeeded is False + assert len(result.errors) == 1 + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_handles_unexpected_exception( + self, service, mock_turn_context, sample_thread_messages + ): + """Test send_chat_history_messages handles unexpected exceptions.""" + service._mcp_server_configuration_service.send_chat_history = AsyncMock( + side_effect=Exception("Unexpected error") + ) + + result = await service.send_chat_history_messages(mock_turn_context, sample_thread_messages) + + assert result.succeeded is False + assert len(result.errors) == 1 + assert "Unexpected error" in str(result.errors[0].message) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_handles_api_error(self, service, mock_turn_context): + """Test that send_chat_history handles Azure API errors.""" + mock_client = Mock() + mock_client.messages = Mock() + mock_client.messages.list = Mock(side_effect=Exception("API Error")) + + result = await service.send_chat_history(mock_client, "thread-123", mock_turn_context) + + assert result.succeeded is False + assert len(result.errors) == 1 + assert "API Error" in str(result.errors[0].message) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_handles_connection_error(self, service, mock_turn_context): + """Test that send_chat_history handles connection errors.""" + mock_client = Mock() + mock_client.messages = Mock() + mock_client.messages.list = Mock(side_effect=ConnectionError("Connection failed")) + + result = await service.send_chat_history(mock_client, "thread-123", mock_turn_context) + + assert result.succeeded is False + assert len(result.errors) == 1 + assert "Connection failed" in str(result.errors[0].message) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_handles_timeout(self, service, mock_turn_context): + """Test that send_chat_history handles timeout errors.""" + mock_client = Mock() + mock_client.messages = Mock() + mock_client.messages.list = Mock(side_effect=TimeoutError("Request timed out")) + + result = await service.send_chat_history(mock_client, "thread-123", mock_turn_context) + + assert result.succeeded is False + assert len(result.errors) == 1 + assert "timed out" in str(result.errors[0].message) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_propagates_validation_error( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that ValueError exceptions are re-raised, not wrapped.""" + mock_client = Mock() + mock_client.messages = Mock() + mock_client.messages.list = Mock(return_value=AsyncIteratorMock(sample_thread_messages)) + + # Mock send_chat_history_messages to raise ValueError + with patch.object( + service, "send_chat_history_messages", new_callable=AsyncMock + ) as mock_method: + mock_method.side_effect = ValueError("turn_context cannot be None") + + with pytest.raises(ValueError, match="turn_context cannot be None"): + await service.send_chat_history(mock_client, "thread-123", mock_turn_context) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_propagates_validation_error( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that ValueError from core service is re-raised.""" + service._mcp_server_configuration_service.send_chat_history = AsyncMock( + side_effect=ValueError("Invalid argument") + ) + + with pytest.raises(ValueError, match="Invalid argument"): + await service.send_chat_history_messages(mock_turn_context, sample_thread_messages) + + +# ============================================================================= +# EDGE CASE TESTS +# ============================================================================= + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_handles_role_without_value_attribute(self, service, mock_turn_context): + """Test defensive handling when role doesn't have .value attribute.""" + message = Mock() + message.id = "msg-1" + message.role = "user" # String, not an enum with .value + message.content = [Mock()] + message.content[0].text = Mock() + message.content[0].text.value = "Hello" + message.created_at = datetime.now(UTC) + + result = await service.send_chat_history_messages(mock_turn_context, [message]) + + # Should succeed - role is handled defensively + assert result.succeeded is True + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_empty_thread_still_calls_core_service( + self, service, mock_turn_context + ): + """Test that empty thread still calls core service to register current user message.""" + mock_client = Mock() + mock_client.messages = Mock() + mock_client.messages.list = Mock(return_value=AsyncIteratorMock([])) + + result = await service.send_chat_history(mock_client, "thread-123", mock_turn_context) + + assert result.succeeded is True + # Core service should still be called even for empty threads + service._mcp_server_configuration_service.send_chat_history.assert_called_once() + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + assert call_args.kwargs["chat_history_messages"] == [] + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_concurrent_calls_do_not_interfere(self, service, mock_turn_context): + """Test that concurrent calls to send_chat_history_messages are isolated.""" + import asyncio + + messages1 = [MockThreadMessage(message_id="msg-1", role="user", content_texts=["Set 1"])] + messages2 = [MockThreadMessage(message_id="msg-2", role="user", content_texts=["Set 2"])] + + captured_payloads = [] + + async def capture_and_succeed(*args, **kwargs): + captured_payloads.append(kwargs.get("chat_history_messages")) + await asyncio.sleep(0.01) + return OperationResult.success() + + service._mcp_server_configuration_service.send_chat_history = AsyncMock( + side_effect=capture_and_succeed + ) + + results = await asyncio.gather( + service.send_chat_history_messages(mock_turn_context, messages1), + service.send_chat_history_messages(mock_turn_context, messages2), + ) + + assert all(r.succeeded for r in results) + assert len(captured_payloads) == 2 + contents = [p[0].content for p in captured_payloads] + assert "Set 1" in contents + assert "Set 2" in contents + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_preserves_custom_orchestrator_name( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that custom orchestrator name is preserved in options.""" + options = ToolOptions(orchestrator_name="MyCustomOrchestrator") + + await service.send_chat_history_messages( + mock_turn_context, sample_thread_messages, tool_options=options + ) + + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + assert call_args.kwargs["options"].orchestrator_name == "MyCustomOrchestrator" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_passes_tool_options( + self, service, mock_turn_context, sample_thread_messages + ): + """Test that tool_options are passed through send_chat_history.""" + mock_client = Mock() + mock_client.messages = Mock() + mock_client.messages.list = Mock(return_value=AsyncIteratorMock(sample_thread_messages)) + + options = ToolOptions(orchestrator_name="TestOrchestrator") + + with patch.object( + service, "send_chat_history_messages", new_callable=AsyncMock + ) as mock_method: + mock_method.return_value = OperationResult.success() + + await service.send_chat_history( + mock_client, "thread-123", mock_turn_context, tool_options=options + ) + + call_args = mock_method.call_args + assert call_args.kwargs["tool_options"] == options