diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 44f1b93e..9bf457cb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,4 +2,7 @@ * @microsoft/agent365-approvers /.github/ @microsoft/agent365-approvers /libraries/microsoft-agents-a365-observability-*/ @microsoft/agent365-observability-approvers -/tests/observability/ @microsoft/agent365-observability-approvers \ No newline at end of file +/tests/observability/ @microsoft/agent365-observability-approvers +/libraries/microsoft-agents-a365-notifications/ @microsoft/agent365-tooling-approvers +/libraries/microsoft-agents-a365-tooling*/ @microsoft/agent365-tooling-approvers +/tests/tooling/ @microsoft/agent365-tooling-approvers \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbb46774..42b7a28e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,8 +108,7 @@ jobs: - name: Check linting run: | - uv run --frozen ruff check . - continue-on-error: true + uv run --frozen ruff check . --preview - name: Check formatting run: | diff --git a/CLAUDE.md b/CLAUDE.md index e0772348..be898b02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,11 +168,45 @@ Place it before imports with one blank line after. ### Python Conventions -- Type hints preferred (Pydantic models heavily used) +- Type hints required on all function parameters and return types - Async/await patterns for I/O operations - Use explicit `None` checks: `if x is not None:` not `if x:` - Local imports should be moved to top of file - Return defensive copies of mutable data to protect singletons +- **Async method naming**: Do NOT use `_async` suffix on async methods. The `_async` suffix is only appropriate when providing both sync and async versions of the same method. Since this SDK is async-only, use plain method names (e.g., `send_chat_history_messages` not `send_chat_history_messages_async`) + +### Type Hints - NEVER Use `Any` + +**CRITICAL: Never use `typing.Any` in this codebase.** Using `Any` defeats the purpose of type checking and can hide bugs. Instead: + +1. **Use actual types from external SDKs** - When integrating with external libraries (OpenAI, LangChain, etc.), import and use their actual types: + ```python + from agents.memory import Session + from agents.items import TResponseInputItem + + async def send_chat_history(self, session: Session) -> OperationResult: + ... + ``` + +2. **Use `Union` for known possible types**: + ```python + from typing import Union + MessageType = Union[UserMessage, AssistantMessage, SystemMessage, Dict[str, object]] + ``` + +3. **Use `object` for truly unknown types** that you only pass through: + ```python + def log_item(item: object) -> None: ... + ``` + +4. **Use `Protocol` only as a last resort** - If external types cannot be found or imported, define a Protocol. However, **confirm with the developer first** before proceeding with this approach, as it may indicate a missing dependency or incorrect understanding of the external API. + +**Why this matters:** +- `Any` disables all type checking for that variable +- Bugs that type checkers would catch go unnoticed +- Code readability suffers - developers don't know what types to expect +- Using actual SDK types provides better IDE support and ensures compatibility +- This applies to both production code AND test files ## CI/CD diff --git a/docs/design.md b/docs/design.md index 3ebd5c9f..9f107784 100644 --- a/docs/design.md +++ b/docs/design.md @@ -227,9 +227,51 @@ Framework-specific adapters for MCP tool integration: |---------|---------|------------| | `extensions-agentframework` | Adapt MCP tools to Microsoft Agents SDK | [design.md](../libraries/microsoft-agents-a365-tooling-extensions-agentframework/docs/design.md) | | `extensions-azureaifoundry` | Azure AI Foundry tool integration | [design.md](../libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/docs/design.md) | -| `extensions-openai` | OpenAI function calling integration | [design.md](../libraries/microsoft-agents-a365-tooling-extensions-openai/docs/design.md) | +| `extensions-openai` | OpenAI function calling integration and chat history | [design.md](../libraries/microsoft-agents-a365-tooling-extensions-openai/docs/design.md) | | `extensions-semantickernel` | Semantic Kernel plugin integration | [design.md](../libraries/microsoft-agents-a365-tooling-extensions-semantickernel/docs/design.md) | +#### OpenAI Extension: Chat History API + +The OpenAI tooling extension provides methods to send chat history to the MCP platform for real-time threat protection: + +**Key Classes:** + +| Class | Purpose | +|-------|---------| +| `McpToolRegistrationService` | MCP tool registration and chat history management | + +**Methods:** + +| Method | Purpose | +|--------|---------| +| `send_chat_history(turn_context, session, limit, options)` | Extract messages from OpenAI Session and send to MCP platform | +| `send_chat_history_messages(turn_context, messages, options)` | Send a list of OpenAI TResponseInputItem messages to MCP platform | + +**Usage Example:** + +```python +from agents import Agent, Runner +from microsoft_agents_a365.tooling.extensions.openai import McpToolRegistrationService + +service = McpToolRegistrationService() +agent = Agent(name="my-agent", model="gpt-4") + +# In your agent handler: +async with Runner.run(agent, messages) as result: + session = result.session + + # Option 1: Send from Session object + op_result = await service.send_chat_history(turn_context, session) + + # Option 2: Send from message list + op_result = await service.send_chat_history_messages(turn_context, messages) + + if op_result.succeeded: + print("Chat history sent successfully") +``` + +The methods convert OpenAI message types to `ChatHistoryMessage` format and delegate to the core `McpToolServerConfigurationService.send_chat_history()` method. + ### 6. Notifications (`microsoft-agents-a365-notifications`) > **Detailed documentation**: [libraries/microsoft-agents-a365-notifications/docs/design.md](../libraries/microsoft-agents-a365-notifications/docs/design.md) diff --git a/generate_dependency_diagram.py b/generate_dependency_diagram.py index c766de62..c0d5bced 100644 --- a/generate_dependency_diagram.py +++ b/generate_dependency_diagram.py @@ -9,7 +9,6 @@ import re import tomllib from pathlib import Path -from typing import Dict, List, Set class PackageInfo: @@ -19,16 +18,16 @@ def __init__(self, name: str, package_type: str, path: Path): self.name = name self.package_type = package_type self.path = path - self.dependencies: Set[str] = set() + self.dependencies: set[str] = set() -def read_pyproject_toml(path: Path) -> Dict: +def read_pyproject_toml(path: Path) -> dict: """Read and parse a pyproject.toml file.""" with open(path, "rb") as f: return tomllib.load(f) -def extract_dependencies(pyproject_data: Dict, package_names: Set[str]) -> Set[str]: +def extract_dependencies(pyproject_data: dict, package_names: set[str]) -> set[str]: """Extract internal package dependencies from pyproject.toml data.""" dependencies = set() @@ -45,7 +44,7 @@ def extract_dependencies(pyproject_data: Dict, package_names: Set[str]) -> Set[s return dependencies -def generate_mermaid_diagram(packages: List[PackageInfo]) -> str: +def generate_mermaid_diagram(packages: list[PackageInfo]) -> str: """Generate a Mermaid diagram from package information.""" # Color scheme based on package types @@ -96,7 +95,7 @@ def generate_mermaid_diagram(packages: List[PackageInfo]) -> str: lines.append(" %% Styling") # Group packages by type for styling - packages_by_type: Dict[str, List[str]] = {} + packages_by_type: dict[str, list[str]] = {} for pkg in packages: if pkg.package_type not in packages_by_type: packages_by_type[pkg.package_type] = [] @@ -170,8 +169,8 @@ def main(): # Collect all package names first and cache pyproject data all_package_names = set() - packages: List[PackageInfo] = [] - pyproject_data_cache: Dict[str, Dict] = {} + packages: list[PackageInfo] = [] + pyproject_data_cache: dict[str, dict] = {} for path_str, pkg_type in package_configs: pyproject_path = repo_root / path_str / "pyproject.toml" diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/__init__.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/__init__.py index b12f85c7..0a347c52 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/__init__.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Microsoft Agent 365 Notifications diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/agent_notification.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/agent_notification.py index 8a5f94de..57483843 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/agent_notification.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/agent_notification.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from __future__ import annotations from collections.abc import Awaitable, Callable, Iterable diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/__init__.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/__init__.py index a4ee9d2d..ecfc2407 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/__init__.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from .agent_notification_activity import AgentNotificationActivity from .email_reference import EmailReference from .wpx_comment import WpxComment diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_lifecycle_event.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_lifecycle_event.py index 87d89c61..12a43c87 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_lifecycle_event.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_lifecycle_event.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from enum import Enum diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_notification_activity.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_notification_activity.py index c2de5e77..77af7a39 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_notification_activity.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_notification_activity.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from typing import Any, Optional, Type, TypeVar from microsoft_agents.activity import Activity from .notification_types import NotificationTypes diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_subchannel.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_subchannel.py index 2adbd521..4fd00207 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_subchannel.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_subchannel.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from enum import Enum diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/email_reference.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/email_reference.py index 77e746ee..70faf9d0 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/email_reference.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/email_reference.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from typing import Optional, Literal from microsoft_agents.activity.entity import Entity from .notification_types import NotificationTypes diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/email_response.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/email_response.py index 6aff69c5..c52c32c4 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/email_response.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/email_response.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from typing import Literal from microsoft_agents.activity.activity import Activity from microsoft_agents.activity.entity import Entity diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/notification_types.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/notification_types.py index 5aea900b..96ab4e67 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/notification_types.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/notification_types.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from enum import Enum diff --git a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/wpx_comment.py b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/wpx_comment.py index a57f8d84..53f7a690 100644 --- a/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/wpx_comment.py +++ b/libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/wpx_comment.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from typing import Optional, Literal from microsoft_agents.activity.entity import Entity from .notification_types import NotificationTypes diff --git a/libraries/microsoft-agents-a365-notifications/pyproject.toml b/libraries/microsoft-agents-a365-notifications/pyproject.toml index e2b3283b..a75a0570 100644 --- a/libraries/microsoft-agents-a365-notifications/pyproject.toml +++ b/libraries/microsoft-agents-a365-notifications/pyproject.toml @@ -69,6 +69,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-notifications/setup.py b/libraries/microsoft-agents-a365-notifications/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-notifications/setup.py +++ b/libraries/microsoft-agents-a365-notifications/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py index 07a1a9f8..fdc17a34 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Microsoft Agent 365 Python SDK for OpenTelemetry tracing. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py index 7a8b9658..26eab5d2 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import logging import threading diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py index 46634e5a..35ad5321 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Constants for SDK OpenTelemetry implementation. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execution_type.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execution_type.py index eb7b0b84..59f5c15e 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execution_type.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execution_type.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Execution type enum. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py index 6c0c8b67..da51c41f 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # pip install opentelemetry-sdk opentelemetry-api requests diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py index aae458ff..b6d687c0 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import json import logging diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_call_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_call_details.py index 18d726ca..d62c1eab 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_call_details.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_call_details.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. from dataclasses import dataclass diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_operation_type.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_operation_type.py index 90465081..1b2809c2 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_operation_type.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_operation_type.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. from enum import Enum diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_details.py index 221d7873..72b3d8e0 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_details.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_details.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Data class for invoke agent details. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/__init__.py index 27367a57..7ef448ca 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/__init__.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Middleware components for Microsoft Agent 365 SDK. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py index 6d05786e..368f7119 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Per request baggage builder for OpenTelemetry context propagation. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py index 0782b10c..eb92c81f 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Base class for OpenTelemetry tracing scopes. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py index 3e292641..40f61a96 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Request class. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/source_metadata.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/source_metadata.py index 777e650c..74581b0a 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/source_metadata.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/source_metadata.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Source metadata class. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tenant_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tenant_details.py index 8a8b76d9..a9186fcb 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tenant_details.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tenant_details.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Tenant details class. from dataclasses import dataclass diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_call_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_call_details.py index 5889bcd2..fbcaafb9 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_call_details.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_call_details.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Data class for tool call details. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_type.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_type.py index b026d9b4..d6580f19 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_type.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_type.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Tool type enum. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/__init__.py index c75ca536..18b27d77 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/__init__.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Trace Processors diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py index 237be264..ed1d331a 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py @@ -1,6 +1,7 @@ -"""Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. -Span processor for copying OpenTelemetry baggage entries onto spans. +"""Span processor for copying OpenTelemetry baggage entries onto spans. This implementation assumes `opentelemetry.baggage.get_all` is available with the signature `get_all(context: Context | None) -> Mapping[str, object]`. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py index bfb75a56..d33bcab3 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. from .. import constants as consts diff --git a/libraries/microsoft-agents-a365-observability-core/pyproject.toml b/libraries/microsoft-agents-a365-observability-core/pyproject.toml index fdce9c36..5d8447ef 100644 --- a/libraries/microsoft-agents-a365-observability-core/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-core/pyproject.toml @@ -80,6 +80,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-observability-core/setup.py b/libraries/microsoft-agents-a365-observability-core/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-observability-core/setup.py +++ b/libraries/microsoft-agents-a365-observability-core/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml index 57da1a7a..39e226b0 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml @@ -68,6 +68,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/setup.py b/libraries/microsoft-agents-a365-observability-extensions-agentframework/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/setup.py +++ b/libraries/microsoft-agents-a365-observability-extensions-agentframework/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/__init__.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/__init__.py index 282c3d0b..2c25c81b 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/__init__.py +++ b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Wraps the Langchain Agents SDK tracer to integrate with our Telemetry Solution. diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py index 221f20d2..bb8eb122 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py +++ b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import logging import re diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer_instrumentor.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer_instrumentor.py index 73ebc669..efb245ba 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer_instrumentor.py +++ b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer_instrumentor.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. from __future__ import annotations diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py index f634808c..b7dfe638 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py +++ b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import json from collections.abc import Iterable, Iterator, Mapping, Sequence diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml index d7046632..cff9f156 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml @@ -71,6 +71,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/setup.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/setup.py +++ b/libraries/microsoft-agents-a365-observability-extensions-langchain/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/__init__.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/__init__.py index 72259f15..2b548f52 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/__init__.py +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Wraps the OpenAI Agents SDK tracer to integrate with the Microsoft Agent 365 Telemetry Solution. diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/constants.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/constants.py index 9bc69bf9..2cd5b415 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/constants.py +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/constants.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Span Attribute Types from microsoft_agents_a365.observability.core.constants import ( diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_instrumentor.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_instrumentor.py index 18d5f805..dae8786e 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_instrumentor.py +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_instrumentor.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Wrapper for OpenAI Agents SDK diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py index 1459c95a..a9c775b3 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Processor for OpenAI Agents SDK diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py index f1904a85..eb9c0b07 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # -------------------------------------------------- # # HELPER FUNCTIONS ### diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml index a0c55afc..bc6e7225 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml @@ -69,6 +69,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/setup.py b/libraries/microsoft-agents-a365-observability-extensions-openai/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/setup.py +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/__init__.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/__init__.py index 2a50eae8..b7c52582 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/__init__.py +++ b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/__init__.py @@ -1 +1,3 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py index d7a78133..c78f748a 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py +++ b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. # Custom Span Processor diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/trace_instrumentor.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/trace_instrumentor.py index 1d1b75c6..bd44bb42 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/trace_instrumentor.py +++ b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/trace_instrumentor.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. from __future__ import annotations diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml index e8ef995a..182329b2 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml @@ -71,6 +71,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/setup.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/setup.py +++ b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py index 6dbf807a..c1e36341 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from __future__ import annotations from collections.abc import Iterator diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py index f7b4f517..f0f990d6 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/libraries/microsoft-agents-a365-observability-hosting/setup.py b/libraries/microsoft-agents-a365-observability-hosting/setup.py index 9e538463..6fcc5181 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/setup.py +++ b/libraries/microsoft-agents-a365-observability-hosting/setup.py @@ -14,7 +14,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py index 4b9cb194..3eb6463b 100644 --- a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Utility logic for environment-related operations. diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/power_platform_api_discovery.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/power_platform_api_discovery.py index f3891a36..df691110 100644 --- a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/power_platform_api_discovery.py +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/power_platform_api_discovery.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import re from typing import Literal diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/utility.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/utility.py index 9a26ccc1..6d02505a 100644 --- a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/utility.py +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/utility.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Utility functions for Microsoft Agent 365 runtime operations. diff --git a/libraries/microsoft-agents-a365-runtime/pyproject.toml b/libraries/microsoft-agents-a365-runtime/pyproject.toml index c994444c..2ae56b2f 100644 --- a/libraries/microsoft-agents-a365-runtime/pyproject.toml +++ b/libraries/microsoft-agents-a365-runtime/pyproject.toml @@ -65,6 +65,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-runtime/setup.py b/libraries/microsoft-agents-a365-runtime/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-runtime/setup.py +++ b/libraries/microsoft-agents-a365-runtime/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/docs/design.md b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/docs/design.md index 569f5860..7d8af5ee 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/docs/design.md +++ b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/docs/design.md @@ -76,6 +76,87 @@ mcp_tool = MCPStreamableHTTPTool( ) ``` +### Chat History API + +The service provides methods to send chat history to the MCP platform for real-time threat protection analysis. This enables security scanning of conversation content. + +#### send_chat_history_messages + +The primary method for sending chat history. Converts Agent Framework `ChatMessage` objects to the `ChatHistoryMessage` format expected by the MCP platform. + +```python +from agent_framework import ChatMessage, Role + +service = McpToolRegistrationService() + +# Create messages +messages = [ + ChatMessage(role=Role.USER, text="Hello, how are you?"), + ChatMessage(role=Role.ASSISTANT, text="I'm doing well, thank you!"), +] + +# Send to MCP platform for threat protection +result = await service.send_chat_history_messages(messages, turn_context) + +if result.succeeded: + print("Chat history sent successfully") +else: + print(f"Failed: {result.errors}") +``` + +#### send_chat_history_from_store + +A convenience method that extracts messages from a `ChatMessageStoreProtocol` and delegates to `send_chat_history_messages`. + +```python +# Using a ChatMessageStore directly +result = await service.send_chat_history_from_store( + thread.chat_message_store, + turn_context +) +``` + +#### Chat History API Parameters + +| Method | Parameter | Type | Description | +|--------|-----------|------|-------------| +| `send_chat_history_messages` | `chat_messages` | `Sequence[ChatMessage]` | Messages to send | +| | `turn_context` | `TurnContext` | Conversation context | +| | `tool_options` | `ToolOptions \| None` | Optional configuration | +| `send_chat_history_from_store` | `chat_message_store` | `ChatMessageStoreProtocol` | Message store | +| | `turn_context` | `TurnContext` | Conversation context | +| | `tool_options` | `ToolOptions \| None` | Optional configuration | + +#### Chat History Integration Flow + +``` +Agent Framework ChatMessage objects + │ + ▼ +McpToolRegistrationService.send_chat_history_messages() + │ + ├── Convert ChatMessage → ChatHistoryMessage + │ ├── Extract role via .value property + │ ├── Generate UUID if message_id is None + │ ├── Filter out empty/whitespace content + │ └── Filter out None roles + │ + ▼ +McpToolServerConfigurationService.send_chat_history() + │ + ▼ +MCP Platform Real-Time Threat Protection Endpoint +``` + +#### Message Filtering Behavior + +The conversion process filters out invalid messages: +- Messages with `None` role are skipped (logged at WARNING level) +- Messages with empty or whitespace-only content are skipped +- If all messages are filtered out, the method returns success without calling the backend + +This ensures only valid, meaningful messages are sent for threat analysis. + ## File Structure ``` diff --git a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/__init__.py b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/__init__.py index b06cfe4e..c4ba4378 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/__init__.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Agent 365 Tooling Agent Framework Extensions diff --git a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/services/__init__.py b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/services/__init__.py index 6f38d194..d0ff91ab 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/services/__init__.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/services/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Services module for Agent Framework tooling. diff --git a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/services/mcp_tool_registration_service.py b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/services/mcp_tool_registration_service.py index 267680d6..c4b1ec88 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/services/mcp_tool_registration_service.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/services/mcp_tool_registration_service.py @@ -1,21 +1,24 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. -from typing import Optional, List, Any, Union import logging +import uuid +from datetime import datetime, timezone +from typing import Any, List, Optional, Sequence, Union -from agent_framework import ChatAgent, MCPStreamableHTTPTool +from agent_framework import ChatAgent, ChatMessage, ChatMessageStoreProtocol, MCPStreamableHTTPTool from agent_framework.azure import AzureOpenAIChatClient from agent_framework.openai import OpenAIChatClient from microsoft_agents.hosting.core import Authorization, TurnContext +from microsoft_agents_a365.runtime import 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, ) @@ -96,14 +99,10 @@ async def add_tool_servers_to_agent( # Add servers as MCPStreamableHTTPTool instances for config in server_configs: - try: - server_url = getattr(config, "server_url", None) or getattr( - config, "mcp_server_unique_name", None - ) - if not server_url: - self._logger.warning(f"MCP server config missing server_url: {config}") - continue + # Use mcp_server_name if available (not None or empty), otherwise fall back to mcp_server_unique_name + server_name = config.mcp_server_name or config.mcp_server_unique_name + try: # Prepare auth headers headers = {} if auth_token: @@ -115,18 +114,16 @@ async def add_tool_servers_to_agent( self._orchestrator_name ) - server_name = getattr(config, "mcp_server_name", "Unknown") - # Create and configure MCPStreamableHTTPTool mcp_tools = MCPStreamableHTTPTool( name=server_name, - url=server_url, + url=config.url, headers=headers, description=f"MCP tools from {server_name}", ) # Let Agent Framework handle the connection automatically - self._logger.info(f"Created MCP plugin for '{server_name}' at {server_url}") + self._logger.info(f"Created MCP plugin for '{server_name}' at {config.url}") all_tools.append(mcp_tools) self._connected_servers.append(mcp_tools) @@ -134,7 +131,6 @@ async def add_tool_servers_to_agent( self._logger.info(f"Added MCP plugin '{server_name}' to agent tools") except Exception as tool_ex: - server_name = getattr(config, "mcp_server_name", "Unknown") self._logger.warning( f"Failed to create MCP plugin for {server_name}: {tool_ex}" ) @@ -154,6 +150,186 @@ async def add_tool_servers_to_agent( self._logger.error(f"Failed to add tool servers to agent: {ex}") raise + def _convert_chat_messages_to_history( + self, + chat_messages: Sequence[ChatMessage], + ) -> List[ChatHistoryMessage]: + """ + Convert Agent Framework ChatMessage objects to ChatHistoryMessage format. + + This internal helper method transforms Agent Framework's native ChatMessage + objects into the ChatHistoryMessage format expected by the MCP platform's + real-time threat protection endpoint. + + Args: + chat_messages: Sequence of ChatMessage objects to convert. + + Returns: + List of ChatHistoryMessage objects ready for the MCP platform. + + Note: + - If message_id is None, a new UUID is generated + - Role is extracted via the .value property of the Role object + - Timestamp is set to current UTC time (ChatMessage has no timestamp) + - Messages with empty or whitespace-only content are filtered out and + logged at WARNING level. This is because ChatHistoryMessage requires + non-empty content for validation. The filtered messages will not be + sent to the MCP platform. + """ + history_messages: List[ChatHistoryMessage] = [] + current_time = datetime.now(timezone.utc) + + for msg in chat_messages: + message_id = msg.message_id if msg.message_id is not None else str(uuid.uuid4()) + if msg.role is None: + self._logger.warning( + "Skipping message %s with missing role during conversion", message_id + ) + continue + # Defensive handling: use .value if role is an enum, otherwise convert to string + role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + content = msg.text if msg.text is not None else "" + + # Skip messages with empty content as ChatHistoryMessage validates non-empty content + if not content.strip(): + self._logger.warning( + "Skipping message %s with empty content during conversion", message_id + ) + continue + + history_message = ChatHistoryMessage( + id=message_id, + role=role, + content=content, + timestamp=current_time, + ) + history_messages.append(history_message) + + self._logger.debug( + "Converted message %s with role '%s' to ChatHistoryMessage", message_id, role + ) + + return history_messages + + async def send_chat_history_messages( + self, + chat_messages: Sequence[ChatMessage], + turn_context: TurnContext, + tool_options: Optional[ToolOptions] = None, + ) -> OperationResult: + """ + Send chat history messages to the MCP platform for real-time threat protection. + + This is the primary implementation method that handles message conversion + and delegation to the core tooling service. + + Args: + chat_messages: Sequence of Agent Framework ChatMessage objects to send. + turn_context: TurnContext from the Agents SDK containing conversation info. + tool_options: Optional configuration for the request. Defaults to + AgentFramework-specific options if not provided. + + Returns: + OperationResult indicating success or failure of the operation. + + Raises: + ValueError: If chat_messages or turn_context is None. + + Example: + >>> service = McpToolRegistrationService() + >>> messages = [ChatMessage(role=Role.USER, text="Hello")] + >>> result = await service.send_chat_history_messages(messages, turn_context) + >>> if result.succeeded: + ... print("Chat history sent successfully") + """ + # Input validation + if chat_messages is None: + raise ValueError("chat_messages cannot be None") + + if turn_context is None: + raise ValueError("turn_context cannot be None") + + # Handle empty messages - return success with warning + if len(chat_messages) == 0: + self._logger.warning("Empty message list provided to send_chat_history_messages") + return OperationResult.success() + + self._logger.info(f"Send chat history initiated with {len(chat_messages)} messages") + + # Use default options if not provided + if tool_options is None: + tool_options = ToolOptions(orchestrator_name=self._orchestrator_name) + + # Convert messages to ChatHistoryMessage format + history_messages = self._convert_chat_messages_to_history(chat_messages) + + # Check if all messages were filtered out during conversion + if len(history_messages) == 0: + self._logger.warning("All messages were filtered out during conversion (empty content)") + return OperationResult.success() + + # Delegate to core service + result = await self._mcp_server_configuration_service.send_chat_history( + turn_context=turn_context, + chat_history_messages=history_messages, + options=tool_options, + ) + + if result.succeeded: + self._logger.info( + f"Chat history sent successfully with {len(history_messages)} messages" + ) + else: + self._logger.error(f"Failed to send chat history: {result}") + + return result + + async def send_chat_history_from_store( + self, + chat_message_store: ChatMessageStoreProtocol, + turn_context: TurnContext, + tool_options: Optional[ToolOptions] = None, + ) -> OperationResult: + """ + Send chat history from a ChatMessageStore to the MCP platform. + + This is a convenience method that extracts messages from the store + and delegates to send_chat_history_messages(). + + Args: + chat_message_store: ChatMessageStore containing the conversation history. + turn_context: TurnContext from the Agents SDK containing conversation info. + tool_options: Optional configuration for the request. + + Returns: + OperationResult indicating success or failure of the operation. + + Raises: + ValueError: If chat_message_store or turn_context is None. + + Example: + >>> service = McpToolRegistrationService() + >>> result = await service.send_chat_history_from_store( + ... thread.chat_message_store, turn_context + ... ) + """ + # Input validation + 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") + + # Extract messages from the store + messages = await chat_message_store.list_messages() + + # Delegate to the primary implementation + return await self.send_chat_history_messages( + chat_messages=messages, + turn_context=turn_context, + tool_options=tool_options, + ) + async def cleanup(self): """Clean up any resources used by the service.""" try: diff --git a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/pyproject.toml b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/pyproject.toml index a25e8091..030b08b0 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/pyproject.toml @@ -67,6 +67,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/setup.py b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/setup.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/__init__.py b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/__init__.py index 33ef7cb5..08bcbb81 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/__init__.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Azure Foundry Services Module. 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 1342b870..5677c72c 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 @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ MCP Tool Registration Service implementation for Azure Foundry. @@ -178,8 +179,11 @@ async def _get_mcp_tool_definitions_and_resources( else server.mcp_server_name ) + # Use the URL from server (always populated by the configuration service) + server_url = server.url + # Create MCP tool using Azure Foundry SDK - mcp_tool = McpTool(server_label=server_label, server_url=server.mcp_server_unique_name) + mcp_tool = McpTool(server_label=server_label, server_url=server_url) # Configure the tool mcp_tool.set_approval_mode("never") diff --git a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/pyproject.toml b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/pyproject.toml index 58ae9ed7..29a6f7a1 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/pyproject.toml @@ -66,6 +66,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/setup.py b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/setup.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/__init__.py b/libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/__init__.py index c2d53bd0..6e77b7df 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/__init__.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/__init__.py @@ -2,10 +2,21 @@ # Licensed under the MIT License. """ -OpenAI extensions for Microsoft Agent 365 Tooling SDK +OpenAI extensions for Microsoft Agent 365 Tooling SDK. Tooling and utilities specifically for OpenAI framework integration. -Provides OpenAI-specific helper utilities. +Provides OpenAI-specific helper utilities including: +- McpToolRegistrationService: Service for MCP tool registration and chat history management + +For type hints, use the types directly from the OpenAI Agents SDK: +- agents.memory.Session: Protocol for session objects +- agents.items.TResponseInputItem: Type for input message items """ +from .mcp_tool_registration_service import McpToolRegistrationService + __version__ = "1.0.0" + +__all__ = [ + "McpToolRegistrationService", +] diff --git a/libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/mcp_tool_registration_service.py b/libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/mcp_tool_registration_service.py index 1d6e6cc9..7708b4d2 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/mcp_tool_registration_service.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/mcp_tool_registration_service.py @@ -1,30 +1,40 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. -from typing import Dict, Optional -from dataclasses import dataclass -import logging +""" +MCP Tool Registration Service for OpenAI. -from agents import Agent +This module provides OpenAI-specific extensions for MCP tool registration, +including methods to send chat history from OpenAI Sessions and message lists. +""" -from microsoft_agents.hosting.core import Authorization, TurnContext +import logging +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Dict, List, Optional +from agents import Agent +from agents.items import TResponseInputItem from agents.mcp import ( MCPServerStreamableHttp, MCPServerStreamableHttpParams, ) +from agents.memory import Session +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, ) -# TODO: This is not needed. Remove this. @dataclass class MCPServerInfo: """Information about an MCP server""" @@ -59,7 +69,7 @@ async def add_tool_servers_to_agent( auth_handler_name: str, context: TurnContext, auth_token: Optional[str] = None, - ): + ) -> Agent: """ Add new MCP servers to the agent by creating a new Agent instance. @@ -78,7 +88,7 @@ async def add_tool_servers_to_agent( New Agent instance with all MCP servers, or original agent if no new servers """ - if not auth_token: + if auth_token is None or auth_token.strip() == "": scopes = get_mcp_platform_authentication_scope() authToken = await auth.exchange_token(context, scopes, auth_handler_name) auth_token = authToken.token @@ -101,9 +111,13 @@ async def add_tool_servers_to_agent( # Convert MCP server configs to MCPServerInfo objects mcp_servers_info = [] for server_config in mcp_server_configs: + # Use mcp_server_name if available (not None or empty), otherwise fall back to mcp_server_unique_name + server_name = server_config.mcp_server_name or server_config.mcp_server_unique_name + # Use the URL from config (always populated by the configuration service) + server_url = server_config.url server_info = MCPServerInfo( - name=server_config.mcp_server_name, - url=server_config.mcp_server_unique_name, + name=server_name, + url=server_url, ) mcp_servers_info.append(server_info) @@ -180,8 +194,6 @@ async def add_tool_servers_to_agent( all_mcp_servers = existing_mcp_servers + new_mcp_servers # Recreate the agent with all MCP servers - from agents import Agent - new_agent = Agent( name=agent.name, model=agent.model, @@ -213,12 +225,12 @@ async def add_tool_servers_to_agent( # Clean up connected servers if agent creation fails self._logger.error(f"Failed to recreate agent with new MCP servers: {e}") await self._cleanup_servers(connected_servers) - raise e + raise self._logger.info("No new MCP servers to add to agent") return agent - async def _cleanup_servers(self, servers): + async def _cleanup_servers(self, servers: List[MCPServerStreamableHttp]) -> None: """Clean up connected MCP servers""" for server in servers: try: @@ -228,8 +240,430 @@ async def _cleanup_servers(self, servers): # Log cleanup errors but don't raise them self._logger.debug(f"Error during server cleanup: {e}") - async def cleanup_all_servers(self): + async def cleanup_all_servers(self) -> None: """Clean up all connected MCP servers""" if hasattr(self, "_connected_servers"): await self._cleanup_servers(self._connected_servers) self._connected_servers = [] + + # -------------------------------------------------------------------------- + # SEND CHAT HISTORY - OpenAI-specific implementations + # -------------------------------------------------------------------------- + + async def send_chat_history( + self, + turn_context: TurnContext, + session: Session, + limit: Optional[int] = None, + options: Optional[ToolOptions] = None, + ) -> OperationResult: + """ + Extract chat history from an OpenAI Session and send it to the MCP platform. + + This method extracts messages from an OpenAI Session object using get_items() + and sends them to the MCP platform for real-time threat protection. + + Args: + turn_context: TurnContext from the Agents SDK containing conversation info. + Must have a valid activity with conversation.id, activity.id, + and activity.text. + session: OpenAI Session instance to extract messages from. Must support + the get_items() method which returns a list of TResponseInputItem. + limit: Optional maximum number of items to retrieve from session. + If None, retrieves all items. + options: Optional ToolOptions for customization. If not provided, + uses default options with orchestrator_name="OpenAI". + + Returns: + OperationResult indicating success or failure. On success, returns + OperationResult.success(). On failure, returns OperationResult.failed() + with error details. + + Raises: + ValueError: If turn_context is None or session is None. + + Example: + >>> from agents import Agent, Runner + >>> from microsoft_agents_a365.tooling.extensions.openai import ( + ... McpToolRegistrationService + ... ) + >>> + >>> service = McpToolRegistrationService() + >>> agent = Agent(name="my-agent", model="gpt-4") + >>> + >>> # In your agent handler: + >>> async with Runner.run(agent, messages) as result: + ... session = result.session + ... op_result = await service.send_chat_history( + ... turn_context, session + ... ) + ... if op_result.succeeded: + ... print("Chat history sent successfully") + """ + # Validate inputs + if turn_context is None: + raise ValueError("turn_context cannot be None") + if session is None: + raise ValueError("session cannot be None") + + try: + # Extract messages from session + self._logger.info("Extracting messages from OpenAI session") + if limit is not None: + messages = session.get_items(limit=limit) + else: + messages = session.get_items() + + self._logger.debug(f"Retrieved {len(messages)} items from session") + + # Delegate to the list-based method + return await self.send_chat_history_messages( + turn_context=turn_context, + messages=messages, + options=options, + ) + except ValueError: + # Re-raise validation errors + raise + except Exception as ex: + self._logger.error(f"Failed to send chat history from session: {ex}") + return OperationResult.failed(OperationError(ex)) + + async def send_chat_history_messages( + self, + turn_context: TurnContext, + messages: List[TResponseInputItem], + options: Optional[ToolOptions] = None, + ) -> OperationResult: + """ + Send OpenAI chat history messages to the MCP platform for threat protection. + + This method accepts a list of OpenAI TResponseInputItem messages, converts + them to ChatHistoryMessage format, and sends them to the MCP platform. + + Args: + turn_context: TurnContext from the Agents SDK containing conversation info. + Must have a valid activity with conversation.id, activity.id, + and activity.text. + messages: List of OpenAI TResponseInputItem messages to send. Supports + UserMessage, AssistantMessage, SystemMessage, and other OpenAI + message types. + options: Optional ToolOptions for customization. If not provided, + uses default options with orchestrator_name="OpenAI". + + Returns: + OperationResult indicating success or failure. On success, returns + OperationResult.success(). On failure, returns OperationResult.failed() + with error details. + + Raises: + ValueError: If turn_context is None or messages is None. + + Example: + >>> from microsoft_agents_a365.tooling.extensions.openai import ( + ... McpToolRegistrationService + ... ) + >>> + >>> service = McpToolRegistrationService() + >>> messages = [ + ... {"role": "user", "content": "Hello"}, + ... {"role": "assistant", "content": "Hi there!"}, + ... ] + >>> + >>> result = await service.send_chat_history_messages( + ... turn_context, messages + ... ) + >>> if result.succeeded: + ... print("Chat history sent successfully") + """ + # 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: + self._logger.info("Empty message list provided, returning success") + return OperationResult.success() + + self._logger.info(f"Sending {len(messages)} OpenAI messages as chat history") + + # Set default options + if options is None: + options = ToolOptions(orchestrator_name=self._orchestrator_name) + elif options.orchestrator_name is None: + options.orchestrator_name = self._orchestrator_name + + try: + # Convert OpenAI messages to ChatHistoryMessage format + chat_history_messages = self._convert_openai_messages_to_chat_history(messages) + + if len(chat_history_messages) == 0: + self._logger.warning("No messages could be converted to chat history format") + return OperationResult.success() + + self._logger.debug( + f"Converted {len(chat_history_messages)} messages to ChatHistoryMessage format" + ) + + # Delegate to core service + return await self.config_service.send_chat_history( + turn_context=turn_context, + chat_history_messages=chat_history_messages, + options=options, + ) + 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)) + + # -------------------------------------------------------------------------- + # PRIVATE HELPER METHODS - Message Conversion + # -------------------------------------------------------------------------- + + def _convert_openai_messages_to_chat_history( + self, messages: List[TResponseInputItem] + ) -> List[ChatHistoryMessage]: + """ + Convert a list of OpenAI messages to ChatHistoryMessage format. + + Args: + messages: List of OpenAI TResponseInputItem messages. + + Returns: + List of ChatHistoryMessage objects. Messages that cannot be converted + are filtered out with a warning log. + """ + chat_history_messages: List[ChatHistoryMessage] = [] + + for idx, message in enumerate(messages): + converted = self._convert_single_message(message, idx) + if converted is not None: + chat_history_messages.append(converted) + + self._logger.info( + f"Converted {len(chat_history_messages)} of {len(messages)} messages " + "to ChatHistoryMessage format" + ) + return chat_history_messages + + def _convert_single_message( + self, message: TResponseInputItem, index: int = 0 + ) -> Optional[ChatHistoryMessage]: + """ + Convert a single OpenAI message to ChatHistoryMessage format. + + Args: + message: Single OpenAI TResponseInputItem message. + index: Index of the message in the list (for logging). + + Returns: + ChatHistoryMessage object or None if conversion fails. + """ + try: + role = self._extract_role(message) + content = self._extract_content(message) + msg_id = self._extract_id(message) + timestamp = self._extract_timestamp(message) + + self._logger.debug( + f"Converting message {index}: role={role}, " + f"has_id={msg_id is not None}, has_timestamp={timestamp is not None}" + ) + + # Skip messages with empty content after extraction + # The ChatHistoryMessage validator requires non-empty content + if not content or not content.strip(): + self._logger.warning(f"Message {index} has empty content, skipping") + return None + + return ChatHistoryMessage( + id=msg_id, + role=role, + content=content, + timestamp=timestamp, + ) + except Exception as ex: + self._logger.error(f"Failed to convert message {index}: {ex}") + return None + + def _extract_role(self, message: TResponseInputItem) -> str: + """ + Extract the role from an OpenAI message. + + Role mapping: + - UserMessage or role="user" -> "user" + - AssistantMessage or role="assistant" -> "assistant" + - SystemMessage or role="system" -> "system" + - ResponseOutputMessage with role="assistant" -> "assistant" + - Unknown types -> "user" (default fallback with warning) + + Args: + message: OpenAI message object. + + Returns: + Role string: "user", "assistant", or "system". + """ + # Check for role attribute directly + if hasattr(message, "role"): + role = message.role + if role in ("user", "assistant", "system"): + return role + + # Check message type by class name + type_name = type(message).__name__ + + if "UserMessage" in type_name or "user" in type_name.lower(): + return "user" + elif "AssistantMessage" in type_name or "assistant" in type_name.lower(): + return "assistant" + elif "SystemMessage" in type_name or "system" in type_name.lower(): + return "system" + elif "ResponseOutputMessage" in type_name: + # ResponseOutputMessage typically has role attribute + if hasattr(message, "role") and message.role == "assistant": + return "assistant" + return "assistant" # Default for response output + + # For dict-like objects + if isinstance(message, dict): + role = message.get("role", "") + if role in ("user", "assistant", "system"): + return role + + # Default fallback with warning + self._logger.warning(f"Unknown message type {type_name}, defaulting to 'user' role") + return "user" + + def _extract_content(self, message: TResponseInputItem) -> str: + """ + Extract text content from an OpenAI message. + + Content extraction priority: + 1. If message has .content as string -> use directly + 2. If message has .content as list -> concatenate all text parts + 3. If message has .text attribute -> use directly + 4. If content is empty/None -> return empty string with warning + + Args: + message: OpenAI message object. + + Returns: + Extracted text content as string. + """ + content = "" + + # Try .content attribute first + if hasattr(message, "content"): + raw_content = message.content + + if isinstance(raw_content, str): + content = raw_content + elif isinstance(raw_content, list): + # Concatenate text parts from content list + text_parts = [] + for part in raw_content: + if isinstance(part, str): + text_parts.append(part) + elif hasattr(part, "text"): + text_parts.append(str(part.text)) + elif isinstance(part, dict): + if "text" in part: + text_parts.append(str(part["text"])) + elif part.get("type") == "text" and "text" in part: + text_parts.append(str(part["text"])) + content = " ".join(text_parts) + + # Try .text attribute as fallback + if not content and hasattr(message, "text"): + content = str(message.text) if message.text else "" + + # Try dict-like access + if not content and isinstance(message, dict): + content = message.get("content", "") or message.get("text", "") or "" + if isinstance(content, list): + text_parts = [] + for part in content: + if isinstance(part, str): + text_parts.append(part) + elif isinstance(part, dict) and "text" in part: + text_parts.append(str(part["text"])) + content = " ".join(text_parts) + + if not content: + self._logger.warning("Message has empty content, using empty string") + + return content + + def _extract_id(self, message: TResponseInputItem) -> str: + """ + Extract or generate a unique ID for the message. + + If the message has an existing ID, it is preserved. Otherwise, + a new UUID is generated. + + Args: + message: OpenAI message object. + + Returns: + Message ID as string. + """ + # Try to get existing ID + existing_id = None + + if hasattr(message, "id") and message.id: + existing_id = str(message.id) + elif isinstance(message, dict) and message.get("id"): + existing_id = str(message["id"]) + + if existing_id: + return existing_id + + # Generate new UUID + generated_id = str(uuid.uuid4()) + self._logger.debug(f"Generated UUID {generated_id} for message without ID") + return generated_id + + def _extract_timestamp(self, message: TResponseInputItem) -> datetime: + """ + Extract or generate a timestamp for the message. + + If the message has an existing timestamp, it is preserved. Otherwise, + the current UTC time is used. + + Args: + message: OpenAI message object. + + Returns: + Timestamp as datetime object. + """ + # Try to get existing timestamp + existing_timestamp = None + + if hasattr(message, "timestamp") and message.timestamp: + existing_timestamp = message.timestamp + elif hasattr(message, "created_at") and message.created_at: + existing_timestamp = message.created_at + elif isinstance(message, dict): + existing_timestamp = message.get("timestamp") or message.get("created_at") + + if existing_timestamp: + # Convert to datetime if needed + if isinstance(existing_timestamp, datetime): + return existing_timestamp + elif isinstance(existing_timestamp, (int, float)): + # Unix timestamp + return datetime.fromtimestamp(existing_timestamp, tz=timezone.utc) + elif isinstance(existing_timestamp, str): + # Try ISO format parsing + try: + return datetime.fromisoformat(existing_timestamp.replace("Z", "+00:00")) + except ValueError: + pass + + # Use current UTC time + self._logger.debug("Using current UTC time for message without timestamp") + return datetime.now(timezone.utc) diff --git a/libraries/microsoft-agents-a365-tooling-extensions-openai/pyproject.toml b/libraries/microsoft-agents-a365-tooling-extensions-openai/pyproject.toml index 76f02b70..449ac55e 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-openai/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling-extensions-openai/pyproject.toml @@ -65,6 +65,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-tooling-extensions-openai/setup.py b/libraries/microsoft-agents-a365-tooling-extensions-openai/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-openai/setup.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-openai/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/__init__.py b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/__init__.py index e981e2aa..02de5908 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/__init__.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Services module for Semantic Kernel tooling. diff --git a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/mcp_tool_registration_service.py b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/mcp_tool_registration_service.py index e4180319..987cd97d 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/mcp_tool_registration_service.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/mcp_tool_registration_service.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ MCP Tool Registration Service implementation for Semantic Kernel. @@ -20,7 +21,7 @@ from microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service import ( McpToolServerConfigurationService, ) -from microsoft_agents_a365.tooling.models import MCPServerConfig, ToolOptions +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, @@ -125,9 +126,15 @@ async def add_tool_servers_to_agent( self._orchestrator_name ) + # Use the URL from server (always populated by the configuration service) + server_url = server.url + + # Use mcp_server_name if available (not None or empty), otherwise fall back to mcp_server_unique_name + server_name = server.mcp_server_name or server.mcp_server_unique_name + plugin = MCPStreamableHttpPlugin( - name=server.mcp_server_name, - url=server.mcp_server_unique_name, + name=server_name, + url=server_url, headers=headers, ) @@ -135,7 +142,7 @@ async def add_tool_servers_to_agent( await plugin.connect() # Add plugin to kernel - kernel.add_plugin(plugin, server.mcp_server_name) + kernel.add_plugin(plugin, server_name) # Store reference to keep plugin alive throughout application lifecycle # By storing plugin references in _connected_plugins, we prevent Python's garbage collector from cleaning up the plugin objects diff --git a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/pyproject.toml b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/pyproject.toml index 8de328a1..35f94b93 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/pyproject.toml @@ -65,6 +65,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/setup.py b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/setup.py +++ b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/__init__.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/__init__.py index 567db1bb..edee7a4a 100644 --- a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/__init__.py +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/__init__.py @@ -27,3 +27,6 @@ "get_mcp_base_url", "build_mcp_server_url", ] + +# Enable namespace package extension for tooling-extensions-* packages +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/extensions/__init__.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/extensions/__init__.py new file mode 100644 index 00000000..3e7da8a5 --- /dev/null +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/extensions/__init__.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Microsoft Agent 365 Tooling Extensions namespace package. + +This file enables the `microsoft_agents_a365.tooling.extensions` namespace +to span multiple installed packages (e.g., extensions-openai, extensions-agentframework). +""" + +import sys +from pkgutil import extend_path + +# Standard pkgutil-style namespace extension +__path__ = extend_path(__path__, __name__) + +# For editable installs with custom finders, manually discover extension paths +for finder in sys.meta_path: + if hasattr(finder, "find_spec"): + try: + spec = finder.find_spec(__name__, None) + if spec is not None and spec.submodule_search_locations: + for path in spec.submodule_search_locations: + if path not in __path__ and not path.endswith(".__path_hook__"): + __path__.append(path) + except (ImportError, TypeError): + # Some meta path finders may not support this namespace and can raise + # ImportError or TypeError; ignore these and continue discovering paths. + pass diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/mcp_server_config.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/mcp_server_config.py index b8526c13..42ab90bb 100644 --- a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/mcp_server_config.py +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/mcp_server_config.py @@ -1,10 +1,12 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ MCP Server Configuration model. """ from dataclasses import dataclass +from typing import Optional @dataclass @@ -19,6 +21,10 @@ class MCPServerConfig: #: Gets or sets the unique name of the MCP server. mcp_server_unique_name: str + #: Gets or sets the custom URL for the MCP server. If provided, this URL will be used + #: instead of constructing the URL from the base URL and unique name. + url: Optional[str] = None + def __post_init__(self): """Validate the configuration after initialization.""" if not self.mcp_server_name: diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/tool_options.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/tool_options.py index 9c5c7604..4afbcd37 100644 --- a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/tool_options.py +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/tool_options.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Tooling Options model. diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/__init__.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/__init__.py index fc669b72..c36e427b 100644 --- a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/__init__.py +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ MCP tooling services package. diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/mcp_tool_server_configuration_service.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/mcp_tool_server_configuration_service.py index df1ae5b2..71d2ee5d 100644 --- a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/mcp_tool_server_configuration_service.py +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/mcp_tool_server_configuration_service.py @@ -412,16 +412,26 @@ def _parse_manifest_server_config( MCPServerConfig object or None if parsing fails. """ try: - name = self._extract_server_name(server_element) - server_name = self._extract_server_unique_name(server_element) + mcp_server_name = self._extract_server_name(server_element) + mcp_server_unique_name = self._extract_server_unique_name(server_element) - if not self._validate_server_strings(name, server_name): + if not self._validate_server_strings(mcp_server_name, mcp_server_unique_name): return None - # Construct full URL using environment utilities - full_url = build_mcp_server_url(server_name) + # Check if a URL is provided + endpoint = self._extract_server_url(server_element) - return MCPServerConfig(mcp_server_name=name, mcp_server_unique_name=full_url) + # Use mcp_server_name if available, otherwise fall back to mcp_server_unique_name for URL construction + server_name = mcp_server_name or mcp_server_unique_name + + # Determine the final URL: use custom URL if provided, otherwise construct it + final_url = endpoint if endpoint else build_mcp_server_url(server_name) + + return MCPServerConfig( + mcp_server_name=mcp_server_name, + mcp_server_unique_name=mcp_server_unique_name, + url=final_url, + ) except Exception: return None @@ -439,13 +449,26 @@ def _parse_gateway_server_config( MCPServerConfig object or None if parsing fails. """ try: - name = self._extract_server_name(server_element) - endpoint = self._extract_server_unique_name(server_element) + mcp_server_name = self._extract_server_name(server_element) + mcp_server_unique_name = self._extract_server_unique_name(server_element) - if not self._validate_server_strings(name, endpoint): + if not self._validate_server_strings(mcp_server_name, mcp_server_unique_name): return None - return MCPServerConfig(mcp_server_name=name, mcp_server_unique_name=endpoint) + # Check if a URL is provided by the gateway + endpoint = self._extract_server_url(server_element) + + # Use mcp_server_name if available, otherwise fall back to mcp_server_unique_name for URL construction + server_name = mcp_server_name or mcp_server_unique_name + + # Determine the final URL: use custom URL if provided, otherwise construct it + final_url = endpoint if endpoint else build_mcp_server_url(server_name) + + return MCPServerConfig( + mcp_server_name=mcp_server_name, + mcp_server_unique_name=mcp_server_unique_name, + url=final_url, + ) except Exception: return None @@ -500,6 +523,21 @@ def _extract_server_unique_name(self, server_element: Dict[str, Any]) -> Optiona return server_element["mcpServerUniqueName"] return None + def _extract_server_url(self, server_element: Dict[str, Any]) -> Optional[str]: + """ + Extracts custom server URL from configuration element. + + Args: + server_element: Configuration dictionary. + + Returns: + Server URL string or None. + """ + # Check for 'url' field in both manifest and gateway responses + if "url" in server_element and isinstance(server_element["url"], str): + return server_element["url"] + return None + def _validate_server_strings(self, name: Optional[str], unique_name: Optional[str]) -> bool: """ Validates that server name and unique name are valid strings. @@ -561,8 +599,13 @@ async def send_chat_history( # Validate input parameters if turn_context is None: raise ValueError("turn_context cannot be None") - if chat_history_messages is None or len(chat_history_messages) == 0: - raise ValueError("chat_history_messages cannot be None or empty") + if chat_history_messages is None: + raise ValueError("chat_history_messages cannot be None") + + # Handle empty messages - return success with warning (consistent with extension behavior) + if len(chat_history_messages) == 0: + self._logger.warning("Empty message list provided to send_chat_history") + return OperationResult.success() # Extract required information from turn context if not turn_context.activity: diff --git a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/utils/constants.py b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/utils/constants.py index 46f3e03c..c788447d 100644 --- a/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/utils/constants.py +++ b/libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/utils/constants.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. """ Provides constant values used throughout the Tooling components. diff --git a/libraries/microsoft-agents-a365-tooling/pyproject.toml b/libraries/microsoft-agents-a365-tooling/pyproject.toml index 354480c2..77fbceec 100644 --- a/libraries/microsoft-agents-a365-tooling/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling/pyproject.toml @@ -62,6 +62,10 @@ target-version = ['py311'] line-length = 100 target-version = "py311" +[tool.ruff.lint.flake8-copyright] +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." +min-file-size = 1 + [tool.mypy] python_version = "3.11" strict = true diff --git a/libraries/microsoft-agents-a365-tooling/setup.py b/libraries/microsoft-agents-a365-tooling/setup.py index 1c812d3b..d311d241 100644 --- a/libraries/microsoft-agents-a365-tooling/setup.py +++ b/libraries/microsoft-agents-a365-tooling/setup.py @@ -13,7 +13,7 @@ helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" sys.path.insert(0, str(helper_path)) -from setup_utils import get_dynamic_dependencies +from setup_utils import get_dynamic_dependencies # noqa: E402 # Use minimum version strategy: # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) diff --git a/pyproject.toml b/pyproject.toml index 4799b21e..e23deb0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,8 +66,11 @@ line-length = 100 target-version = "py311" preview = true +[tool.ruff.lint] +preview = true + # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. -lint.select = [ +select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # Pyflakes @@ -78,13 +81,13 @@ lint.select = [ "CPY", ] -lint.ignore = [ +ignore = [ "E501", # Line too long, handled by formatter ] # Allow fix for all enabled rules (when `--fix`) is provided. -lint.fixable = ["ALL"] -lint.unfixable = [] +fixable = ["ALL"] +unfixable = [] # Exclude a variety of commonly ignored directories. exclude = [ @@ -112,14 +115,14 @@ exclude = [ ] [tool.ruff.lint.flake8-copyright] -# Require this exact line anywhere in the first 4096 bytes -notice-rgx = "^# Copyright \\(c\\) Microsoft\\. All rights reserved\\." +# Require this exact copyright header (matches CLAUDE.md specification) +notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." # (optional) ensure all files are checked regardless of size min-file-size = 1 [tool.ruff.lint.per-file-ignores] # Tests can use magic values, assertions, and imports -"tests/*" = ["PLR2004", "S101", "TID252"] +"tests/*" = ["PLR2004", "S101", "TID252", "B903"] "samples/*" = ["B903"] [tool.ruff.format] diff --git a/tests/__init__.py b/tests/__init__.py index 2a50eae8..59e481eb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,2 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/observability/core/exporters/test_utils.py b/tests/observability/core/exporters/test_utils.py index 76b80b59..dda81a7a 100644 --- a/tests/observability/core/exporters/test_utils.py +++ b/tests/observability/core/exporters/test_utils.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import os import unittest diff --git a/tests/observability/core/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py index 6ad13d2f..c3b215b9 100644 --- a/tests/observability/core/test_agent365_exporter.py +++ b/tests/observability/core/test_agent365_exporter.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import json import os diff --git a/tests/observability/core/test_baggage_builder.py b/tests/observability/core/test_baggage_builder.py index e6b398b5..937b3085 100644 --- a/tests/observability/core/test_baggage_builder.py +++ b/tests/observability/core/test_baggage_builder.py @@ -1,5 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. - +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import os import unittest diff --git a/tests/observability/core/test_record_attributes.py b/tests/observability/core/test_record_attributes.py index 95f1ee57..a43b67b2 100644 --- a/tests/observability/core/test_record_attributes.py +++ b/tests/observability/core/test_record_attributes.py @@ -1,5 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. - +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import os import unittest diff --git a/tests/observability/core/test_span_processor.py b/tests/observability/core/test_span_processor.py index a10d52f9..f7bd4f5a 100644 --- a/tests/observability/core/test_span_processor.py +++ b/tests/observability/core/test_span_processor.py @@ -1,5 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. - +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import unittest from unittest.mock import MagicMock diff --git a/tests/observability/extensions/agentframework/integration/test_agentframework_trace_processor.py b/tests/observability/extensions/agentframework/integration/test_agentframework_trace_processor.py index f3e319cb..800282d3 100644 --- a/tests/observability/extensions/agentframework/integration/test_agentframework_trace_processor.py +++ b/tests/observability/extensions/agentframework/integration/test_agentframework_trace_processor.py @@ -18,10 +18,10 @@ # AgentFramework SDK try: - from agent_framework.azure import AzureOpenAIChatClient from agent_framework import ChatAgent, ai_function - from azure.identity import AzureCliCredential + from agent_framework.azure import AzureOpenAIChatClient from agent_framework.observability import setup_observability + from azure.identity import AzureCliCredential except ImportError: pytest.skip( "AgentFramework library and dependencies required for integration tests", diff --git a/tests/observability/extensions/langchain/test_wrapper_langchain.py b/tests/observability/extensions/langchain/test_wrapper_langchain.py index a5ff15e5..0a9c1116 100644 --- a/tests/observability/extensions/langchain/test_wrapper_langchain.py +++ b/tests/observability/extensions/langchain/test_wrapper_langchain.py @@ -1,5 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. - +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import unittest from unittest.mock import MagicMock diff --git a/tests/observability/extensions/openai/test_prompt_suppression.py b/tests/observability/extensions/openai/test_prompt_suppression.py index c5226e2c..2c8e0ffe 100644 --- a/tests/observability/extensions/openai/test_prompt_suppression.py +++ b/tests/observability/extensions/openai/test_prompt_suppression.py @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import unittest diff --git a/tests/observability/extensions/openai/test_wrapper_openaiagents.py b/tests/observability/extensions/openai/test_wrapper_openaiagents.py index a5852c5a..b95c99ce 100644 --- a/tests/observability/extensions/openai/test_wrapper_openaiagents.py +++ b/tests/observability/extensions/openai/test_wrapper_openaiagents.py @@ -1,5 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. - +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import unittest diff --git a/tests/observability/extensions/semantickernel/test_wrapper_semantic_kernel.py b/tests/observability/extensions/semantickernel/test_wrapper_semantic_kernel.py index 99c9a266..ff475dbb 100644 --- a/tests/observability/extensions/semantickernel/test_wrapper_semantic_kernel.py +++ b/tests/observability/extensions/semantickernel/test_wrapper_semantic_kernel.py @@ -1,5 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. - +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import unittest from unittest.mock import MagicMock, patch diff --git a/tests/runtime/test_environment_utils.py b/tests/runtime/test_environment_utils.py index cb0c8077..a79a1b43 100644 --- a/tests/runtime/test_environment_utils.py +++ b/tests/runtime/test_environment_utils.py @@ -4,7 +4,6 @@ """Unit tests for environment_utils module.""" import pytest - from microsoft_agents_a365.runtime.environment_utils import ( PROD_OBSERVABILITY_SCOPE, get_observability_authentication_scope, diff --git a/tests/runtime/test_power_platform_api_discovery.py b/tests/runtime/test_power_platform_api_discovery.py index 4189d44c..9dd27cfb 100644 --- a/tests/runtime/test_power_platform_api_discovery.py +++ b/tests/runtime/test_power_platform_api_discovery.py @@ -4,7 +4,6 @@ """Unit tests for PowerPlatformApiDiscovery class.""" import pytest - from microsoft_agents_a365.runtime.power_platform_api_discovery import ( PowerPlatformApiDiscovery, ) diff --git a/tests/runtime/test_version_utils.py b/tests/runtime/test_version_utils.py index 5da6851b..5e87da4d 100644 --- a/tests/runtime/test_version_utils.py +++ b/tests/runtime/test_version_utils.py @@ -4,8 +4,8 @@ """Unit tests for version_utils module.""" import warnings -import pytest +import pytest from microsoft_agents_a365.runtime.version_utils import build_version diff --git a/tests/tooling/__init__.py b/tests/tooling/__init__.py index 59e481eb..e29b546c 100644 --- a/tests/tooling/__init__.py +++ b/tests/tooling/__init__.py @@ -1,2 +1,4 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. + +"""Tests for tooling components.""" diff --git a/tests/tooling/extensions/__init__.py b/tests/tooling/extensions/__init__.py new file mode 100644 index 00000000..dc3584eb --- /dev/null +++ b/tests/tooling/extensions/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for tooling extensions.""" diff --git a/tests/tooling/extensions/agentframework/__init__.py b/tests/tooling/extensions/agentframework/__init__.py new file mode 100644 index 00000000..eaac7401 --- /dev/null +++ b/tests/tooling/extensions/agentframework/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Test package for Agent Framework tooling extensions.""" diff --git a/tests/tooling/extensions/agentframework/services/__init__.py b/tests/tooling/extensions/agentframework/services/__init__.py new file mode 100644 index 00000000..7d0206f4 --- /dev/null +++ b/tests/tooling/extensions/agentframework/services/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Test package for Agent Framework tooling extension services.""" diff --git a/tests/tooling/extensions/agentframework/services/test_send_chat_history.py b/tests/tooling/extensions/agentframework/services/test_send_chat_history.py new file mode 100644 index 00000000..f3a780a3 --- /dev/null +++ b/tests/tooling/extensions/agentframework/services/test_send_chat_history.py @@ -0,0 +1,559 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for send_chat_history_from_store methods in McpToolRegistrationService.""" + +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents_a365.runtime import OperationError, OperationResult +from microsoft_agents_a365.tooling.extensions.agentframework.services import ( + McpToolRegistrationService, +) +from microsoft_agents_a365.tooling.models import ToolOptions + + +class TestSendChatHistoryAsync: + """Tests for send_chat_history_messages and send_chat_history_from_store methods.""" + + @pytest.fixture + def mock_turn_context(self): + """Create a mock TurnContext with valid activity data.""" + 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_role(self): + """Create a mock Role object with .value property.""" + role = Mock() + role.value = "user" + return role + + @pytest.fixture + def mock_assistant_role(self): + """Create a mock Role object for assistant.""" + role = Mock() + role.value = "assistant" + return role + + @pytest.fixture + def sample_chat_messages(self, mock_role, mock_assistant_role): + """Create sample Agent Framework ChatMessage-like objects.""" + msg1 = Mock() + msg1.message_id = "msg-1" + msg1.role = mock_role + msg1.text = "Hello" + + msg2 = Mock() + msg2.message_id = "msg-2" + msg2.role = mock_assistant_role + msg2.text = "Hi there!" + + return [msg1, msg2] + + @pytest.fixture + def mock_chat_message_store(self, sample_chat_messages): + """Create a mock ChatMessageStoreProtocol.""" + store = AsyncMock() + store.list_messages = AsyncMock(return_value=sample_chat_messages) + return store + + @pytest.fixture + def service(self): + """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 + + # ==================== Validation Tests (8 tests) ==================== + + @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 for None messages.""" + with pytest.raises(ValueError, match="chat_messages cannot be None"): + await service.send_chat_history_messages(None, mock_turn_context) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_validates_turn_context_none( + self, service, sample_chat_messages + ): + """Test that send_chat_history_messages raises ValueError for None turn_context.""" + with pytest.raises(ValueError, match="turn_context cannot be None"): + await service.send_chat_history_messages(sample_chat_messages, None) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_from_store_validates_store_none( + self, service, mock_turn_context + ): + """Test that send_chat_history_from_store raises ValueError for None store.""" + with pytest.raises(ValueError, match="chat_message_store cannot be None"): + await service.send_chat_history_from_store(None, mock_turn_context) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_from_store_validates_turn_context_none( + self, service, mock_chat_message_store + ): + """Test that send_chat_history_from_store raises ValueError for None turn_context.""" + with pytest.raises(ValueError, match="turn_context cannot be None"): + await service.send_chat_history_from_store(mock_chat_message_store, None) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_empty_messages_returns_success( + self, service, mock_turn_context + ): + """Test that empty message list returns success with warning log.""" + # Act + result = await service.send_chat_history_messages([], mock_turn_context) + + # Assert + assert result.succeeded is True + # Core service should not be called for empty messages + service._mcp_server_configuration_service.send_chat_history.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_generates_uuid_for_missing_id( + self, service, mock_turn_context, mock_role + ): + """Test that UUID is generated when message_id is None.""" + # Arrange + msg = Mock() + msg.message_id = None # No message ID + msg.role = mock_role + msg.text = "Hello" + + # Act + await service.send_chat_history_messages([msg], mock_turn_context) + + # Assert + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + history_messages = call_args.kwargs["chat_history_messages"] + + assert len(history_messages) == 1 + # Verify a UUID was generated (not None and valid UUID format) + assert history_messages[0].id is not None + # Use uuid.UUID() to validate format - raises ValueError if invalid + uuid.UUID(history_messages[0].id) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_generates_timestamp( + self, service, mock_turn_context, sample_chat_messages + ): + """Test that current UTC timestamp is generated for messages.""" + # Act + before_time = datetime.now(UTC) + await service.send_chat_history_messages(sample_chat_messages, mock_turn_context) + after_time = datetime.now(UTC) + + # Assert + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + history_messages = call_args.kwargs["chat_history_messages"] + + for msg in history_messages: + assert msg.timestamp is not None + assert before_time <= msg.timestamp <= after_time + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_handles_missing_text( + self, service, mock_turn_context, mock_role + ): + """Test that messages with None text are skipped (empty content not allowed).""" + # Arrange + msg_with_text = Mock() + msg_with_text.message_id = "msg-1" + msg_with_text.role = mock_role + msg_with_text.text = "Hello" + + msg_without_text = Mock() + msg_without_text.message_id = "msg-2" + msg_without_text.role = mock_role + msg_without_text.text = None # No text + + # Act + await service.send_chat_history_messages( + [msg_with_text, msg_without_text], mock_turn_context + ) + + # Assert + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + history_messages = call_args.kwargs["chat_history_messages"] + + # Only the message with text should be included + assert len(history_messages) == 1 + assert history_messages[0].content == "Hello" + + # ==================== Success and Delegation Tests (5 tests) ==================== + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_success( + self, service, mock_turn_context, sample_chat_messages + ): + """Test successful send_chat_history_messages call.""" + # Act + result = await service.send_chat_history_messages(sample_chat_messages, mock_turn_context) + + # Assert + 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_from_store_with_store_success( + self, service, mock_turn_context, mock_chat_message_store + ): + """Test successful send_chat_history_from_store call with ChatMessageStore.""" + # Act + result = await service.send_chat_history_from_store( + mock_chat_message_store, mock_turn_context + ) + + # Assert + assert result.succeeded is True + mock_chat_message_store.list_messages.assert_called_once() + service._mcp_server_configuration_service.send_chat_history.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_from_store_delegates_to_messages_async( + self, service, mock_turn_context, mock_chat_message_store, sample_chat_messages + ): + """Test that send_chat_history_from_store delegates to send_chat_history_messages.""" + # Arrange + with patch.object( + service, "send_chat_history_messages", new_callable=AsyncMock + ) as mock_messages_method: + mock_messages_method.return_value = OperationResult.success() + + # Act + result = await service.send_chat_history_from_store( + mock_chat_message_store, mock_turn_context + ) + + # Assert + assert result.succeeded is True + mock_chat_message_store.list_messages.assert_called_once() + mock_messages_method.assert_called_once_with( + chat_messages=sample_chat_messages, + turn_context=mock_turn_context, + tool_options=None, + ) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_with_tool_options( + self, service, mock_turn_context, sample_chat_messages + ): + """Test that ToolOptions are passed correctly to core service.""" + # Arrange + options = ToolOptions(orchestrator_name="TestOrchestrator") + + # Act + await service.send_chat_history_messages( + sample_chat_messages, mock_turn_context, tool_options=options + ) + + # Assert + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + assert call_args.kwargs["options"] == options + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_converts_messages_correctly( + self, service, mock_turn_context, sample_chat_messages + ): + """Test that ChatMessage objects are correctly converted to ChatHistoryMessage.""" + # Act + await service.send_chat_history_messages(sample_chat_messages, mock_turn_context) + + # Assert + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + history_messages = call_args.kwargs["chat_history_messages"] + + assert len(history_messages) == 2 + + # 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!" + assert history_messages[1].timestamp is not None + + # ==================== Error Handling Tests (4 tests) ==================== + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_handles_http_error( + self, service, mock_turn_context, sample_chat_messages + ): + """Test send_chat_history_messages handles HTTP errors from core service.""" + # Arrange + error = OperationError(Exception("500, Internal Server Error")) + service._mcp_server_configuration_service.send_chat_history = AsyncMock( + return_value=OperationResult.failed(error) + ) + + # Act + result = await service.send_chat_history_messages(sample_chat_messages, mock_turn_context) + + # Assert + assert result.succeeded is False + assert len(result.errors) == 1 + assert "500" in str(result.errors[0].message) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_handles_timeout( + self, service, mock_turn_context, sample_chat_messages + ): + """Test send_chat_history_messages handles timeout errors.""" + # Arrange + error = OperationError(Exception("Request timed out")) + service._mcp_server_configuration_service.send_chat_history = AsyncMock( + return_value=OperationResult.failed(error) + ) + + # Act + result = await service.send_chat_history_messages(sample_chat_messages, mock_turn_context) + + # Assert + 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_messages_handles_connection_error( + self, service, mock_turn_context, sample_chat_messages + ): + """Test send_chat_history_messages handles connection errors.""" + # Arrange + error = OperationError(Exception("Connection failed")) + service._mcp_server_configuration_service.send_chat_history = AsyncMock( + return_value=OperationResult.failed(error) + ) + + # Act + result = await service.send_chat_history_messages(sample_chat_messages, mock_turn_context) + + # Assert + 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_messages_role_value_conversion( + self, service, mock_turn_context + ): + """Test that Role.value is used for string conversion.""" + # Arrange - Create messages with different role values + system_role = Mock() + system_role.value = "system" + + user_role = Mock() + user_role.value = "user" + + assistant_role = Mock() + assistant_role.value = "assistant" + + msg1 = Mock() + msg1.message_id = "msg-1" + msg1.role = system_role + msg1.text = "System prompt" + + msg2 = Mock() + msg2.message_id = "msg-2" + msg2.role = user_role + msg2.text = "User message" + + msg3 = Mock() + msg3.message_id = "msg-3" + msg3.role = assistant_role + msg3.text = "Assistant response" + + # Act + await service.send_chat_history_messages([msg1, msg2, msg3], mock_turn_context) + + # Assert + 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 + assert history_messages[0].role == "system" + assert history_messages[1].role == "user" + assert history_messages[2].role == "assistant" + + # ==================== Additional Coverage Tests (CRM-001, 004, 005, 006, 011) ==================== + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_from_store_propagates_store_exception( + self, service, mock_turn_context + ): + """Test that exceptions from chat_message_store.list_messages() propagate (CRM-001).""" + # Arrange + mock_store = AsyncMock() + mock_store.list_messages = AsyncMock(side_effect=RuntimeError("Store connection failed")) + + # Act & Assert + with pytest.raises(RuntimeError, match="Store connection failed"): + await service.send_chat_history_from_store(mock_store, mock_turn_context) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_skips_whitespace_only_content( + self, service, mock_turn_context, mock_role + ): + """Test that messages with whitespace-only content are filtered out (CRM-004).""" + # Arrange + msg_with_text = Mock() + msg_with_text.message_id = "msg-1" + msg_with_text.role = mock_role + msg_with_text.text = "Valid content" + + msg_whitespace_only = Mock() + msg_whitespace_only.message_id = "msg-2" + msg_whitespace_only.role = mock_role + msg_whitespace_only.text = " \t\n " # Whitespace only + + # Act + await service.send_chat_history_messages( + [msg_with_text, msg_whitespace_only], mock_turn_context + ) + + # Assert + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + history_messages = call_args.kwargs["chat_history_messages"] + + # Only the message with actual content should be included + assert len(history_messages) == 1 + assert history_messages[0].content == "Valid content" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_skips_messages_with_none_role( + self, service, mock_turn_context, mock_role + ): + """Test that messages with None role are filtered out (CRM-005).""" + # Arrange + msg_with_role = Mock() + msg_with_role.message_id = "msg-1" + msg_with_role.role = mock_role + msg_with_role.text = "Valid message" + + msg_without_role = Mock() + msg_without_role.message_id = "msg-2" + msg_without_role.role = None # No role + msg_without_role.text = "This should be skipped" + + # Act + await service.send_chat_history_messages( + [msg_with_role, msg_without_role], mock_turn_context + ) + + # Assert + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + history_messages = call_args.kwargs["chat_history_messages"] + + # Only the message with a role should be included + assert len(history_messages) == 1 + assert history_messages[0].content == "Valid message" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_all_filtered_returns_success( + self, service, mock_turn_context, mock_role + ): + """Test that all messages filtered out returns success without calling core (CRM-006).""" + # Arrange - all messages have empty content + msg1 = Mock() + msg1.message_id = "msg-1" + msg1.role = mock_role + msg1.text = "" # Empty + + msg2 = Mock() + msg2.message_id = "msg-2" + msg2.role = mock_role + msg2.text = " " # Whitespace only + + msg3 = Mock() + msg3.message_id = "msg-3" + msg3.role = None # None role + msg3.text = "Valid text but no role" + + # Act + result = await service.send_chat_history_messages([msg1, msg2, msg3], mock_turn_context) + + # Assert + assert result.succeeded is True + # Core service should not be called when all messages are filtered out + service._mcp_server_configuration_service.send_chat_history.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_creates_default_tool_options( + self, service, mock_turn_context, sample_chat_messages + ): + """Test that default ToolOptions with AgentFramework orchestrator is created (CRM-011).""" + # Act - call without providing tool_options + await service.send_chat_history_messages(sample_chat_messages, mock_turn_context) + + # Assert + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + options = call_args.kwargs["options"] + + assert options is not None + assert options.orchestrator_name == "AgentFramework" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_handles_role_without_value_attribute( + self, service, mock_turn_context + ): + """Test defensive handling when role doesn't have .value attribute (CRM-003).""" + # Arrange - role is a plain string, not an enum + msg = Mock() + msg.message_id = "msg-1" + msg.role = "user" # String, not an enum with .value + msg.text = "Hello" + + # Act + await service.send_chat_history_messages([msg], mock_turn_context) + + # Assert + call_args = service._mcp_server_configuration_service.send_chat_history.call_args + history_messages = call_args.kwargs["chat_history_messages"] + + assert len(history_messages) == 1 + assert history_messages[0].role == "user" diff --git a/tests/tooling/extensions/openai/__init__.py b/tests/tooling/extensions/openai/__init__.py new file mode 100644 index 00000000..1793ec2c --- /dev/null +++ b/tests/tooling/extensions/openai/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for OpenAI tooling extensions.""" diff --git a/tests/tooling/extensions/openai/conftest.py b/tests/tooling/extensions/openai/conftest.py new file mode 100644 index 00000000..9a6f108e --- /dev/null +++ b/tests/tooling/extensions/openai/conftest.py @@ -0,0 +1,295 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Shared pytest fixtures for OpenAI extension tests.""" + +from datetime import UTC, datetime +from typing import TypeAlias +from unittest.mock import Mock + +import pytest + +# -------------------------------------------------------------------------- +# TYPE DEFINITIONS +# -------------------------------------------------------------------------- + +# Content can be string, list of content parts, or None (mimics OpenAI SDK) +MessageContent: TypeAlias = str | list[object] | None + + +# -------------------------------------------------------------------------- +# MOCK OPENAI MESSAGE CLASSES +# -------------------------------------------------------------------------- + + +class MockUserMessage: + """Mock OpenAI UserMessage for testing.""" + + def __init__( + self, + content: MessageContent = "Hello", + id: str | None = None, + timestamp: datetime | None = None, + ): + self.role = "user" + self.content = content + self.id = id + self.timestamp = timestamp + + +class MockAssistantMessage: + """Mock OpenAI AssistantMessage for testing.""" + + def __init__( + self, + content: MessageContent = "Hi there!", + id: str | None = None, + timestamp: datetime | None = None, + ): + self.role = "assistant" + self.content = content + self.id = id + self.timestamp = timestamp + + +class MockSystemMessage: + """Mock OpenAI SystemMessage for testing.""" + + def __init__( + self, + content: MessageContent = "You are a helpful assistant.", + id: str | None = None, + timestamp: datetime | None = None, + ): + self.role = "system" + self.content = content + self.id = id + self.timestamp = timestamp + + +class MockResponseOutputMessage: + """Mock OpenAI ResponseOutputMessage for testing.""" + + def __init__( + self, + content: MessageContent = "Response from agent", + role: str = "assistant", + id: str | None = None, + timestamp: datetime | None = None, + ): + self.role = role + self.content = content + self.id = id + self.timestamp = timestamp + + +class MockUnknownMessage: + """Mock unknown message type for testing fallback behavior.""" + + def __init__(self, content: MessageContent = "Unknown content"): + self.content = content + + +class MockContentPart: + """Mock content part for list-based content.""" + + def __init__(self, text: str): + self.type = "text" + self.text = text + + +# Type alias for mock messages +MockMessage: TypeAlias = ( + MockUserMessage + | MockAssistantMessage + | MockSystemMessage + | MockResponseOutputMessage + | MockUnknownMessage +) + + +class MockSession: + """Mock OpenAI Session for testing.""" + + def __init__(self, items: list[MockMessage] | None = None): + self._items: list[MockMessage] = items or [] + + def get_items(self, limit: int | None = None) -> list[MockMessage]: + """Get items from the session, optionally limited.""" + if limit is not None: + return self._items[:limit] + return self._items + + +# -------------------------------------------------------------------------- +# PYTEST FIXTURES +# -------------------------------------------------------------------------- + + +@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-123" + mock_activity.conversation = mock_conversation + mock_activity.id = "msg-456" + mock_activity.text = "Hello, how are you?" + + mock_context.activity = mock_activity + return mock_context + + +@pytest.fixture +def 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.fixture +def mock_turn_context_no_conversation_id(): + """Create a mock TurnContext with no conversation ID.""" + from microsoft_agents.hosting.core import TurnContext + + mock_context = Mock(spec=TurnContext) + mock_activity = Mock() + mock_activity.conversation = None + mock_activity.id = "msg-456" + mock_activity.text = "Hello" + mock_context.activity = mock_activity + return mock_context + + +@pytest.fixture +def mock_turn_context_no_message_id(): + """Create a mock TurnContext with no message ID.""" + from microsoft_agents.hosting.core import TurnContext + + mock_context = Mock(spec=TurnContext) + mock_activity = Mock() + mock_conversation = Mock() + mock_conversation.id = "conv-123" + mock_activity.conversation = mock_conversation + mock_activity.id = None + mock_activity.text = "Hello" + mock_context.activity = mock_activity + return mock_context + + +@pytest.fixture +def mock_turn_context_no_user_message(): + """Create a mock TurnContext with no user message text.""" + from microsoft_agents.hosting.core import TurnContext + + mock_context = Mock(spec=TurnContext) + mock_activity = Mock() + mock_conversation = Mock() + mock_conversation.id = "conv-123" + mock_activity.conversation = mock_conversation + mock_activity.id = "msg-456" + mock_activity.text = None + mock_context.activity = mock_activity + return mock_context + + +@pytest.fixture +def sample_user_message(): + """Create a sample user message.""" + return MockUserMessage(content="Hello, how are you?") + + +@pytest.fixture +def sample_assistant_message(): + """Create a sample assistant message.""" + return MockAssistantMessage(content="I'm doing great, thank you!") + + +@pytest.fixture +def sample_system_message(): + """Create a sample system message.""" + return MockSystemMessage(content="You are a helpful assistant.") + + +@pytest.fixture +def sample_openai_messages(): + """Create a list of sample OpenAI messages.""" + return [ + MockUserMessage(content="Hello"), + MockAssistantMessage(content="Hi there!"), + MockUserMessage(content="How are you?"), + MockAssistantMessage(content="I'm doing well, thanks for asking!"), + ] + + +@pytest.fixture +def sample_messages_with_ids(): + """Create sample messages with pre-existing IDs.""" + return [ + MockUserMessage(content="Hello", id="user-msg-001"), + MockAssistantMessage(content="Hi!", id="assistant-msg-001"), + ] + + +@pytest.fixture +def sample_messages_with_timestamps(): + """Create sample messages with pre-existing timestamps.""" + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC) + return [ + MockUserMessage(content="Hello", timestamp=timestamp), + MockAssistantMessage( + content="Hi!", + timestamp=datetime(2024, 1, 15, 10, 30, 5, tzinfo=UTC), + ), + ] + + +@pytest.fixture +def sample_message_with_list_content(): + """Create a message with list-based content.""" + return MockUserMessage(content=[MockContentPart("Hello, "), MockContentPart("how are you?")]) + + +@pytest.fixture +def sample_message_with_empty_content(): + """Create a message with empty content.""" + return MockUserMessage(content="") + + +@pytest.fixture +def sample_message_with_none_content(): + """Create a message with None content.""" + return MockUserMessage(content=None) + + +@pytest.fixture +def sample_unknown_message(): + """Create an unknown message type.""" + return MockUnknownMessage(content="Unknown type content") + + +@pytest.fixture +def mock_session(sample_openai_messages): + """Create a mock OpenAI Session with sample messages.""" + return MockSession(items=sample_openai_messages) + + +@pytest.fixture +def mock_empty_session(): + """Create a mock OpenAI Session with no messages.""" + return MockSession(items=[]) + + +@pytest.fixture +def service(): + """Create a McpToolRegistrationService instance for testing.""" + from microsoft_agents_a365.tooling.extensions.openai import McpToolRegistrationService + + return McpToolRegistrationService() diff --git a/tests/tooling/extensions/openai/test_e2e.py b/tests/tooling/extensions/openai/test_e2e.py new file mode 100644 index 00000000..a33ec8e5 --- /dev/null +++ b/tests/tooling/extensions/openai/test_e2e.py @@ -0,0 +1,349 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""End-to-end tests for OpenAI send_chat_history methods with mocked HTTP. + +These tests verify the complete flow from Session/messages through conversion +to the HTTP call, using mocked HTTP responses. They are marked as unit tests +because they use mocks and don't require real external services. +""" + +import json +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from microsoft_agents_a365.runtime import OperationResult +from microsoft_agents_a365.tooling.models import ChatHistoryMessage + +from .conftest import ( + MockAssistantMessage, + MockSession, + MockSystemMessage, + MockUserMessage, +) + +# ============================================================================= +# END-TO-END TESTS WITH MOCKED HTTP (E2E-01 to E2E-03) +# ============================================================================= + + +class TestEndToEndWithMockedHttp: + """End-to-end tests with mocked HTTP dependencies. + + These tests verify the complete flow through the service but use mocked + HTTP responses. They are marked as unit tests since no real network + calls are made. + """ + + # E2E-01 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_e2e_success(self, service, mock_turn_context): + """Test full end-to-end flow: Session -> conversion -> HTTP -> success.""" + # Create a session with realistic messages + messages = [ + MockSystemMessage(content="You are a helpful assistant."), + MockUserMessage(content="What is the capital of France?"), + MockAssistantMessage(content="The capital of France is Paris."), + MockUserMessage(content="And what about Germany?"), + MockAssistantMessage(content="The capital of Germany is Berlin."), + ] + session = MockSession(items=messages) + + # Mock aiohttp.ClientSession + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text = AsyncMock(return_value="OK") + + with patch("aiohttp.ClientSession") as mock_session_class: + mock_session_instance = MagicMock() + mock_post = AsyncMock() + mock_post.__aenter__.return_value = mock_response + mock_session_instance.post.return_value = mock_post + mock_session_class.return_value.__aenter__.return_value = mock_session_instance + + # Execute + result = await service.send_chat_history(mock_turn_context, session) + + # Verify + assert result.succeeded is True + assert len(result.errors) == 0 + + # Verify HTTP call was made + assert mock_session_instance.post.called + + # E2E-02 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_e2e_server_error(self, service, mock_turn_context): + """Test full end-to-end flow with HTTP 500 error.""" + messages = [ + MockUserMessage(content="Hello"), + MockAssistantMessage(content="Hi there!"), + ] + session = MockSession(items=messages) + + # Mock aiohttp.ClientSession with 500 response + mock_response = AsyncMock() + mock_response.status = 500 + mock_response.text = AsyncMock(return_value="Internal Server Error") + mock_response.request_info = MagicMock() + mock_response.history = () + mock_response.headers = {} + + with patch("aiohttp.ClientSession") as mock_session_class: + mock_session_instance = MagicMock() + mock_post = AsyncMock() + mock_post.__aenter__.return_value = mock_response + mock_session_instance.post.return_value = mock_post + mock_session_class.return_value.__aenter__.return_value = mock_session_instance + + # Execute + result = await service.send_chat_history(mock_turn_context, session) + + # Verify failure + assert result.succeeded is False + assert len(result.errors) == 1 + + # E2E-03 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_e2e_payload_format(self, service, mock_turn_context): + """Test that the JSON payload has the correct structure.""" + messages = [ + MockUserMessage(content="Hello", id="user-001"), + MockAssistantMessage(content="Hi!", id="assistant-001"), + ] + session = MockSession(items=messages) + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text = AsyncMock(return_value="OK") + + captured_payload = None + + with patch("aiohttp.ClientSession") as mock_session_class: + mock_session_instance = MagicMock() + + def capture_post(*args, **kwargs): + nonlocal captured_payload + captured_payload = kwargs.get("data") + mock_post = AsyncMock() + mock_post.__aenter__.return_value = mock_response + return mock_post + + mock_session_instance.post.side_effect = capture_post + mock_session_class.return_value.__aenter__.return_value = mock_session_instance + + # Execute + result = await service.send_chat_history(mock_turn_context, session) + + # Verify success + assert result.succeeded is True + + # Verify payload structure + assert captured_payload is not None + payload = json.loads(captured_payload) + + # Check required fields + assert "conversationId" in payload + assert "messageId" in payload + assert "userMessage" in payload + assert "chatHistory" in payload + + # Check conversation context from turn_context + assert payload["conversationId"] == "conv-123" + assert payload["messageId"] == "msg-456" + assert payload["userMessage"] == "Hello, how are you?" + + # Check chat history + chat_history = payload["chatHistory"] + assert len(chat_history) == 2 + + # Verify first message (user) + assert chat_history[0]["role"] == "user" + assert chat_history[0]["content"] == "Hello" + assert chat_history[0]["id"] == "user-001" + + # Verify second message (assistant) + assert chat_history[1]["role"] == "assistant" + assert chat_history[1]["content"] == "Hi!" + assert chat_history[1]["id"] == "assistant-001" + + +class TestConversionChainE2E: + """End-to-end tests for the full conversion chain with mocked dependencies.""" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_messages_converted_to_chat_history_message_type( + self, service, mock_turn_context, sample_openai_messages + ): + """Test that OpenAI messages are converted to ChatHistoryMessage instances.""" + captured_messages = None + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.success() + + def capture_args(*args, **kwargs): + nonlocal captured_messages + captured_messages = kwargs.get("chat_history_messages") + return OperationResult.success() + + mock_send.side_effect = capture_args + + await service.send_chat_history_messages(mock_turn_context, sample_openai_messages) + + # Verify all messages are ChatHistoryMessage instances + assert captured_messages is not None + for msg in captured_messages: + assert isinstance(msg, ChatHistoryMessage) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_session_extraction_and_conversion_chain(self, service, mock_turn_context): + """Test the full chain: session.get_items() -> conversion -> send.""" + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC) + messages = [ + MockUserMessage(content="Query", id="q-1", timestamp=timestamp), + MockAssistantMessage(content="Response", id="r-1", timestamp=timestamp), + ] + session = MockSession(items=messages) + + captured_messages = None + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + + def capture_args(*args, **kwargs): + nonlocal captured_messages + captured_messages = kwargs.get("chat_history_messages") + return OperationResult.success() + + mock_send.side_effect = capture_args + + result = await service.send_chat_history(mock_turn_context, session) + + # Verify success + assert result.succeeded is True + + # Verify messages were extracted and converted + assert captured_messages is not None + assert len(captured_messages) == 2 + + # Verify IDs were preserved + assert captured_messages[0].id == "q-1" + assert captured_messages[1].id == "r-1" + + # Verify timestamps were preserved + assert captured_messages[0].timestamp == timestamp + assert captured_messages[1].timestamp == timestamp + + +class TestLimitParameterE2E: + """End-to-end tests for the limit parameter with mocked dependencies.""" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_limit_restricts_session_items(self, service, mock_turn_context): + """Test that limit parameter correctly restricts items from session.""" + # Create session with many messages + messages = [MockUserMessage(content=f"Msg {i}") for i in range(100)] + session = MockSession(items=messages) + + captured_messages = None + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + + def capture_args(*args, **kwargs): + nonlocal captured_messages + captured_messages = kwargs.get("chat_history_messages") + return OperationResult.success() + + mock_send.side_effect = capture_args + + # Send with limit + result = await service.send_chat_history(mock_turn_context, session, limit=10) + + assert result.succeeded is True + assert captured_messages is not None + assert len(captured_messages) == 10 + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_no_limit_sends_all_items(self, service, mock_turn_context): + """Test that no limit sends all session items.""" + messages = [MockUserMessage(content=f"Msg {i}") for i in range(50)] + session = MockSession(items=messages) + + captured_messages = None + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + + def capture_args(*args, **kwargs): + nonlocal captured_messages + captured_messages = kwargs.get("chat_history_messages") + return OperationResult.success() + + mock_send.side_effect = capture_args + + # Send without limit + result = await service.send_chat_history(mock_turn_context, session) + + assert result.succeeded is True + assert captured_messages is not None + assert len(captured_messages) == 50 + + +class TestHeadersE2E: + """End-to-end tests for HTTP headers with mocked dependencies.""" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_user_agent_header_includes_orchestrator_name( + self, service, mock_turn_context, sample_openai_messages + ): + """Test that User-Agent header includes orchestrator name.""" + captured_headers = None + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.text = AsyncMock(return_value="OK") + + with patch("aiohttp.ClientSession") as mock_session_class: + mock_session_instance = MagicMock() + + def capture_post(*args, **kwargs): + nonlocal captured_headers + captured_headers = kwargs.get("headers") + mock_post = AsyncMock() + mock_post.__aenter__.return_value = mock_response + return mock_post + + mock_session_instance.post.side_effect = capture_post + mock_session_class.return_value.__aenter__.return_value = mock_session_instance + + await service.send_chat_history_messages(mock_turn_context, sample_openai_messages) + + # Verify headers + assert captured_headers is not None + assert "User-Agent" in captured_headers + # User agent should contain OpenAI or orchestrator info + user_agent = captured_headers["User-Agent"] + assert "OpenAI" in user_agent or "microsoft-agents" in user_agent.lower() diff --git a/tests/tooling/extensions/openai/test_message_conversion.py b/tests/tooling/extensions/openai/test_message_conversion.py new file mode 100644 index 00000000..df2a97f7 --- /dev/null +++ b/tests/tooling/extensions/openai/test_message_conversion.py @@ -0,0 +1,463 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for message conversion logic in McpToolRegistrationService.""" + +import uuid +from datetime import UTC, datetime +from unittest.mock import Mock + +import pytest + +from .conftest import ( + MockAssistantMessage, + MockContentPart, + MockResponseOutputMessage, + MockSystemMessage, + MockUnknownMessage, + MockUserMessage, +) + +# ============================================================================= +# CONVERSION TESTS (CV-01 to CV-12) +# ============================================================================= + + +class TestRoleConversion: + """Tests for role extraction and mapping.""" + + # CV-01 + @pytest.mark.unit + def test_convert_user_message_to_chat_history(self, service): + """Test that UserMessage converts with role='user'.""" + message = MockUserMessage(content="Hello from user") + + result = service._convert_single_message(message) + + assert result is not None + assert result.role == "user" + assert result.content == "Hello from user" + + # CV-02 + @pytest.mark.unit + def test_convert_assistant_message_to_chat_history(self, service): + """Test that AssistantMessage converts with role='assistant'.""" + message = MockAssistantMessage(content="Hello from assistant") + + result = service._convert_single_message(message) + + assert result is not None + assert result.role == "assistant" + assert result.content == "Hello from assistant" + + # CV-03 + @pytest.mark.unit + def test_convert_system_message_to_chat_history(self, service): + """Test that SystemMessage converts with role='system'.""" + message = MockSystemMessage(content="System instructions") + + result = service._convert_single_message(message) + + assert result is not None + assert result.role == "system" + assert result.content == "System instructions" + + # CV-10 + @pytest.mark.unit + def test_convert_unknown_message_type_defaults_to_user(self, service): + """Test that unknown message type defaults to 'user' role.""" + message = MockUnknownMessage(content="Unknown type content") + + result = service._convert_single_message(message) + + assert result is not None + assert result.role == "user" + assert result.content == "Unknown type content" + + @pytest.mark.unit + def test_convert_response_output_message_to_assistant(self, service): + """Test that ResponseOutputMessage converts with role='assistant'.""" + message = MockResponseOutputMessage(content="Response content", role="assistant") + + result = service._convert_single_message(message) + + assert result is not None + assert result.role == "assistant" + + @pytest.mark.unit + def test_extract_role_from_dict_message(self, service): + """Test role extraction from dict-like message.""" + message = {"role": "user", "content": "Hello"} + + role = service._extract_role(message) + + assert role == "user" + + @pytest.mark.unit + def test_extract_role_from_dict_assistant(self, service): + """Test role extraction from dict with assistant role.""" + message = {"role": "assistant", "content": "Hi"} + + role = service._extract_role(message) + + assert role == "assistant" + + @pytest.mark.unit + def test_extract_role_from_dict_system(self, service): + """Test role extraction from dict with system role.""" + message = {"role": "system", "content": "Instructions"} + + role = service._extract_role(message) + + assert role == "system" + + +class TestContentExtraction: + """Tests for content extraction from messages.""" + + # CV-04 + @pytest.mark.unit + def test_convert_message_with_string_content(self, service): + """Test that string content is extracted directly.""" + message = MockUserMessage(content="Simple string content") + + result = service._convert_single_message(message) + + assert result is not None + assert result.content == "Simple string content" + + # CV-05 + @pytest.mark.unit + def test_convert_message_with_list_content(self, service): + """Test that list content is concatenated.""" + message = MockUserMessage(content=[MockContentPart("Hello, "), MockContentPart("world!")]) + + result = service._convert_single_message(message) + + assert result is not None + assert result.content == "Hello, world!" + + # CV-11 + @pytest.mark.unit + def test_convert_empty_content_skips_message(self, service): + """Test that messages with empty content are skipped during conversion.""" + message = MockUserMessage(content="") + + result = service._convert_single_message(message) + + # Empty content should cause the message to be skipped + assert result is None + + @pytest.mark.unit + def test_extract_content_from_text_attribute(self, service): + """Test content extraction from .text attribute as fallback.""" + message = Mock() + message.content = None + message.text = "Content from text attribute" + + content = service._extract_content(message) + + assert content == "Content from text attribute" + + @pytest.mark.unit + def test_extract_content_from_dict(self, service): + """Test content extraction from dict message.""" + message = {"role": "user", "content": "Dict content"} + + content = service._extract_content(message) + + assert content == "Dict content" + + @pytest.mark.unit + def test_extract_content_from_dict_with_list(self, service): + """Test content extraction from dict with list content.""" + message = { + "role": "user", + "content": [{"type": "text", "text": "Part 1"}, {"text": "Part 2"}], + } + + content = service._extract_content(message) + + assert "Part 1" in content + assert "Part 2" in content + + @pytest.mark.unit + def test_extract_content_concatenates_string_parts(self, service): + """Test that string parts in list are concatenated.""" + message = Mock() + message.content = ["Hello", " ", "world"] + + content = service._extract_content(message) + + assert content == "Hello world" + + +class TestIdExtraction: + """Tests for ID extraction and generation.""" + + # CV-06 + @pytest.mark.unit + def test_convert_message_generates_uuid_when_id_missing(self, service): + """Test that UUID is generated for messages without ID.""" + message = MockUserMessage(content="No ID message", id=None) + + result = service._convert_single_message(message) + + assert result is not None + assert result.id is not None + # Verify it's a valid UUID format + try: + uuid.UUID(result.id) + is_valid_uuid = True + except ValueError: + is_valid_uuid = False + assert is_valid_uuid + + # CV-08 + @pytest.mark.unit + def test_convert_message_preserves_existing_id(self, service): + """Test that existing ID is preserved.""" + message = MockUserMessage(content="Has ID", id="existing-id-123") + + result = service._convert_single_message(message) + + assert result is not None + assert result.id == "existing-id-123" + + @pytest.mark.unit + def test_extract_id_from_dict(self, service): + """Test ID extraction from dict message.""" + message = {"role": "user", "content": "Hello", "id": "dict-id-456"} + + msg_id = service._extract_id(message) + + assert msg_id == "dict-id-456" + + @pytest.mark.unit + def test_extract_id_generates_uuid_for_dict_without_id(self, service): + """Test UUID generation for dict without ID.""" + message = {"role": "user", "content": "Hello"} + + msg_id = service._extract_id(message) + + # Should be a valid UUID + try: + uuid.UUID(msg_id) + is_valid_uuid = True + except ValueError: + is_valid_uuid = False + assert is_valid_uuid + + +class TestTimestampExtraction: + """Tests for timestamp extraction and generation.""" + + # CV-07 + @pytest.mark.unit + def test_convert_message_uses_utc_when_timestamp_missing(self, service): + """Test that current UTC timestamp is used when missing.""" + message = MockUserMessage(content="No timestamp", timestamp=None) + before = datetime.now(UTC) + + result = service._convert_single_message(message) + + after = datetime.now(UTC) + + assert result is not None + assert result.timestamp is not None + assert before <= result.timestamp <= after + + # CV-09 + @pytest.mark.unit + def test_convert_message_preserves_existing_timestamp(self, service): + """Test that existing timestamp is preserved.""" + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC) + message = MockUserMessage(content="Has timestamp", timestamp=timestamp) + + result = service._convert_single_message(message) + + assert result is not None + assert result.timestamp == timestamp + + @pytest.mark.unit + def test_extract_timestamp_from_created_at(self, service): + """Test timestamp extraction from created_at attribute.""" + timestamp = datetime(2024, 6, 1, 12, 0, 0, tzinfo=UTC) + message = Mock() + message.timestamp = None + message.created_at = timestamp + + result = service._extract_timestamp(message) + + assert result == timestamp + + @pytest.mark.unit + def test_extract_timestamp_from_unix_timestamp(self, service): + """Test timestamp extraction from Unix timestamp.""" + unix_ts = 1704067200 # 2024-01-01 00:00:00 UTC + message = Mock() + message.timestamp = unix_ts + message.created_at = None + + result = service._extract_timestamp(message) + + assert result.year == 2024 + assert result.month == 1 + assert result.day == 1 + + @pytest.mark.unit + def test_extract_timestamp_from_iso_string(self, service): + """Test timestamp extraction from ISO format string.""" + message = Mock() + message.timestamp = "2024-03-15T14:30:00Z" + message.created_at = None + + result = service._extract_timestamp(message) + + assert result.year == 2024 + assert result.month == 3 + assert result.day == 15 + + +class TestBatchConversion: + """Tests for batch message conversion.""" + + # CV-12 + @pytest.mark.unit + def test_convert_multiple_messages(self, service, sample_openai_messages): + """Test that multiple messages are converted correctly.""" + result = service._convert_openai_messages_to_chat_history(sample_openai_messages) + + assert len(result) == len(sample_openai_messages) + + # Check alternating roles + assert result[0].role == "user" + assert result[1].role == "assistant" + assert result[2].role == "user" + assert result[3].role == "assistant" + + @pytest.mark.unit + def test_convert_filters_out_empty_content_messages(self, service): + """Test that messages with empty content are filtered out.""" + messages = [ + MockUserMessage(content="Valid content"), + MockUserMessage(content=""), # Should be filtered + MockAssistantMessage(content="Also valid"), + ] + + result = service._convert_openai_messages_to_chat_history(messages) + + # Only 2 messages should be converted (empty one filtered) + assert len(result) == 2 + assert result[0].content == "Valid content" + assert result[1].content == "Also valid" + + @pytest.mark.unit + def test_convert_handles_mixed_message_types(self, service): + """Test conversion of mixed message types.""" + messages = [ + MockSystemMessage(content="System prompt"), + MockUserMessage(content="User query"), + MockAssistantMessage(content="Assistant response"), + MockResponseOutputMessage(content="Output message"), + ] + + result = service._convert_openai_messages_to_chat_history(messages) + + assert len(result) == 4 + assert result[0].role == "system" + assert result[1].role == "user" + assert result[2].role == "assistant" + assert result[3].role == "assistant" + + @pytest.mark.unit + def test_convert_empty_list_returns_empty_list(self, service): + """Test that empty input returns empty output.""" + result = service._convert_openai_messages_to_chat_history([]) + + assert result == [] + + @pytest.mark.unit + def test_all_converted_messages_have_ids(self, service, sample_openai_messages): + """Test that all converted messages have IDs.""" + result = service._convert_openai_messages_to_chat_history(sample_openai_messages) + + for msg in result: + assert msg.id is not None + assert len(msg.id) > 0 + + @pytest.mark.unit + def test_all_converted_messages_have_timestamps(self, service, sample_openai_messages): + """Test that all converted messages have timestamps.""" + result = service._convert_openai_messages_to_chat_history(sample_openai_messages) + + for msg in result: + assert msg.timestamp is not None + + +class TestDictMessageConversion: + """Tests for dict-based message conversion.""" + + @pytest.mark.unit + def test_convert_dict_user_message(self, service): + """Test conversion of dict-based user message.""" + message = {"role": "user", "content": "Hello from dict"} + + result = service._convert_single_message(message) + + assert result is not None + assert result.role == "user" + assert result.content == "Hello from dict" + + @pytest.mark.unit + def test_convert_dict_with_id_and_timestamp(self, service): + """Test conversion of dict with ID and timestamp.""" + message = { + "role": "assistant", + "content": "Response", + "id": "dict-msg-id", + "timestamp": "2024-01-15T10:30:00Z", + } + + result = service._convert_single_message(message) + + assert result is not None + assert result.id == "dict-msg-id" + assert result.timestamp.year == 2024 + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + @pytest.mark.unit + def test_message_with_only_whitespace_content_skipped(self, service): + """Test that messages with only whitespace are skipped.""" + message = MockUserMessage(content=" ") + + result = service._convert_single_message(message) + + assert result is None + + @pytest.mark.unit + def test_message_with_none_content_skipped(self, service): + """Test that messages with None content are skipped.""" + message = Mock() + message.role = "user" + message.content = None + message.text = None + message.id = None + message.timestamp = None + + result = service._convert_single_message(message) + + assert result is None + + @pytest.mark.unit + def test_conversion_preserves_message_order(self, service): + """Test that message order is preserved during conversion.""" + messages = [MockUserMessage(content=f"Message {i}") for i in range(10)] + + result = service._convert_openai_messages_to_chat_history(messages) + + for i, msg in enumerate(result): + assert msg.content == f"Message {i}" diff --git a/tests/tooling/extensions/openai/test_send_chat_history.py b/tests/tooling/extensions/openai/test_send_chat_history.py new file mode 100644 index 00000000..9d7b9f12 --- /dev/null +++ b/tests/tooling/extensions/openai/test_send_chat_history.py @@ -0,0 +1,518 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for send_chat_history and send_chat_history_messages methods.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from microsoft_agents_a365.runtime import OperationResult +from microsoft_agents_a365.tooling.models import ToolOptions + +from .conftest import ( + MockSession, + MockUserMessage, +) + +# ============================================================================= +# INPUT VALIDATION TESTS (UV-01 to UV-09) +# ============================================================================= + + +class TestInputValidation: + """Tests for input validation in send_chat_history methods.""" + + # UV-01 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_validates_turn_context_none( + self, service, sample_openai_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_openai_messages) + + # UV-02 + @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) + + # UV-03 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_empty_list_returns_success( + self, service, mock_turn_context + ): + """Test that empty message list returns success (no-op).""" + result = await service.send_chat_history_messages(mock_turn_context, []) + + assert result.succeeded is True + assert len(result.errors) == 0 + + # UV-04 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_validates_activity_none( + self, service, mock_turn_context_no_activity, sample_openai_messages + ): + """Test that send_chat_history_messages validates turn_context.activity.""" + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.side_effect = ValueError("turn_context.activity cannot be None") + + with pytest.raises(ValueError, match="turn_context.activity cannot be None"): + await service.send_chat_history_messages( + mock_turn_context_no_activity, sample_openai_messages + ) + + # UV-05 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_validates_conversation_id( + self, service, mock_turn_context_no_conversation_id, sample_openai_messages + ): + """Test that send_chat_history_messages validates conversation_id.""" + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.side_effect = ValueError("conversation_id cannot be empty or None") + + with pytest.raises(ValueError, match="conversation_id cannot be empty"): + await service.send_chat_history_messages( + mock_turn_context_no_conversation_id, sample_openai_messages + ) + + # UV-06 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_validates_message_id( + self, service, mock_turn_context_no_message_id, sample_openai_messages + ): + """Test that send_chat_history_messages validates message_id.""" + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.side_effect = ValueError("message_id cannot be empty or None") + + with pytest.raises(ValueError, match="message_id cannot be empty"): + await service.send_chat_history_messages( + mock_turn_context_no_message_id, sample_openai_messages + ) + + # UV-07 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_validates_user_message( + self, service, mock_turn_context_no_user_message, sample_openai_messages + ): + """Test that send_chat_history_messages validates user_message text.""" + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.side_effect = ValueError("user_message cannot be empty or None") + + with pytest.raises(ValueError, match="user_message cannot be empty"): + await service.send_chat_history_messages( + mock_turn_context_no_user_message, sample_openai_messages + ) + + # UV-08 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_validates_turn_context_none(self, service, mock_session): + """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(None, mock_session) + + # UV-09 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_validates_session_none(self, service, mock_turn_context): + """Test that send_chat_history raises ValueError when session is None.""" + with pytest.raises(ValueError, match="session cannot be None"): + await service.send_chat_history(mock_turn_context, None) + + +# ============================================================================= +# SUCCESS PATH TESTS (SP-01 to SP-07) +# ============================================================================= + + +class TestSuccessPath: + """Tests for successful execution paths.""" + + # SP-01 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_success( + self, service, mock_turn_context, sample_openai_messages + ): + """Test successful send_chat_history_messages call.""" + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.success() + + result = await service.send_chat_history_messages( + mock_turn_context, sample_openai_messages + ) + + assert result.succeeded is True + assert len(result.errors) == 0 + mock_send.assert_called_once() + + # SP-02 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_with_options( + self, service, mock_turn_context, sample_openai_messages + ): + """Test send_chat_history_messages with custom ToolOptions.""" + custom_options = ToolOptions(orchestrator_name="CustomOrchestrator") + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.success() + + result = await service.send_chat_history_messages( + mock_turn_context, sample_openai_messages, options=custom_options + ) + + assert result.succeeded is True + # Verify options were passed through + call_args = mock_send.call_args + assert call_args.kwargs["options"].orchestrator_name == "CustomOrchestrator" + + # SP-03 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_default_orchestrator_name( + self, service, mock_turn_context, sample_openai_messages + ): + """Test that default orchestrator name is set to 'OpenAI'.""" + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.success() + + await service.send_chat_history_messages(mock_turn_context, sample_openai_messages) + + # Verify default orchestrator name + call_args = mock_send.call_args + assert call_args.kwargs["options"].orchestrator_name == "OpenAI" + + # SP-04 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_delegates_to_config_service( + self, service, mock_turn_context, sample_openai_messages + ): + """Test that send_chat_history_messages delegates to config_service.""" + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.success() + + await service.send_chat_history_messages(mock_turn_context, sample_openai_messages) + + # Verify delegation + mock_send.assert_called_once() + call_args = mock_send.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_openai_messages) + + # SP-05 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_success(self, service, mock_turn_context, mock_session): + """Test successful send_chat_history call.""" + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.success() + + result = await service.send_chat_history(mock_turn_context, mock_session) + + assert result.succeeded is True + mock_send.assert_called_once() + + # SP-06 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_with_limit(self, service, mock_turn_context): + """Test send_chat_history with limit parameter.""" + # Create session with many messages + messages = [MockUserMessage(content=f"Message {i}") for i in range(10)] + session = MockSession(items=messages) + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.success() + + result = await service.send_chat_history(mock_turn_context, session, limit=5) + + assert result.succeeded is True + + # Verify only limited messages were sent + call_args = mock_send.call_args + chat_history = call_args.kwargs["chat_history_messages"] + assert len(chat_history) == 5 + + # SP-07 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_delegates_to_send_chat_history_messages( + self, service, mock_turn_context, mock_session + ): + """Test that send_chat_history calls send_chat_history_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_turn_context, mock_session) + + mock_method.assert_called_once() + call_args = mock_method.call_args + assert call_args.kwargs["turn_context"] == mock_turn_context + + +# ============================================================================= +# ERROR HANDLING TESTS (EH-01 to EH-05) +# ============================================================================= + + +class TestErrorHandling: + """Tests for error handling scenarios.""" + + # EH-01 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_http_error( + self, service, mock_turn_context, sample_openai_messages + ): + """Test send_chat_history_messages handles HTTP errors.""" + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.failed( + MagicMock(message="HTTP 500: Internal Server Error") + ) + + result = await service.send_chat_history_messages( + mock_turn_context, sample_openai_messages + ) + + assert result.succeeded is False + + # EH-02 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_timeout_error( + self, service, mock_turn_context, sample_openai_messages + ): + """Test send_chat_history_messages handles timeout errors.""" + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.side_effect = TimeoutError("Request timed out") + + result = await service.send_chat_history_messages( + mock_turn_context, sample_openai_messages + ) + + assert result.succeeded is False + assert len(result.errors) == 1 + + # EH-03 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_client_error( + self, service, mock_turn_context, sample_openai_messages + ): + """Test send_chat_history_messages handles network/client errors.""" + import aiohttp + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.side_effect = aiohttp.ClientError("Connection failed") + + result = await service.send_chat_history_messages( + mock_turn_context, sample_openai_messages + ) + + assert result.succeeded is False + assert len(result.errors) == 1 + + # EH-04 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_messages_conversion_error(self, service, mock_turn_context): + """Test send_chat_history_messages handles conversion errors gracefully.""" + sample_messages = [MockUserMessage(content="Hello")] + + with patch.object(service, "_convert_openai_messages_to_chat_history") as mock_convert: + mock_convert.side_effect = Exception("Conversion failed") + + result = await service.send_chat_history_messages(mock_turn_context, sample_messages) + + assert result.succeeded is False + assert len(result.errors) == 1 + assert "Conversion failed" in str(result.errors[0].message) + + # EH-05 + @pytest.mark.asyncio + @pytest.mark.unit + async def test_send_chat_history_get_items_error(self, service, mock_turn_context): + """Test send_chat_history handles session.get_items() errors.""" + # Create a mock session that raises an error + mock_session = Mock() + mock_session.get_items.side_effect = Exception("Session error") + + result = await service.send_chat_history(mock_turn_context, mock_session) + + assert result.succeeded is False + assert len(result.errors) == 1 + + +# ============================================================================= +# ORCHESTRATOR NAME HANDLING TESTS +# ============================================================================= + + +class TestOrchestratorNameHandling: + """Tests for orchestrator name handling in options.""" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_options_with_none_orchestrator_name_gets_default( + self, service, mock_turn_context, sample_openai_messages + ): + """Test that options with None orchestrator_name get default value.""" + options = ToolOptions(orchestrator_name=None) + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.success() + + await service.send_chat_history_messages( + mock_turn_context, sample_openai_messages, options=options + ) + + call_args = mock_send.call_args + assert call_args.kwargs["options"].orchestrator_name == "OpenAI" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_options_preserves_custom_orchestrator_name( + self, service, mock_turn_context, sample_openai_messages + ): + """Test that custom orchestrator name is preserved.""" + options = ToolOptions(orchestrator_name="MyCustomOrchestrator") + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + mock_send.return_value = OperationResult.success() + + await service.send_chat_history_messages( + mock_turn_context, sample_openai_messages, options=options + ) + + call_args = mock_send.call_args + assert call_args.kwargs["options"].orchestrator_name == "MyCustomOrchestrator" + + +# ============================================================================= +# THREAD SAFETY / CONCURRENT CALLS TESTS +# ============================================================================= + + +class TestConcurrentCalls: + """Tests for thread safety and concurrent call isolation.""" + + @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 = [MockUserMessage(content="Message set 1")] + messages2 = [MockUserMessage(content="Message set 2")] + + captured_payloads: list[list[object]] = [] + + with patch.object( + service.config_service, + "send_chat_history", + new_callable=AsyncMock, + ) as mock_send: + + async def capture_and_succeed(*args: object, **kwargs: object) -> OperationResult: + captured_payloads.append(kwargs.get("chat_history_messages")) + await asyncio.sleep(0.01) # Simulate async work + return OperationResult.success() + + mock_send.side_effect = capture_and_succeed + + # Run concurrently + results = await asyncio.gather( + service.send_chat_history_messages(mock_turn_context, messages1), + service.send_chat_history_messages(mock_turn_context, messages2), + ) + + # Both should succeed independently + assert all(r.succeeded for r in results) + assert len(captured_payloads) == 2 + # Verify no cross-contamination + contents = [p[0].content for p in captured_payloads] + assert "Message set 1" in contents + assert "Message set 2" in contents diff --git a/tests/tooling/models/test_chat_history_message.py b/tests/tooling/models/test_chat_history_message.py index 12578590..dc9bb5e6 100644 --- a/tests/tooling/models/test_chat_history_message.py +++ b/tests/tooling/models/test_chat_history_message.py @@ -3,11 +3,11 @@ """Unit tests for ChatHistoryMessage class.""" -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest -from pydantic import ValidationError from microsoft_agents_a365.tooling.models import ChatHistoryMessage +from pydantic import ValidationError class TestChatHistoryMessage: @@ -16,7 +16,7 @@ class TestChatHistoryMessage: def test_chat_history_message_can_be_instantiated(self): """Test that ChatHistoryMessage can be instantiated with valid parameters.""" # Arrange & Act - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message = ChatHistoryMessage( id="msg-123", role="user", @@ -34,7 +34,7 @@ def test_chat_history_message_can_be_instantiated(self): def test_chat_history_message_to_dict(self): """Test that ChatHistoryMessage converts to dictionary correctly.""" # Arrange - timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC) message = ChatHistoryMessage( id="msg-456", role="assistant", @@ -65,7 +65,7 @@ def test_chat_history_message_with_optional_id_none(self): def test_chat_history_message_requires_non_empty_content(self): """Test that ChatHistoryMessage requires a non-empty content.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) # Act & Assert with pytest.raises(ValidationError, match="cannot be empty or whitespace"): @@ -90,7 +90,7 @@ def test_chat_history_message_with_optional_timestamp_none(self): def test_chat_history_message_supports_system_role(self): """Test that ChatHistoryMessage supports system role.""" # Arrange & Act - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message = ChatHistoryMessage( id="sys-001", role="system", @@ -104,7 +104,7 @@ def test_chat_history_message_supports_system_role(self): def test_chat_history_message_preserves_timestamp_precision(self): """Test that ChatHistoryMessage preserves timestamp precision.""" # Arrange - timestamp = datetime(2024, 1, 15, 10, 30, 45, 123000, tzinfo=timezone.utc) + timestamp = datetime(2024, 1, 15, 10, 30, 45, 123000, tzinfo=UTC) message = ChatHistoryMessage( id="msg-001", role="user", @@ -122,7 +122,7 @@ def test_chat_history_message_preserves_timestamp_precision(self): def test_chat_history_message_rejects_whitespace_only_content(self): """Test that ChatHistoryMessage rejects whitespace-only content.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) # Act & Assert with pytest.raises(ValidationError, match="cannot be empty or whitespace"): @@ -136,7 +136,7 @@ def test_chat_history_message_rejects_whitespace_only_content(self): def test_chat_history_message_rejects_newline_only_content(self): """Test that ChatHistoryMessage rejects newline-only content.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) # Act & Assert with pytest.raises(ValidationError, match="cannot be empty or whitespace"): @@ -150,7 +150,7 @@ def test_chat_history_message_rejects_newline_only_content(self): def test_chat_history_message_rejects_invalid_role(self): """Test that ChatHistoryMessage rejects invalid role values.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) # Act & Assert with pytest.raises( @@ -165,7 +165,7 @@ def test_chat_history_message_rejects_invalid_role(self): def test_chat_history_message_supports_all_valid_roles(self): """Test that ChatHistoryMessage supports all valid role values.""" - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) for role in ["user", "assistant", "system"]: message = ChatHistoryMessage( diff --git a/tests/tooling/models/test_chat_message_request.py b/tests/tooling/models/test_chat_message_request.py index e3cd8375..683e6b66 100644 --- a/tests/tooling/models/test_chat_message_request.py +++ b/tests/tooling/models/test_chat_message_request.py @@ -3,11 +3,11 @@ """Unit tests for ChatMessageRequest class.""" -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest -from pydantic import ValidationError from microsoft_agents_a365.tooling.models import ChatHistoryMessage, ChatMessageRequest +from pydantic import ValidationError class TestChatMessageRequest: @@ -16,7 +16,7 @@ class TestChatMessageRequest: def test_chat_message_request_can_be_instantiated(self): """Test that ChatMessageRequest can be instantiated with valid parameters.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message1 = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) message2 = ChatHistoryMessage( id="msg-2", role="assistant", content="Hi there!", timestamp=timestamp @@ -41,7 +41,7 @@ def test_chat_message_request_can_be_instantiated(self): def test_chat_message_request_to_dict(self): """Test that ChatMessageRequest converts to dictionary correctly.""" # Arrange - timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC) message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) request = ChatMessageRequest( conversation_id="conv-123", @@ -65,7 +65,7 @@ def test_chat_message_request_to_dict(self): def test_chat_message_request_requires_non_empty_conversation_id(self): """Test that ChatMessageRequest requires a non-empty conversation_id.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert @@ -80,7 +80,7 @@ def test_chat_message_request_requires_non_empty_conversation_id(self): def test_chat_message_request_requires_non_empty_message_id(self): """Test that ChatMessageRequest requires a non-empty message_id.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert @@ -95,7 +95,7 @@ def test_chat_message_request_requires_non_empty_message_id(self): def test_chat_message_request_requires_non_empty_user_message(self): """Test that ChatMessageRequest requires a non-empty user_message.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert @@ -123,7 +123,7 @@ def test_chat_message_request_requires_non_empty_chat_history(self): def test_chat_message_request_with_multiple_messages(self): """Test that ChatMessageRequest handles multiple messages correctly.""" # Arrange - timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC) message1 = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) message2 = ChatHistoryMessage( id="msg-2", role="assistant", content="Hi!", timestamp=timestamp @@ -151,7 +151,7 @@ def test_chat_message_request_with_multiple_messages(self): def test_chat_message_request_rejects_whitespace_only_conversation_id(self): """Test that ChatMessageRequest rejects whitespace-only conversation_id.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert @@ -166,7 +166,7 @@ def test_chat_message_request_rejects_whitespace_only_conversation_id(self): def test_chat_message_request_rejects_whitespace_only_message_id(self): """Test that ChatMessageRequest rejects whitespace-only message_id.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert @@ -181,7 +181,7 @@ def test_chat_message_request_rejects_whitespace_only_message_id(self): def test_chat_message_request_rejects_whitespace_only_user_message(self): """Test that ChatMessageRequest rejects whitespace-only user_message.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert @@ -196,7 +196,7 @@ def test_chat_message_request_rejects_whitespace_only_user_message(self): def test_chat_message_request_rejects_tab_only_conversation_id(self): """Test that ChatMessageRequest rejects tab-only conversation_id.""" # Arrange - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) message = ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp) # Act & Assert diff --git a/tests/tooling/services/test_send_chat_history.py b/tests/tooling/services/test_send_chat_history.py index 67f0e4e3..60b56c80 100644 --- a/tests/tooling/services/test_send_chat_history.py +++ b/tests/tooling/services/test_send_chat_history.py @@ -3,7 +3,7 @@ """Unit tests for send_chat_history method in McpToolServerConfigurationService.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -33,7 +33,7 @@ def mock_turn_context(self): @pytest.fixture def chat_history_messages(self): """Create sample chat history messages.""" - timestamp = datetime.now(timezone.utc) + timestamp = datetime.now(UTC) return [ ChatHistoryMessage(id="msg-1", role="user", content="Hello", timestamp=timestamp), ChatHistoryMessage( @@ -142,17 +142,18 @@ async def test_send_chat_history_validates_chat_history_messages( ): """Test that send_chat_history validates chat_history_messages parameter.""" # Act & Assert - with pytest.raises(ValueError, match="chat_history_messages cannot be None or empty"): + with pytest.raises(ValueError, match="chat_history_messages cannot be None"): await service.send_chat_history(mock_turn_context, None) @pytest.mark.asyncio - async def test_send_chat_history_validates_empty_chat_history_list( - self, service, mock_turn_context - ): - """Test that send_chat_history validates empty chat_history list.""" - # Act & Assert - with pytest.raises(ValueError, match="chat_history_messages cannot be None or empty"): - await service.send_chat_history(mock_turn_context, []) + async def test_send_chat_history_empty_list_returns_success(self, service, mock_turn_context): + """Test that send_chat_history returns success for empty list (CRM-008).""" + # Act + result = await service.send_chat_history(mock_turn_context, []) + + # Assert - empty list should return success, not raise exception + assert result.succeeded is True + assert len(result.errors) == 0 @pytest.mark.asyncio async def test_send_chat_history_validates_activity(self, service, chat_history_messages): diff --git a/tests/tooling/test_mcp_server_configuration.py b/tests/tooling/test_mcp_server_configuration.py new file mode 100644 index 00000000..41ff26a4 --- /dev/null +++ b/tests/tooling/test_mcp_server_configuration.py @@ -0,0 +1,243 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for MCP Server Configuration Service.""" + +import json +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from microsoft_agents_a365.tooling.models import MCPServerConfig +from microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service import ( + McpToolServerConfigurationService, +) + + +class TestMCPServerConfig: + """Tests for MCPServerConfig model.""" + + def test_mcp_server_config_with_custom_url(self): + """Test that MCPServerConfig can be created with a custom URL.""" + config = MCPServerConfig( + mcp_server_name="TestServer", + mcp_server_unique_name="test_server", + url="https://custom.mcp.server/endpoint", + ) + + assert config.mcp_server_name == "TestServer" + assert config.mcp_server_unique_name == "test_server" + assert config.url == "https://custom.mcp.server/endpoint" + + def test_mcp_server_config_without_custom_url(self): + """Test that MCPServerConfig works without a custom URL.""" + config = MCPServerConfig( + mcp_server_name="TestServer", + mcp_server_unique_name="test_server", + ) + + assert config.mcp_server_name == "TestServer" + assert config.mcp_server_unique_name == "test_server" + assert config.url is None + + def test_mcp_server_config_validation(self): + """Test that MCPServerConfig validates required fields.""" + with pytest.raises(ValueError, match="mcp_server_name cannot be empty"): + MCPServerConfig(mcp_server_name="", mcp_server_unique_name="test") + + with pytest.raises(ValueError, match="mcp_server_unique_name cannot be empty"): + MCPServerConfig(mcp_server_name="test", mcp_server_unique_name="") + + +class TestMcpToolServerConfigurationService: + """Tests for McpToolServerConfigurationService.""" + + @pytest.fixture + def service(self): + """Create a service instance for testing.""" + return McpToolServerConfigurationService() + + @pytest.fixture + def mock_manifest_data(self) -> dict[str, Any]: + """Create mock manifest data.""" + return { + "mcpServers": [ + { + "mcpServerName": "TestServer1", + "mcpServerUniqueName": "test_server_1", + }, + { + "mcpServerName": "TestServer2", + "mcpServerUniqueName": "test_server_2", + "url": "https://custom.server.com/mcp", + }, + ] + } + + def test_extract_server_url_from_manifest(self, service): + """Test extracting custom URL from manifest element.""" + # Test with url field + element = {"url": "https://custom.url.com"} + url = service._extract_server_url(element) + assert url == "https://custom.url.com" + + # Test with no URL + element = {} + url = service._extract_server_url(element) + assert url is None + + def test_parse_manifest_server_config_with_custom_url(self, service): + """Test parsing manifest config with custom URL.""" + server_element = { + "mcpServerName": "CustomServer", + "mcpServerUniqueName": "custom_server", + "url": "https://my.custom.server/mcp", + } + + config = service._parse_manifest_server_config(server_element) + + assert config is not None + assert config.mcp_server_name == "CustomServer" + assert config.mcp_server_unique_name == "custom_server" + assert config.url == "https://my.custom.server/mcp" + + @patch( + "microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service.build_mcp_server_url" + ) + def test_parse_manifest_server_config_without_custom_url(self, mock_build_url, service): + """Test parsing manifest config without custom URL constructs URL.""" + mock_build_url.return_value = "https://default.server/agents/servers/DefaultServer" + + server_element = { + "mcpServerName": "DefaultServer", + "mcpServerUniqueName": "test_server", + } + + config = service._parse_manifest_server_config(server_element) + + assert config is not None + assert config.mcp_server_name == "DefaultServer" + assert config.mcp_server_unique_name == "test_server" + # Without a custom URL, build_mcp_server_url constructs the full URL and stores it in the url field + # Uses mcp_server_name if available, otherwise falls back to mcp_server_unique_name + assert config.url == "https://default.server/agents/servers/DefaultServer" + mock_build_url.assert_called_once_with("DefaultServer") + + def test_parse_gateway_server_config_with_custom_url(self, service): + """Test parsing gateway config with custom URL.""" + server_element = { + "mcpServerName": "GatewayServer", + "mcpServerUniqueName": "gateway_server_endpoint", + "url": "https://gateway.custom.url/mcp", + } + + config = service._parse_gateway_server_config(server_element) + + assert config is not None + assert config.mcp_server_name == "GatewayServer" + assert config.mcp_server_unique_name == "gateway_server_endpoint" + assert config.url == "https://gateway.custom.url/mcp" + + @patch( + "microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service.build_mcp_server_url" + ) + def test_parse_gateway_server_config_without_custom_url(self, mock_build_url, service): + """Test parsing gateway config without custom URL.""" + mock_build_url.return_value = "https://default.server/agents/servers/GatewayServer" + + server_element = { + "mcpServerName": "GatewayServer", + "mcpServerUniqueName": "gateway_server", + } + + config = service._parse_gateway_server_config(server_element) + + assert config is not None + assert config.mcp_server_name == "GatewayServer" + assert config.mcp_server_unique_name == "gateway_server" + # Without a custom URL, build_mcp_server_url constructs the full URL and stores it in the url field + # Uses mcp_server_name if available, otherwise falls back to mcp_server_unique_name + assert config.url == "https://default.server/agents/servers/GatewayServer" + mock_build_url.assert_called_once_with("GatewayServer") + + @patch.dict(os.environ, {"ENVIRONMENT": "Development"}) + def test_is_development_scenario(self, service): + """Test development scenario detection.""" + assert service._is_development_scenario() is True + + @patch.dict(os.environ, {"ENVIRONMENT": "Production"}) + def test_is_production_scenario(self, service): + """Test production scenario detection.""" + assert service._is_development_scenario() is False + + @patch.object(McpToolServerConfigurationService, "_load_servers_from_manifest") + @patch.dict(os.environ, {"ENVIRONMENT": "Development"}) + @pytest.mark.asyncio + async def test_list_tool_servers_development(self, mock_load_manifest, service): + """Test listing servers in development mode.""" + mock_servers = [ + MCPServerConfig( + mcp_server_name="DevServer", + mcp_server_unique_name="dev_server", + url="https://dev.server/mcp", + ) + ] + mock_load_manifest.return_value = mock_servers + + servers = await service.list_tool_servers( + agentic_app_id="test-app-id", auth_token="test-token" + ) + + assert servers == mock_servers + mock_load_manifest.assert_called_once() + + @patch( + "microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service.get_tooling_gateway_for_digital_worker" + ) + @patch.dict(os.environ, {"ENVIRONMENT": "Production"}) + @pytest.mark.asyncio + async def test_list_tool_servers_production_with_custom_url(self, mock_gateway_url, service): + """Test listing servers in production mode with custom URL.""" + mock_gateway_url.return_value = "https://gateway.test/agents/test-app-id/mcpServers" + + # Mock aiohttp response + mock_response_data = { + "mcpServers": [ + { + "mcpServerName": "ProdServer", + "mcpServerUniqueName": "prod_server", + "url": "https://prod.custom.url/mcp", + } + ] + } + + with patch("aiohttp.ClientSession") as mock_session_class: + # Create proper async context managers + mock_response = MagicMock() + mock_response.status = 200 + mock_response.text = AsyncMock(return_value=json.dumps(mock_response_data)) + + # Create async context manager for response + mock_response_cm = MagicMock() + mock_response_cm.__aenter__ = AsyncMock(return_value=mock_response) + mock_response_cm.__aexit__ = AsyncMock(return_value=None) + + # Create async context manager for session + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response_cm) + + mock_session_cm = MagicMock() + mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_cm.__aexit__ = AsyncMock(return_value=None) + + mock_session_class.return_value = mock_session_cm + + servers = await service.list_tool_servers( + agentic_app_id="test-app-id", auth_token="test-token" + ) + + assert len(servers) == 1 + assert servers[0].mcp_server_name == "ProdServer" + assert servers[0].mcp_server_unique_name == "prod_server" + assert servers[0].url == "https://prod.custom.url/mcp" diff --git a/tests/usage_example.py b/tests/usage_example.py index e091e859..e42e9259 100644 --- a/tests/usage_example.py +++ b/tests/usage_example.py @@ -1,5 +1,5 @@ -# Copyright (c) Microsoft. All rights reserved. - +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. import os from urllib.parse import urlparse diff --git a/versioning/helper/__init__.py b/versioning/helper/__init__.py index 40ecc2c3..5cd1fdc6 100644 --- a/versioning/helper/__init__.py +++ b/versioning/helper/__init__.py @@ -4,10 +4,10 @@ """Microsoft Agent 365 Python SDK setup utilities.""" from .setup_utils import ( - get_package_version, - get_dynamic_dependencies, get_base_version, + get_dynamic_dependencies, get_next_major_version, + get_package_version, ) __all__ = [ diff --git a/versioning/helper/setup_utils.py b/versioning/helper/setup_utils.py index 1f08ca8a..5c007f68 100644 --- a/versioning/helper/setup_utils.py +++ b/versioning/helper/setup_utils.py @@ -9,7 +9,6 @@ """ from os import environ -from typing import List def get_package_version() -> str: @@ -106,7 +105,7 @@ def get_dynamic_dependencies( pyproject_path: str = "pyproject.toml", use_exact_match: bool = False, use_compatible_release: bool = False, -) -> List[str]: +) -> list[str]: """ Read dependencies from pyproject.toml and update internal package versions. @@ -156,31 +155,33 @@ def get_dynamic_dependencies( raise ImportError( "Failed to import TOML library. For Python < 3.11, please install tomli: " "pip install tomli" - ) + ) from None # Read and parse pyproject.toml with comprehensive error handling try: with open(pyproject_path, "rb") as f: pyproject = tomllib.load(f) - except FileNotFoundError: + except FileNotFoundError as err: raise FileNotFoundError( f"Could not find {pyproject_path}. " f"Ensure the file exists in the expected location. " f"Current working directory may be incorrect." - ) - except PermissionError: + ) from err + except PermissionError as err: raise PermissionError( f"Permission denied reading {pyproject_path}. Check file permissions." - ) + ) from err except Exception as e: # Catch TOML decode errors (attribute may vary by library) if "TOML" in type(e).__name__ or "Decode" in type(e).__name__: raise ValueError( f"Invalid TOML syntax in {pyproject_path}: {e}. " f"Please check the file for syntax errors." - ) + ) from e # Re-raise unexpected errors - raise RuntimeError(f"Unexpected error reading {pyproject_path}: {type(e).__name__}: {e}") + raise RuntimeError( + f"Unexpected error reading {pyproject_path}: {type(e).__name__}: {e}" + ) from e # Validate pyproject.toml structure if "project" not in pyproject: