// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Azure.Core; using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.Mcp; using Microsoft.Extensions.AI; using Shared.IntegrationTests; namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; /// /// Integration tests for InvokeFunctionTool and InvokeMcpTool actions. /// public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output) { #region InvokeFunctionTool Tests [Theory] [InlineData("InvokeFunctionTool.yaml", new string[] { "GetSpecials", "GetItemPrice" }, "2.95")] [InlineData("InvokeFunctionToolWithApproval.yaml", new string[] { "GetItemPrice" }, "4.9")] public Task ValidateInvokeFunctionToolAsync(string workflowFileName, string[] expectedFunctionCalls, string? expectedResultContains) => this.RunInvokeFunctionToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains); #endregion #region InvokeMcpTool Tests [Theory] [InlineData("InvokeMcpTool.yaml", "Azure OpenAI")] public Task ValidateInvokeMcpToolAsync(string workflowFileName, string? expectedResultContains) => this.RunInvokeMcpToolTestAsync(workflowFileName, expectedResultContains, requireApproval: false); [Theory] [InlineData("InvokeMcpToolWithApproval.yaml", "Azure OpenAI", true)] [InlineData("InvokeMcpToolWithApproval.yaml", "MCP tool invocation was not approved by user", false)] public Task ValidateInvokeMcpToolWithApprovalAsync(string workflowFileName, string? expectedResultContains, bool approveRequest) => this.RunInvokeMcpToolTestAsync(workflowFileName, expectedResultContains, requireApproval: true, approveRequest: approveRequest); #endregion #region InvokeHttpRequest Tests [RetryTheory(3, 5000)] [InlineData("HttpRequest.yaml")] public Task ValidateHttpRequestAsync(string workflowFileName) => this.RunHttpRequestTestAsync(workflowFileName); #endregion #region InvokeFunctionTool Test Helpers /// /// Runs an InvokeFunctionTool workflow test with the specified configuration. /// private async Task RunInvokeFunctionToolTestAsync( string workflowFileName, string[] expectedFunctionCalls, string? expectedResultContains = null) { // Arrange string workflowPath = GetWorkflowPath(workflowFileName); IEnumerable functionTools = new MenuPlugin().GetTools(); Dictionary functionMap = functionTools.ToDictionary(tool => tool.Name, tool => tool); DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false); Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); List invokedFunctions = []; // Act - Run workflow and handle function invocations WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false); while (workflowEvents.InputEvents.Count > 0) { RequestInfoEvent inputEvent = workflowEvents.InputEvents[^1]; ExternalInputRequest? toolRequest = inputEvent.Request.Data.As(); Assert.NotNull(toolRequest); IList functionResults = await this.ProcessFunctionCallsAsync( toolRequest, functionMap, invokedFunctions).ConfigureAwait(false); ChatMessage resultMessage = new(ChatRole.Tool, functionResults); WorkflowEvents resumeEvents = await harness.ResumeAsync( inputEvent.Request.CreateResponse(new ExternalInputResponse(resultMessage))).ConfigureAwait(false); workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. resumeEvents.Events]); // Continue processing until there are no more pending input events from the resumed workflow if (resumeEvents.InputEvents.Count == 0) { break; } } // Assert - Verify function calls were made in expected order Assert.Equal(expectedFunctionCalls.Length, invokedFunctions.Count); for (int i = 0; i < expectedFunctionCalls.Length; i++) { Assert.Equal(expectedFunctionCalls[i], invokedFunctions[i]); } // Assert - Verify executor and action events AssertWorkflowEventsEmitted(workflowEvents); // Assert - Verify expected result if specified if (expectedResultContains is not null) { AssertResultContains(workflowEvents, expectedResultContains); } } /// /// Processes function calls from an external input request. /// Handles both regular function calls and approval requests. /// private async Task> ProcessFunctionCallsAsync( ExternalInputRequest toolRequest, Dictionary functionMap, List invokedFunctions) { List results = []; foreach (ChatMessage message in toolRequest.AgentResponse.Messages) { // Handle approval requests if present foreach (ToolApprovalRequestContent approvalRequest in message.Contents.OfType()) { this.Output.WriteLine($"APPROVAL REQUEST: {((FunctionCallContent)approvalRequest.ToolCall).Name}"); // Auto-approve for testing results.Add(approvalRequest.CreateResponse(approved: true)); } // Handle function calls foreach (FunctionCallContent functionCall in message.Contents.OfType()) { this.Output.WriteLine($"FUNCTION CALL: {functionCall.Name}"); if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool)) { Assert.Fail($"Function not found: {functionCall.Name}"); continue; } invokedFunctions.Add(functionCall.Name); // Execute the function AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues()); object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false); results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); this.Output.WriteLine($"FUNCTION RESULT: {JsonSerializer.Serialize(result)}"); } } return results; } #endregion #region InvokeMcpTool Test Helpers /// /// Runs an InvokeMcpTool workflow test with the specified configuration. /// private async Task RunInvokeMcpToolTestAsync( string workflowFileName, string? expectedResultContains = null, bool requireApproval = false, bool approveRequest = true) { // Arrange string workflowPath = GetWorkflowPath(workflowFileName); DefaultMcpToolHandler mcpToolProvider = new(); DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync( externalConversation: false, mcpToolProvider: mcpToolProvider); Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); // Act - Run workflow and handle MCP tool invocations WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false); while (workflowEvents.InputEvents.Count > 0) { RequestInfoEvent inputEvent = workflowEvents.InputEvents[^1]; ExternalInputRequest? toolRequest = inputEvent.Request.Data.As(); Assert.NotNull(toolRequest); IList mcpResults = this.ProcessMcpToolRequests( toolRequest, approveRequest); ChatMessage resultMessage = new(ChatRole.Tool, mcpResults); WorkflowEvents resumeEvents = await harness.ResumeAsync( inputEvent.Request.CreateResponse(new ExternalInputResponse(resultMessage))).ConfigureAwait(false); workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. resumeEvents.Events]); // Continue processing until there are no more pending input events from the resumed workflow if (resumeEvents.InputEvents.Count == 0) { break; } } // Assert - Verify executor and action events AssertWorkflowEventsEmitted(workflowEvents); // Assert - Verify expected result if specified if (expectedResultContains is not null) { AssertResultContains(workflowEvents, expectedResultContains); } // Cleanup await mcpToolProvider.DisposeAsync().ConfigureAwait(false); } /// /// Processes MCP tool requests from an external input request. /// Handles approval requests for MCP tools. /// private List ProcessMcpToolRequests( ExternalInputRequest toolRequest, bool approveRequest) { List results = []; foreach (ChatMessage message in toolRequest.AgentResponse.Messages) { // Handle MCP approval requests if present foreach (ToolApprovalRequestContent approvalRequest in message.Contents.OfType()) { this.Output.WriteLine($"MCP APPROVAL REQUEST: {approvalRequest.RequestId}"); // Respond based on test configuration ToolApprovalResponseContent response = approvalRequest.CreateResponse(approved: approveRequest); results.Add(response); this.Output.WriteLine($"MCP APPROVAL RESPONSE: {(approveRequest ? "Approved" : "Rejected")}"); } } return results; } #endregion #region InvokeHttpRequest Test Helpers /// /// The Azure ARM scope used to acquire bearer tokens for the HttpRequestAction /// integration test. Matches the URL configured in HttpRequest.yaml. /// private const string ArmScope = "https://management.azure.com/.default"; /// /// The expected ARM endpoint. Only requests whose absolute URL exactly matches /// this scheme and host receive the authenticated ; all /// other URLs (including subdomain look-alikes such as /// https://management.azure.com.evil.com) fall through to the handler /// default and never see the bearer token. /// private static readonly Uri s_armEndpoint = new("https://management.azure.com/"); /// /// Runs an HttpRequestAction workflow test with the specified configuration. /// /// /// The workflow under test calls an authenticated Azure ARM endpoint. We acquire a /// single bearer token via the same Azure CLI credential used elsewhere in the /// integration test suite, attach it to a cached , and route /// matching requests through that client via 's /// httpClientProvider callback. The test owns the 's /// lifetime and disposes it explicitly — does /// not dispose provider-returned clients. /// private async Task RunHttpRequestTestAsync( string workflowFileName) { // Arrange string workflowPath = GetWorkflowPath(workflowFileName); AccessToken accessToken = await TestAzureCliCredentials .CreateAzureCliCredential() .GetTokenAsync(new TokenRequestContext([ArmScope]), CancellationToken.None) .ConfigureAwait(false); using HttpClient authenticatedClient = new(); authenticatedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); await using DefaultHttpRequestHandler httpRequestHandler = new(httpClientProvider: (request, _) => { if (Uri.TryCreate(request.Url, UriKind.Absolute, out Uri? requestUri) && string.Equals(requestUri.Scheme, s_armEndpoint.Scheme, StringComparison.OrdinalIgnoreCase) && string.Equals(requestUri.Host, s_armEndpoint.Host, StringComparison.OrdinalIgnoreCase)) { #pragma warning disable CA2025 // authenticatedClient outlives the handler (LIFO using disposal) and the workflow awaits all dispatches. return Task.FromResult(authenticatedClient); #pragma warning restore CA2025 } // Fall back to the handler's internal client for any non-ARM URLs. return Task.FromResult(null); }); DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync( externalConversation: false, httpRequestHandler: httpRequestHandler); Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); // Act WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false); // Assert - Verify executor and action events AssertWorkflowEventsEmitted(workflowEvents); MessageActivityEvent? messageEvent = workflowEvents.Events .OfType() .LastOrDefault(); Assert.NotNull(messageEvent); Assert.NotNull(messageEvent.Message); Assert.True( Guid.TryParse(messageEvent.Message, out Guid retrievedTenantId), $"Expected the SendMessage payload to be a tenant GUID, but got: '{messageEvent.Message}'"); Assert.NotEqual(Guid.Empty, retrievedTenantId); } #endregion #region Shared Helpers private static void AssertWorkflowEventsEmitted(WorkflowEvents workflowEvents) { Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents); Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents); Assert.NotEmpty(workflowEvents.ActionInvokeEvents); } private static void AssertResultContains(WorkflowEvents workflowEvents, string expectedResultContains) { MessageActivityEvent? messageEvent = workflowEvents.Events .OfType() .LastOrDefault(); Assert.NotNull(messageEvent); Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase); } private static string GetWorkflowPath(string workflowFileName) => Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); #endregion }