// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; 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.Extensions.AI; using Xunit.Abstractions; namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; /// /// Integration tests for InvokeFunctionTool action. /// This test pattern can be extended for other InvokeTool types. /// public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output) { [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.RunInvokeToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains); /// /// Runs an InvokeTool workflow test with the specified configuration. /// This method is designed to be generic and reusable for different InvokeTool types. /// /// The workflow YAML file name. /// Expected function names to be called in order. /// Expected text to be present in the final result. private async Task RunInvokeToolTestAsync( 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) { // No more input events from the last resume - workflow completed 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 Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents); Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents); Assert.NotEmpty(workflowEvents.ActionInvokeEvents); // Assert - Verify expected result if specified if (expectedResultContains is not null) { MessageActivityEvent? messageEvent = workflowEvents.Events .OfType() .LastOrDefault(); Assert.NotNull(messageEvent); Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase); } } /// /// 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 (FunctionApprovalRequestContent approvalRequest in message.Contents.OfType()) { this.Output.WriteLine($"APPROVAL REQUEST: {approvalRequest.FunctionCall.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; } private static string GetWorkflowPath(string workflowFileName) => Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); }