From b8a539425e5d1aca4127e68ce57a4a999bf96997 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Mon, 26 Jan 2026 22:40:34 -0800 Subject: [PATCH 1/3] improved azureai test coverage --- .../azure-ai/tests/test_agent_provider.py | 79 +++- .../tests/test_azure_ai_agent_client.py | 211 ++++++++- .../azure-ai/tests/test_azure_ai_client.py | 227 ++++++++- .../packages/azure-ai/tests/test_provider.py | 36 ++ python/packages/azure-ai/tests/test_shared.py | 429 ++++++++++++++++++ 5 files changed, 968 insertions(+), 14 deletions(-) create mode 100644 python/packages/azure-ai/tests/test_shared.py diff --git a/python/packages/azure-ai/tests/test_agent_provider.py b/python/packages/azure-ai/tests/test_agent_provider.py index edfd749f4c..abb1790d82 100644 --- a/python/packages/azure-ai/tests/test_agent_provider.py +++ b/python/packages/azure-ai/tests/test_agent_provider.py @@ -19,6 +19,7 @@ Agent, CodeInterpreterToolDefinition, ) +from azure.identity.aio import AzureCliCredential from pydantic import BaseModel from agent_framework_azure_ai import ( @@ -464,7 +465,77 @@ def test_as_agent_with_hosted_tools( assert isinstance(agent, ChatAgent) # Should have HostedCodeInterpreterTool in the default_options tools - assert any(isinstance(t, HostedCodeInterpreterTool) for t in (agent.default_options.get("tools") or [])) + assert any(isinstance(t, HostedCodeInterpreterTool) for t in (agent.default_options.get("tools") or [])) # type: ignore + + +def test_as_agent_with_dict_function_tools_validates( + azure_ai_unit_test_env: dict[str, str], + mock_agents_client: MagicMock, +) -> None: + """Test as_agent validates dict-format function tools require implementations.""" + # Dict-based function tool (as returned by some Azure AI SDK operations) + dict_function_tool = { # type: ignore + "type": "function", + "function": { + "name": "dict_based_function", + "description": "A function defined as dict", + "parameters": {"type": "object", "properties": {}}, + }, + } + + mock_agent = MagicMock(spec=Agent) + mock_agent.id = "agent-id" + mock_agent.name = "Agent" + mock_agent.description = None + mock_agent.instructions = None + mock_agent.model = "gpt-4" + mock_agent.temperature = None + mock_agent.top_p = None + mock_agent.tools = [dict_function_tool] + + provider = AzureAIAgentsProvider(agents_client=mock_agents_client) + + with pytest.raises(ServiceInitializationError) as exc_info: + provider.as_agent(mock_agent) + + assert "dict_based_function" in str(exc_info.value) + + +def test_as_agent_with_dict_function_tools_provided( + azure_ai_unit_test_env: dict[str, str], + mock_agents_client: MagicMock, +) -> None: + """Test as_agent succeeds when dict-format function tools have implementations provided.""" + dict_function_tool = { # type: ignore + "type": "function", + "function": { + "name": "dict_based_function", + "description": "A function defined as dict", + "parameters": {"type": "object", "properties": {}}, + }, + } + + mock_agent = MagicMock(spec=Agent) + mock_agent.id = "agent-id" + mock_agent.name = "Agent" + mock_agent.description = None + mock_agent.instructions = None + mock_agent.model = "gpt-4" + mock_agent.temperature = None + mock_agent.top_p = None + mock_agent.tools = [dict_function_tool] + + @ai_function + def dict_based_function() -> str: + """A function implementation.""" + return "result" + + provider = AzureAIAgentsProvider(agents_client=mock_agents_client) + + agent = provider.as_agent(mock_agent, tools=dict_based_function) + + assert isinstance(agent, ChatAgent) + assert agent.id == "agent-id" # endregion @@ -729,8 +800,6 @@ def test_from_azure_ai_agent_tools_unknown_dict() -> None: @skip_if_azure_ai_integration_tests_disabled async def test_integration_create_agent() -> None: """Integration test: Create an agent using the provider.""" - from azure.identity.aio import AzureCliCredential - async with ( AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, @@ -753,8 +822,6 @@ async def test_integration_create_agent() -> None: @skip_if_azure_ai_integration_tests_disabled async def test_integration_get_agent() -> None: """Integration test: Get an existing agent using the provider.""" - from azure.identity.aio import AzureCliCredential - async with ( AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, @@ -779,8 +846,6 @@ async def test_integration_get_agent() -> None: @skip_if_azure_ai_integration_tests_disabled async def test_integration_create_and_run() -> None: """Integration test: Create an agent and run a conversation.""" - from azure.identity.aio import AzureCliCredential - async with ( AzureCliCredential() as credential, AzureAIAgentsProvider(credential=credential) as provider, diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index 7b20caea7d..dfd3a961df 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -25,16 +25,19 @@ Role, ) from agent_framework._serialization import SerializationMixin -from agent_framework.exceptions import ServiceInitializationError +from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError from azure.ai.agents.models import ( AgentsNamedToolChoice, AgentsNamedToolChoiceType, + AgentsToolChoiceOptionMode, + CodeInterpreterToolDefinition, FileInfo, MessageDeltaChunk, MessageDeltaTextContent, MessageDeltaTextFileCitationAnnotation, MessageDeltaTextFilePathAnnotation, MessageDeltaTextUrlCitationAnnotation, + MessageInputTextBlock, RequiredFunctionToolCall, RequiredMcpToolCall, RunStatus, @@ -592,8 +595,6 @@ async def test_azure_ai_chat_client_prepare_options_with_none_tool_choice( run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore - from azure.ai.agents.models import AgentsToolChoiceOptionMode - assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.NONE @@ -607,8 +608,6 @@ async def test_azure_ai_chat_client_prepare_options_with_auto_tool_choice( run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore - from azure.ai.agents.models import AgentsToolChoiceOptionMode - assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.AUTO @@ -1940,3 +1939,205 @@ def test_azure_ai_chat_client_init_with_auto_created_agents_client( assert client.agent_id == "test-agent" assert client.credential is mock_azure_credential assert client._should_close_client is True # Should close since we created it # type: ignore[attr-defined] + + +async def test_azure_ai_chat_client_prepare_options_with_mapping_response_format( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_options with Mapping-based response_format (runtime JSON schema).""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # Runtime JSON schema dict + response_format_dict = { + "type": "json_schema", + "json_schema": { + "name": "TestSchema", + "schema": {"type": "object", "properties": {"name": {"type": "string"}}}, + }, + } + + chat_options: ChatOptions = {"response_format": response_format_dict} # type: ignore[typeddict-item] + + run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore + + assert "response_format" in run_options + # Should pass through as-is for Mapping types + assert run_options["response_format"] == response_format_dict + + +async def test_azure_ai_chat_client_prepare_options_with_invalid_response_format( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_options with invalid response_format raises error.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # Invalid response_format (not BaseModel or Mapping) + chat_options: ChatOptions = {"response_format": "invalid_format"} # type: ignore[typeddict-item] + + with pytest.raises(ServiceInvalidRequestError, match="response_format must be a Pydantic BaseModel"): + await chat_client._prepare_options([], chat_options) # type: ignore + + +async def test_azure_ai_chat_client_prepare_tool_definitions_with_agent_tool_resources( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_tool_definitions_and_resources copies tool_resources from agent definition.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + + # Create mock agent definition with tool_resources + mock_agent_definition = MagicMock() + mock_agent_definition.tools = [] + mock_agent_definition.tool_resources = {"code_interpreter": {"file_ids": ["file-123"]}} + + run_options: dict[str, Any] = {} + options: dict[str, Any] = {} + + await chat_client._prepare_tool_definitions_and_resources(options, mock_agent_definition, run_options) # type: ignore + + # Verify tool_resources was copied to run_options + assert "tool_resources" in run_options + assert run_options["tool_resources"] == {"code_interpreter": {"file_ids": ["file-123"]}} + + +def test_azure_ai_chat_client_prepare_mcp_resources_with_dict_approval_mode( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_mcp_resources with dict-based approval mode (always_require_approval).""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # MCP tool with dict-based approval mode + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + approval_mode={"always_require_approval": {"tool1", "tool2"}}, + ) + + result = chat_client._prepare_mcp_resources([mcp_tool]) # type: ignore + + assert len(result) == 1 + assert result[0]["server_label"] == "Test_MCP" + assert "require_approval" in result[0] + assert result[0]["require_approval"] == {"always": {"tool1", "tool2"}} + + +def test_azure_ai_chat_client_prepare_mcp_resources_with_never_require_dict( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_mcp_resources with dict-based approval mode (never_require_approval).""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # MCP tool with never_require_approval dict + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + approval_mode={"never_require_approval": {"safe_tool"}}, + ) + + result = chat_client._prepare_mcp_resources([mcp_tool]) # type: ignore + + assert len(result) == 1 + assert result[0]["require_approval"] == {"never": {"safe_tool"}} + + +def test_azure_ai_chat_client_prepare_messages_with_function_result( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_messages extracts function_result content.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result="test result") + messages = [ChatMessage(role=Role.USER, contents=[function_result])] + + additional_messages, instructions, required_action_results = chat_client._prepare_messages(messages) # type: ignore + + # function_result should be extracted, not added to additional_messages + assert additional_messages is None + assert required_action_results is not None + assert len(required_action_results) == 1 + assert required_action_results[0].type == "function_result" + + +def test_azure_ai_chat_client_prepare_messages_with_raw_content_block( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_messages handles raw MessageInputContentBlock in content.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client) + + # Create content with raw_representation that is a MessageInputContentBlock + raw_block = MessageInputTextBlock(text="Raw block text") + custom_content = Content(type="custom", raw_representation=raw_block) + messages = [ChatMessage(role=Role.USER, contents=[custom_content])] + + additional_messages, instructions, required_action_results = chat_client._prepare_messages(messages) # type: ignore + + assert additional_messages is not None + assert len(additional_messages) == 1 + assert len(additional_messages[0].content) == 1 + assert additional_messages[0].content[0] == raw_block + + +async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_mcp_tool( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_tools_for_azure_ai with HostedMCPTool.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + + mcp_tool = HostedMCPTool( + name="Test MCP Server", + url="https://example.com/mcp", + allowed_tools=["tool1", "tool2"], + ) + + tool_definitions = await chat_client._prepare_tools_for_azure_ai([mcp_tool]) # type: ignore + + assert len(tool_definitions) >= 1 + # The McpTool.definitions property returns the tool definitions + # Verify the MCP tool was converted correctly by checking the definition type + mcp_def = tool_definitions[0] + assert mcp_def.get("type") == "mcp" + + +async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_tool_definition( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_tools_for_azure_ai with ToolDefinition passthrough.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + + # Pass a ToolDefinition directly - should be passed through as-is + tool_def = CodeInterpreterToolDefinition() + + tool_definitions = await chat_client._prepare_tools_for_azure_ai([tool_def]) # type: ignore + + assert len(tool_definitions) == 1 + assert tool_definitions[0] is tool_def + + +async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_dict_passthrough( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_tools_for_azure_ai with dict passthrough.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + + # Pass a dict tool definition - should be passed through as-is + dict_tool = {"type": "function", "function": {"name": "test_func", "parameters": {}}} + + tool_definitions = await chat_client._prepare_tools_for_azure_ai([dict_tool]) # type: ignore + + assert len(tool_definitions) == 1 + assert tool_definitions[0] is dict_tool + + +async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_unsupported_type( + mock_agents_client: MagicMock, +) -> None: + """Test _prepare_tools_for_azure_ai raises error for unsupported tool type.""" + chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") + + # Pass an unsupported tool type + class UnsupportedTool: + pass + + unsupported_tool = UnsupportedTool() + + with pytest.raises(ServiceInitializationError, match="Unsupported tool type"): + await chat_client._prepare_tools_for_azure_ai([unsupported_tool]) # type: ignore diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index aba45b3f1b..3f3bd300dd 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -2,6 +2,7 @@ import json import os +import sys from collections.abc import AsyncGenerator, AsyncIterator from contextlib import asynccontextmanager from typing import Annotated, Any @@ -34,6 +35,7 @@ ResponseTextFormatConfigurationJsonSchema, WebSearchPreviewTool, ) +from azure.core.exceptions import ResourceNotFoundError from azure.identity.aio import AzureCliCredential from openai.types.responses.parsed_response import ParsedResponse from openai.types.responses.response import Response as OpenAIResponse @@ -579,6 +581,107 @@ async def test_close_client_when_should_close_false(mock_project_client: MagicMo mock_project_client.close.assert_not_called() +async def test_configure_azure_monitor_success(mock_project_client: MagicMock) -> None: + """Test configure_azure_monitor successfully configures Azure Monitor.""" + client = create_test_azure_ai_client(mock_project_client) + + # Mock the telemetry connection string retrieval + mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + ) + + mock_configure = MagicMock() + mock_views = MagicMock(return_value=[]) + mock_resource = MagicMock() + mock_enable = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", mock_views), + patch("agent_framework.observability.create_resource", return_value=mock_resource), + patch("agent_framework.observability.enable_instrumentation", mock_enable), + ): + await client.configure_azure_monitor(enable_sensitive_data=True) + + # Verify connection string was retrieved + mock_project_client.telemetry.get_application_insights_connection_string.assert_called_once() + + # Verify Azure Monitor was configured + mock_configure.assert_called_once() + call_kwargs = mock_configure.call_args[1] + assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + + # Verify instrumentation was enabled with sensitive data flag + mock_enable.assert_called_once_with(enable_sensitive_data=True) + + +async def test_configure_azure_monitor_resource_not_found(mock_project_client: MagicMock) -> None: + """Test configure_azure_monitor handles ResourceNotFoundError gracefully.""" + client = create_test_azure_ai_client(mock_project_client) + + # Mock the telemetry to raise ResourceNotFoundError + mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( + side_effect=ResourceNotFoundError("No Application Insights found") + ) + + # Should not raise, just log warning and return + await client.configure_azure_monitor() + + # Verify connection string retrieval was attempted + mock_project_client.telemetry.get_application_insights_connection_string.assert_called_once() + + +async def test_configure_azure_monitor_import_error(mock_project_client: MagicMock) -> None: + """Test configure_azure_monitor raises ImportError when azure-monitor-opentelemetry is not installed.""" + client = create_test_azure_ai_client(mock_project_client) + + # Mock the telemetry connection string retrieval + mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + + # Mock the import to fail + with ( + patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}), + patch("builtins.__import__", side_effect=ImportError("No module named 'azure.monitor.opentelemetry'")), + pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"), + ): + await client.configure_azure_monitor() + + +async def test_configure_azure_monitor_with_custom_resource(mock_project_client: MagicMock) -> None: + """Test configure_azure_monitor uses custom resource when provided.""" + client = create_test_azure_ai_client(mock_project_client) + + mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + + custom_resource = MagicMock() + mock_configure = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views") as mock_views, + patch("agent_framework.observability.create_resource") as mock_create_resource, + patch("agent_framework.observability.enable_instrumentation"), + ): + mock_views.return_value = [] + + await client.configure_azure_monitor(resource=custom_resource) + + # Verify custom resource was used, not create_resource + mock_create_resource.assert_not_called() + call_kwargs = mock_configure.call_args[1] + assert call_kwargs["resource"] is custom_resource + + async def test_agent_creation_with_instructions( mock_project_client: MagicMock, ) -> None: @@ -675,8 +778,6 @@ async def test_use_latest_version_agent_not_found( mock_project_client: MagicMock, ) -> None: """Test _get_agent_reference_or_create when use_latest_version=True but agent doesn't exist.""" - from azure.core.exceptions import ResourceNotFoundError - client = create_test_azure_ai_client(mock_project_client, agent_name="non-existing-agent", use_latest_version=True) # Mock ResourceNotFoundError when trying to retrieve agent @@ -970,6 +1071,128 @@ def test_get_conversation_id_with_parsed_response_no_conversation() -> None: assert result == "resp_parsed_12345" +def test_prepare_mcp_tool_basic() -> None: + """Test _prepare_mcp_tool with basic HostedMCPTool.""" + mcp_tool = HostedMCPTool( + name="Test MCP Server", + url="https://example.com/mcp", + ) + + result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore + + assert result["server_label"] == "Test_MCP_Server" + assert result["server_url"] == "https://example.com/mcp" + + +def test_prepare_mcp_tool_with_description() -> None: + """Test _prepare_mcp_tool with description.""" + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + description="A test MCP server", + ) + + result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore + + assert result["server_description"] == "A test MCP server" + + +def test_prepare_mcp_tool_with_project_connection_id() -> None: + """Test _prepare_mcp_tool with project_connection_id in additional_properties.""" + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + additional_properties={"project_connection_id": "conn-123"}, + ) + + result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore + + assert result["project_connection_id"] == "conn-123" + assert "headers" not in result # headers should not be set when project_connection_id is present + + +def test_prepare_mcp_tool_with_headers() -> None: + """Test _prepare_mcp_tool with headers (no project_connection_id).""" + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + headers={"Authorization": "Bearer token123"}, + ) + + result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore + + assert result["headers"] == {"Authorization": "Bearer token123"} + + +def test_prepare_mcp_tool_with_allowed_tools() -> None: + """Test _prepare_mcp_tool with allowed_tools.""" + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + allowed_tools=["tool1", "tool2"], + ) + + result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore + + assert set(result["allowed_tools"]) == {"tool1", "tool2"} + + +def test_prepare_mcp_tool_with_approval_mode_always_require() -> None: + """Test _prepare_mcp_tool with string approval_mode 'always_require'.""" + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + approval_mode="always_require", + ) + + result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore + + assert result["require_approval"] == "always" + + +def test_prepare_mcp_tool_with_approval_mode_never_require() -> None: + """Test _prepare_mcp_tool with string approval_mode 'never_require'.""" + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + approval_mode="never_require", + ) + + result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore + + assert result["require_approval"] == "never" + + +def test_prepare_mcp_tool_with_dict_approval_mode_always() -> None: + """Test _prepare_mcp_tool with dict approval_mode containing always_require_approval.""" + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + approval_mode={"always_require_approval": {"dangerous_tool", "risky_tool"}}, + ) + + result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore + + assert "require_approval" in result + assert "always" in result["require_approval"] + assert set(result["require_approval"]["always"]["tool_names"]) == {"dangerous_tool", "risky_tool"} + + +def test_prepare_mcp_tool_with_dict_approval_mode_never() -> None: + """Test _prepare_mcp_tool with dict approval_mode containing never_require_approval.""" + mcp_tool = HostedMCPTool( + name="Test MCP", + url="https://example.com/mcp", + approval_mode={"never_require_approval": {"safe_tool"}}, + ) + + result = AzureAIClient._prepare_mcp_tool(mcp_tool) # type: ignore + + assert "require_approval" in result + assert "never" in result["require_approval"] + assert set(result["require_approval"]["never"]["tool_names"]) == {"safe_tool"} + + def test_from_azure_ai_tools() -> None: """Test from_azure_ai_tools.""" # Test MCP tool diff --git a/python/packages/azure-ai/tests/test_provider.py b/python/packages/azure-ai/tests/test_provider.py index 2a9808db9c..fa91328fc7 100644 --- a/python/packages/azure-ai/tests/test_provider.py +++ b/python/packages/azure-ai/tests/test_provider.py @@ -422,6 +422,42 @@ def test_provider_as_agent(mock_project_client: MagicMock) -> None: assert call_kwargs["model_deployment_name"] == "gpt-4" +def test_provider_merge_tools_skips_function_tool_dicts(mock_project_client: MagicMock) -> None: + """Test that _merge_tools skips function tool dicts but keeps other hosted tools.""" + provider = AzureAIProjectAgentProvider(project_client=mock_project_client) + + # Create a mock AIFunction to provide as implementation + mock_ai_function = create_mock_ai_function("my_function", "My function description") + + # Definition tools include a function tool (dict) and an MCP tool + definition_tools = [ + {"type": "function", "name": "my_function", "parameters": {}}, # Should be skipped + {"type": "mcp", "server_label": "my_mcp", "server_url": "http://localhost:8080"}, # Should be converted + ] + + # Call _merge_tools with user-provided function implementation + merged = provider._merge_tools(definition_tools, [mock_ai_function]) # type: ignore + + # Should have 2 items: the converted HostedMCPTool and the user-provided AIFunction + assert len(merged) == 2 + + # Check that the function tool dict was NOT included (it was skipped) + function_dicts = [t for t in merged if isinstance(t, dict) and t.get("type") == "function"] + assert len(function_dicts) == 0 + + # Check that the MCP tool was converted to HostedMCPTool + from agent_framework import HostedMCPTool + + mcp_tools = [t for t in merged if isinstance(t, HostedMCPTool)] + assert len(mcp_tools) == 1 + assert mcp_tools[0].name == "my mcp" # server_label with _ replaced by space + + # Check that the user-provided AIFunction was included + ai_functions = [t for t in merged if isinstance(t, AIFunction)] + assert len(ai_functions) == 1 + assert ai_functions[0].name == "my_function" + + async def test_provider_context_manager(mock_project_client: MagicMock) -> None: """Test AzureAIProjectAgentProvider async context manager.""" with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client: diff --git a/python/packages/azure-ai/tests/test_shared.py b/python/packages/azure-ai/tests/test_shared.py new file mode 100644 index 0000000000..8af2715d64 --- /dev/null +++ b/python/packages/azure-ai/tests/test_shared.py @@ -0,0 +1,429 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import MagicMock + +import pytest +from agent_framework import ( + AIFunction, + Content, + HostedCodeInterpreterTool, + HostedFileSearchTool, + HostedImageGenerationTool, + HostedMCPTool, + HostedWebSearchTool, +) +from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError +from azure.ai.agents.models import CodeInterpreterToolDefinition +from pydantic import BaseModel + +from agent_framework_azure_ai._shared import ( + _convert_response_format, # type: ignore + _convert_sdk_tool, # type: ignore + _extract_project_connection_id, # type: ignore + _prepare_mcp_tool_for_azure_ai, # type: ignore + create_text_format_config, + from_azure_ai_agent_tools, + from_azure_ai_tools, + to_azure_ai_agent_tools, + to_azure_ai_tools, +) + + +def test_extract_project_connection_id_direct() -> None: + """Test extracting project_connection_id from direct key.""" + result = _extract_project_connection_id({"project_connection_id": "my-connection"}) + assert result == "my-connection" + + +def test_extract_project_connection_id_from_connection_name() -> None: + """Test extracting project_connection_id from connection.name structure.""" + result = _extract_project_connection_id({"connection": {"name": "my-connection"}}) + assert result == "my-connection" + + +def test_extract_project_connection_id_none() -> None: + """Test returns None when no connection info.""" + assert _extract_project_connection_id(None) is None + assert _extract_project_connection_id({}) is None + + +def test_to_azure_ai_agent_tools_empty() -> None: + """Test converting empty/None tools list.""" + assert to_azure_ai_agent_tools(None) == [] + assert to_azure_ai_agent_tools([]) == [] + + +def test_to_azure_ai_agent_tools_ai_function() -> None: + """Test converting AIFunction to tool definition.""" + + def my_func(arg: str) -> str: + """My function.""" + return arg + + ai_func = AIFunction(func=my_func, name="my_func", description="My function.") # type: ignore + result = to_azure_ai_agent_tools([ai_func]) # type: ignore + assert len(result) == 1 + assert result[0]["type"] == "function" + assert result[0]["function"]["name"] == "my_func" + + +def test_to_azure_ai_agent_tools_code_interpreter() -> None: + """Test converting HostedCodeInterpreterTool.""" + tool = HostedCodeInterpreterTool() + result = to_azure_ai_agent_tools([tool]) + assert len(result) == 1 + assert isinstance(result[0], CodeInterpreterToolDefinition) + + +def test_to_azure_ai_agent_tools_web_search_missing_connection() -> None: + """Test HostedWebSearchTool raises without connection info.""" + tool = HostedWebSearchTool() + with pytest.raises(ServiceInitializationError, match="Bing search tool requires"): + to_azure_ai_agent_tools([tool]) + + +def test_to_azure_ai_agent_tools_dict_passthrough() -> None: + """Test dict tools pass through unchanged.""" + tool_dict = {"type": "custom", "config": "value"} + result = to_azure_ai_agent_tools([tool_dict]) + assert result[0] == tool_dict + + +def test_to_azure_ai_agent_tools_unsupported_type() -> None: + """Test unsupported tool type raises error.""" + unsupported = MagicMock() + unsupported.__class__.__name__ = "UnsupportedTool" + with pytest.raises(ServiceInitializationError, match="Unsupported tool type"): + to_azure_ai_agent_tools([unsupported]) + + +def test_from_azure_ai_agent_tools_empty() -> None: + """Test converting empty/None tools list.""" + assert from_azure_ai_agent_tools(None) == [] + assert from_azure_ai_agent_tools([]) == [] + + +def test_from_azure_ai_agent_tools_code_interpreter() -> None: + """Test converting CodeInterpreterToolDefinition.""" + tool = CodeInterpreterToolDefinition() + result = from_azure_ai_agent_tools([tool]) + assert len(result) == 1 + assert isinstance(result[0], HostedCodeInterpreterTool) + + +def test_convert_sdk_tool_code_interpreter() -> None: + """Test _convert_sdk_tool with code_interpreter type.""" + tool = MagicMock() + tool.type = "code_interpreter" + result = _convert_sdk_tool(tool) + assert isinstance(result, HostedCodeInterpreterTool) + + +def test_convert_sdk_tool_function_returns_none() -> None: + """Test _convert_sdk_tool with function type returns None.""" + tool = MagicMock() + tool.type = "function" + result = _convert_sdk_tool(tool) + assert result is None + + +def test_convert_sdk_tool_mcp_returns_none() -> None: + """Test _convert_sdk_tool with mcp type returns None.""" + tool = MagicMock() + tool.type = "mcp" + result = _convert_sdk_tool(tool) + assert result is None + + +def test_convert_sdk_tool_file_search() -> None: + """Test _convert_sdk_tool with file_search type.""" + tool = MagicMock() + tool.type = "file_search" + tool.file_search = MagicMock() + tool.file_search.vector_store_ids = ["vs-1", "vs-2"] + result = _convert_sdk_tool(tool) + assert isinstance(result, HostedFileSearchTool) + assert len(result.inputs) == 2 # type: ignore + + +def test_convert_sdk_tool_bing_grounding() -> None: + """Test _convert_sdk_tool with bing_grounding type.""" + tool = MagicMock() + tool.type = "bing_grounding" + tool.bing_grounding = MagicMock() + tool.bing_grounding.connection_id = "conn-123" + result = _convert_sdk_tool(tool) + assert isinstance(result, HostedWebSearchTool) + assert result.additional_properties["connection_id"] == "conn-123" # type: ignore + + +def test_convert_sdk_tool_bing_custom_search() -> None: + """Test _convert_sdk_tool with bing_custom_search type.""" + tool = MagicMock() + tool.type = "bing_custom_search" + tool.bing_custom_search = MagicMock() + tool.bing_custom_search.connection_id = "conn-123" + tool.bing_custom_search.instance_name = "my-instance" + result = _convert_sdk_tool(tool) + assert isinstance(result, HostedWebSearchTool) + assert result.additional_properties["custom_connection_id"] == "conn-123" # type: ignore + assert result.additional_properties["custom_instance_name"] == "my-instance" # type: ignore + + +def test_to_azure_ai_tools_empty() -> None: + """Test converting empty/None tools list.""" + assert to_azure_ai_tools(None) == [] + assert to_azure_ai_tools([]) == [] + + +def test_to_azure_ai_tools_code_interpreter_with_file_ids() -> None: + """Test converting HostedCodeInterpreterTool with file inputs.""" + tool = HostedCodeInterpreterTool( + inputs=[Content.from_hosted_file(file_id="file-123")] # type: ignore + ) + result = to_azure_ai_tools([tool]) + assert len(result) == 1 + assert result[0]["type"] == "code_interpreter" + assert result[0]["container"]["file_ids"] == ["file-123"] + + +def test_to_azure_ai_tools_ai_function() -> None: + """Test converting AIFunction to FunctionTool.""" + + def my_func(arg: str) -> str: + """My function.""" + return arg + + ai_func = AIFunction(func=my_func, name="my_func", description="My function.") # type: ignore + result = to_azure_ai_tools([ai_func]) # type: ignore + assert len(result) == 1 + assert result[0]["type"] == "function" + assert result[0]["name"] == "my_func" + + +def test_to_azure_ai_tools_file_search() -> None: + """Test converting HostedFileSearchTool.""" + tool = HostedFileSearchTool( + inputs=[Content.from_hosted_vector_store(vector_store_id="vs-123")], # type: ignore + max_results=10, + ) + result = to_azure_ai_tools([tool]) + assert len(result) == 1 + assert result[0]["type"] == "file_search" + assert result[0]["vector_store_ids"] == ["vs-123"] + assert result[0]["max_num_results"] == 10 + + +def test_to_azure_ai_tools_web_search_with_location() -> None: + """Test converting HostedWebSearchTool with user location.""" + tool = HostedWebSearchTool( + additional_properties={ + "user_location": { + "city": "Seattle", + "country": "US", + "region": "WA", + "timezone": "PST", + } + } + ) + result = to_azure_ai_tools([tool]) + assert len(result) == 1 + assert result[0]["type"] == "web_search_preview" + + +def test_to_azure_ai_tools_image_generation() -> None: + """Test converting HostedImageGenerationTool.""" + tool = HostedImageGenerationTool( + options={"model_id": "gpt-image-1", "image_size": "1024x1024"}, + additional_properties={"quality": "high"}, + ) + result = to_azure_ai_tools([tool]) + assert len(result) == 1 + assert result[0]["type"] == "image_generation" + assert result[0]["model"] == "gpt-image-1" + + +def test_prepare_mcp_tool_basic() -> None: + """Test basic MCP tool conversion.""" + tool = HostedMCPTool(name="my tool", url="http://localhost:8080") + result = _prepare_mcp_tool_for_azure_ai(tool) + assert result["server_label"] == "my_tool" + assert "http://localhost:8080" in result["server_url"] + + +def test_prepare_mcp_tool_with_description() -> None: + """Test MCP tool with description.""" + tool = HostedMCPTool(name="my tool", url="http://localhost:8080", description="My MCP server") + result = _prepare_mcp_tool_for_azure_ai(tool) + assert result["server_description"] == "My MCP server" + + +def test_prepare_mcp_tool_with_headers() -> None: + """Test MCP tool with headers (no project_connection_id).""" + tool = HostedMCPTool(name="my tool", url="http://localhost:8080", headers={"X-Api-Key": "secret"}) + result = _prepare_mcp_tool_for_azure_ai(tool) + assert result["headers"] == {"X-Api-Key": "secret"} + + +def test_prepare_mcp_tool_project_connection_takes_precedence() -> None: + """Test project_connection_id takes precedence over headers.""" + tool = HostedMCPTool( + name="my tool", + url="http://localhost:8080", + headers={"X-Api-Key": "secret"}, + additional_properties={"project_connection_id": "my-conn"}, + ) + result = _prepare_mcp_tool_for_azure_ai(tool) + assert result["project_connection_id"] == "my-conn" + assert "headers" not in result + + +def test_prepare_mcp_tool_approval_mode_always() -> None: + """Test MCP tool with always_require approval mode.""" + tool = HostedMCPTool(name="my tool", url="http://localhost:8080", approval_mode="always_require") + result = _prepare_mcp_tool_for_azure_ai(tool) + assert result["require_approval"] == "always" + + +def test_prepare_mcp_tool_approval_mode_never() -> None: + """Test MCP tool with never_require approval mode.""" + tool = HostedMCPTool(name="my tool", url="http://localhost:8080", approval_mode="never_require") + result = _prepare_mcp_tool_for_azure_ai(tool) + assert result["require_approval"] == "never" + + +def test_prepare_mcp_tool_approval_mode_dict() -> None: + """Test MCP tool with dict approval mode.""" + tool = HostedMCPTool( + name="my tool", + url="http://localhost:8080", + approval_mode={ + "always_require_approval": {"sensitive_tool"}, + "never_require_approval": {"safe_tool"}, + }, + ) + result = _prepare_mcp_tool_for_azure_ai(tool) + # The last assignment wins in the current implementation + assert "require_approval" in result + + +def test_create_text_format_config_pydantic_model() -> None: + """Test creating text format config from Pydantic model.""" + + class MySchema(BaseModel): + name: str + value: int + + result = create_text_format_config(MySchema) + assert result["type"] == "json_schema" + assert result["name"] == "MySchema" + assert result["strict"] is True + + +def test_create_text_format_config_json_schema_mapping() -> None: + """Test creating text format config from json_schema mapping.""" + config = { + "type": "json_schema", + "json_schema": { + "name": "MyResponse", + "schema": {"type": "object", "properties": {"name": {"type": "string"}}}, + }, + } + result = create_text_format_config(config) + assert result["type"] == "json_schema" + assert result["name"] == "MyResponse" + + +def test_create_text_format_config_json_object() -> None: + """Test creating text format config for json_object type.""" + result = create_text_format_config({"type": "json_object"}) + assert result["type"] == "json_object" + + +def test_create_text_format_config_text() -> None: + """Test creating text format config for text type.""" + result = create_text_format_config({"type": "text"}) + assert result["type"] == "text" + + +def test_create_text_format_config_invalid_raises() -> None: + """Test invalid response_format raises error.""" + with pytest.raises(ServiceInvalidRequestError): + create_text_format_config({"type": "invalid"}) + + +def test_convert_response_format_with_format_key() -> None: + """Test _convert_response_format with nested format key.""" + config = {"format": {"type": "json_object"}} + result = _convert_response_format(config) + assert result["type"] == "json_object" + + +def test_convert_response_format_json_schema_missing_schema_raises() -> None: + """Test json_schema without schema raises error.""" + with pytest.raises(ServiceInvalidRequestError, match="requires a schema"): + _convert_response_format({"type": "json_schema", "json_schema": {}}) + + +def test_from_azure_ai_tools_mcp_approval_mode_always() -> None: + """Test from_azure_ai_tools converts MCP require_approval='always' to approval_mode.""" + tools = [ + { + "type": "mcp", + "server_label": "my_mcp", + "server_url": "http://localhost:8080", + "require_approval": "always", + } + ] + result = from_azure_ai_tools(tools) + assert len(result) == 1 + assert isinstance(result[0], HostedMCPTool) + assert result[0].approval_mode == "always_require" + + +def test_from_azure_ai_tools_mcp_approval_mode_never() -> None: + """Test from_azure_ai_tools converts MCP require_approval='never' to approval_mode.""" + tools = [ + { + "type": "mcp", + "server_label": "my_mcp", + "server_url": "http://localhost:8080", + "require_approval": "never", + } + ] + result = from_azure_ai_tools(tools) + assert len(result) == 1 + assert isinstance(result[0], HostedMCPTool) + assert result[0].approval_mode == "never_require" + + +def test_from_azure_ai_tools_mcp_approval_mode_dict_always() -> None: + """Test from_azure_ai_tools converts MCP dict require_approval with 'always' key.""" + tools = [ + { + "type": "mcp", + "server_label": "my_mcp", + "server_url": "http://localhost:8080", + "require_approval": {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}}, + } + ] + result = from_azure_ai_tools(tools) + assert len(result) == 1 + assert isinstance(result[0], HostedMCPTool) + assert result[0].approval_mode == {"always_require_approval": {"sensitive_tool", "dangerous_tool"}} + + +def test_from_azure_ai_tools_mcp_approval_mode_dict_never() -> None: + """Test from_azure_ai_tools converts MCP dict require_approval with 'never' key.""" + tools = [ + { + "type": "mcp", + "server_label": "my_mcp", + "server_url": "http://localhost:8080", + "require_approval": {"never": {"tool_names": ["safe_tool"]}}, + } + ] + result = from_azure_ai_tools(tools) + assert len(result) == 1 + assert isinstance(result[0], HostedMCPTool) + assert result[0].approval_mode == {"never_require_approval": {"safe_tool"}} From 4fda918fcedb458a388ecf735ef0fffec5eff1ac Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Tue, 27 Jan 2026 08:02:30 -0800 Subject: [PATCH 2/3] small fix --- python/packages/azure-ai/tests/test_shared.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/packages/azure-ai/tests/test_shared.py b/python/packages/azure-ai/tests/test_shared.py index 8af2715d64..fa4635268f 100644 --- a/python/packages/azure-ai/tests/test_shared.py +++ b/python/packages/azure-ai/tests/test_shared.py @@ -91,10 +91,12 @@ def test_to_azure_ai_agent_tools_dict_passthrough() -> None: def test_to_azure_ai_agent_tools_unsupported_type() -> None: """Test unsupported tool type raises error.""" - unsupported = MagicMock() - unsupported.__class__.__name__ = "UnsupportedTool" + + class UnsupportedTool: + pass + with pytest.raises(ServiceInitializationError, match="Unsupported tool type"): - to_azure_ai_agent_tools([unsupported]) + to_azure_ai_agent_tools([UnsupportedTool()]) # type: ignore def test_from_azure_ai_agent_tools_empty() -> None: From 547351a0614151a52fd31ee224106b23c9a70b30 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Wed, 28 Jan 2026 11:42:01 -0800 Subject: [PATCH 3/3] fix failing tests --- .../azure-ai/tests/test_agent_provider.py | 2 +- .../packages/azure-ai/tests/test_provider.py | 8 ++++---- python/packages/azure-ai/tests/test_shared.py | 18 +++++++++--------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/python/packages/azure-ai/tests/test_agent_provider.py b/python/packages/azure-ai/tests/test_agent_provider.py index eccb7754c0..c4bcf0e953 100644 --- a/python/packages/azure-ai/tests/test_agent_provider.py +++ b/python/packages/azure-ai/tests/test_agent_provider.py @@ -525,7 +525,7 @@ def test_as_agent_with_dict_function_tools_provided( mock_agent.top_p = None mock_agent.tools = [dict_function_tool] - @ai_function + @tool def dict_based_function() -> str: """A function implementation.""" return "result" diff --git a/python/packages/azure-ai/tests/test_provider.py b/python/packages/azure-ai/tests/test_provider.py index eced5e9954..c209d14fd6 100644 --- a/python/packages/azure-ai/tests/test_provider.py +++ b/python/packages/azure-ai/tests/test_provider.py @@ -428,7 +428,7 @@ def test_provider_merge_tools_skips_function_tool_dicts(mock_project_client: Mag """Test that _merge_tools skips function tool dicts but keeps other hosted tools.""" provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - # Create a mock AIFunction to provide as implementation + # Create a mock FunctionTool to provide as implementation mock_ai_function = create_mock_ai_function("my_function", "My function description") # Definition tools include a function tool (dict) and an MCP tool @@ -440,7 +440,7 @@ def test_provider_merge_tools_skips_function_tool_dicts(mock_project_client: Mag # Call _merge_tools with user-provided function implementation merged = provider._merge_tools(definition_tools, [mock_ai_function]) # type: ignore - # Should have 2 items: the converted HostedMCPTool and the user-provided AIFunction + # Should have 2 items: the converted HostedMCPTool and the user-provided FunctionTool assert len(merged) == 2 # Check that the function tool dict was NOT included (it was skipped) @@ -454,8 +454,8 @@ def test_provider_merge_tools_skips_function_tool_dicts(mock_project_client: Mag assert len(mcp_tools) == 1 assert mcp_tools[0].name == "my mcp" # server_label with _ replaced by space - # Check that the user-provided AIFunction was included - ai_functions = [t for t in merged if isinstance(t, AIFunction)] + # Check that the user-provided FunctionTool was included + ai_functions = [t for t in merged if isinstance(t, FunctionTool)] assert len(ai_functions) == 1 assert ai_functions[0].name == "my_function" diff --git a/python/packages/azure-ai/tests/test_shared.py b/python/packages/azure-ai/tests/test_shared.py index fa4635268f..946003dc8b 100644 --- a/python/packages/azure-ai/tests/test_shared.py +++ b/python/packages/azure-ai/tests/test_shared.py @@ -4,8 +4,8 @@ import pytest from agent_framework import ( - AIFunction, Content, + FunctionTool, HostedCodeInterpreterTool, HostedFileSearchTool, HostedImageGenerationTool, @@ -53,15 +53,15 @@ def test_to_azure_ai_agent_tools_empty() -> None: assert to_azure_ai_agent_tools([]) == [] -def test_to_azure_ai_agent_tools_ai_function() -> None: - """Test converting AIFunction to tool definition.""" +def test_to_azure_ai_agent_tools_function_tool() -> None: + """Test converting FunctionTool to tool definition.""" def my_func(arg: str) -> str: """My function.""" return arg - ai_func = AIFunction(func=my_func, name="my_func", description="My function.") # type: ignore - result = to_azure_ai_agent_tools([ai_func]) # type: ignore + func_tool = FunctionTool(func=my_func, name="my_func", description="My function.") # type: ignore + result = to_azure_ai_agent_tools([func_tool]) # type: ignore assert len(result) == 1 assert result[0]["type"] == "function" assert result[0]["function"]["name"] == "my_func" @@ -189,15 +189,15 @@ def test_to_azure_ai_tools_code_interpreter_with_file_ids() -> None: assert result[0]["container"]["file_ids"] == ["file-123"] -def test_to_azure_ai_tools_ai_function() -> None: - """Test converting AIFunction to FunctionTool.""" +def test_to_azure_ai_tools_function_tool() -> None: + """Test converting FunctionTool.""" def my_func(arg: str) -> str: """My function.""" return arg - ai_func = AIFunction(func=my_func, name="my_func", description="My function.") # type: ignore - result = to_azure_ai_tools([ai_func]) # type: ignore + func_tool = FunctionTool(func=my_func, name="my_func", description="My function.") # type: ignore + result = to_azure_ai_tools([func_tool]) # type: ignore assert len(result) == 1 assert result[0]["type"] == "function" assert result[0]["name"] == "my_func"