diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests.csproj b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests.csproj index 3abfac82..4a57f936 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests.csproj +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests.csproj @@ -8,6 +8,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/Services/McpToolRegistrationServiceTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/Services/McpToolRegistrationServiceTests.cs new file mode 100644 index 00000000..e40f3e2b --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/Services/McpToolRegistrationServiceTests.cs @@ -0,0 +1,367 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure; +using Azure.AI.Agents.Persistent; +using FluentAssertions; +using Microsoft.Agents.A365.Runtime; +using Microsoft.Agents.A365.Tooling.Extensions.AzureFoundry; +using Microsoft.Agents.A365.Tooling.Extensions.AzureFoundry.Services; +using Microsoft.Agents.A365.Tooling.Models; +using Microsoft.Agents.A365.Tooling.Services; +using Microsoft.Agents.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests.Services +{ + /// + /// Unit tests for McpToolRegistrationService.SendChatHistoryAsync methods. + /// Tests parameter validation, message retrieval and conversion, and delegation to underlying service. + /// + public class McpToolRegistrationServiceTests + { + private readonly Mock> _loggerMock; + private readonly Mock _serviceProviderMock; + private readonly Mock _mcpServerConfigurationServiceMock; + private readonly Mock _configurationMock; + + public McpToolRegistrationServiceTests() + { + _loggerMock = new Mock>(); + _serviceProviderMock = new Mock(); + _mcpServerConfigurationServiceMock = new Mock(); + _configurationMock = new Mock(); + } + + [Fact] + public async Task SendChatHistoryAsync_ThrowsArgumentNullException_WhenAgentClientIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var turnContextMock = new Mock(); + + // Act + Func act = async () => await service.SendChatHistoryAsync(null!, "thread-123", turnContextMock.Object); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("agentClient"); + } + + [Fact] + public async Task SendChatHistoryAsync_ThrowsArgumentNullException_WhenThreadIdIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var agentClientMock = new Mock(); + var turnContextMock = new Mock(); + + // Act + Func act = async () => await service.SendChatHistoryAsync(agentClientMock.Object, null!, turnContextMock.Object); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("threadId"); + } + + [Fact] + public async Task SendChatHistoryAsync_ThrowsArgumentNullException_WhenTurnContextIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var agentClientMock = new Mock(); + + // Act + Func act = async () => await service.SendChatHistoryAsync(agentClientMock.Object, "thread-123", null!); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("turnContext"); + } + + [Fact] + public async Task SendChatHistoryAsync_ThrowsOperationCanceledException_WhenCancellationTokenIsCanceled() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var agentClientMock = new Mock(); + var turnContextMock = new Mock(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + Func act = async () => await service.SendChatHistoryAsync( + agentClientMock.Object, + "thread-123", + turnContextMock.Object, + cts.Token); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SendChatHistoryAsync_WithToolOptions_ThrowsArgumentNullException_WhenAgentClientIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var turnContextMock = new Mock(); + var toolOptions = new ToolOptions(); + + // Act + Func act = async () => await service.SendChatHistoryAsync(null!, "thread-123", turnContextMock.Object, toolOptions); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("agentClient"); + } + + [Fact] + public async Task SendChatHistoryAsync_WithToolOptions_ThrowsArgumentNullException_WhenThreadIdIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var agentClientMock = new Mock(); + var turnContextMock = new Mock(); + var toolOptions = new ToolOptions(); + + // Act + Func act = async () => await service.SendChatHistoryAsync(agentClientMock.Object, null!, turnContextMock.Object, toolOptions); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("threadId"); + } + + [Fact] + public async Task SendChatHistoryAsync_WithToolOptions_ThrowsArgumentNullException_WhenTurnContextIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var agentClientMock = new Mock(); + var toolOptions = new ToolOptions(); + + // Act + Func act = async () => await service.SendChatHistoryAsync(agentClientMock.Object, "thread-123", null!, toolOptions); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("turnContext"); + } + + [Fact] + public async Task SendChatHistoryAsync_WithToolOptions_ThrowsArgumentNullException_WhenToolOptionsIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var agentClientMock = new Mock(); + var turnContextMock = new Mock(); + + // Act + Func act = async () => await service.SendChatHistoryAsync(agentClientMock.Object, "thread-123", turnContextMock.Object, null!); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("toolOptions"); + } + + [Fact] + public async Task SendChatHistoryAsync_WithToolOptions_ThrowsOperationCanceledException_WhenCancellationTokenIsCanceled() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var agentClientMock = new Mock(); + var turnContextMock = new Mock(); + var toolOptions = new ToolOptions(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + Func act = async () => await service.SendChatHistoryAsync( + agentClientMock.Object, + "thread-123", + turnContextMock.Object, + toolOptions, + cts.Token); + + // Assert + await act.Should().ThrowAsync(); + } + + // Parameter validation tests for direct message overloads + [Fact] + public async Task SendChatHistoryAsync_WithMessages_ThrowsArgumentNullException_WhenTurnContextIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var messages = Array.Empty(); + + // Act + Func act = async () => await service.SendChatHistoryAsync( + turnContext: null!, + messages: messages, + cancellationToken: CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("turnContext"); + } + + [Fact] + public async Task SendChatHistoryAsync_WithMessages_ThrowsArgumentNullException_WhenMessagesIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var turnContextMock = new Mock(); + + // Act + Func act = async () => await service.SendChatHistoryAsync( + turnContext: turnContextMock.Object, + messages: null!, + cancellationToken: CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("messages"); + } + + [Fact] + public async Task SendChatHistoryAsync_WithMessagesAndToolOptions_ThrowsArgumentNullException_WhenTurnContextIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var messages = Array.Empty(); + var toolOptions = new ToolOptions(); + + // Act + Func act = async () => await service.SendChatHistoryAsync( + turnContext: null!, + messages: messages, + toolOptions: toolOptions, + cancellationToken: CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("turnContext"); + } + + [Fact] + public async Task SendChatHistoryAsync_WithMessagesAndToolOptions_ThrowsArgumentNullException_WhenMessagesIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var turnContextMock = new Mock(); + var toolOptions = new ToolOptions(); + + // Act + Func act = async () => await service.SendChatHistoryAsync( + turnContext: turnContextMock.Object, + messages: null!, + toolOptions: toolOptions, + cancellationToken: CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("messages"); + } + + [Fact] + public async Task SendChatHistoryAsync_WithMessagesAndToolOptions_ThrowsArgumentNullException_WhenToolOptionsIsNull() + { + // Arrange + var service = new McpToolRegistrationService( + _loggerMock.Object, + _serviceProviderMock.Object, + _mcpServerConfigurationServiceMock.Object, + _configurationMock.Object); + + var turnContextMock = new Mock(); + var messages = Array.Empty(); + + // Act + Func act = async () => await service.SendChatHistoryAsync( + turnContext: turnContextMock.Object, + messages: messages, + toolOptions: null!, + cancellationToken: CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("toolOptions"); + } + + // Note: Tests for message conversion logic cannot be added due to Azure SDK limitations + // PersistentThreadMessage has non-virtual properties that cannot be mocked + + } +} diff --git a/src/Tooling/Extensions/AzureAIFoundry/Services/IMcpToolRegistrationService.cs b/src/Tooling/Extensions/AzureAIFoundry/Services/IMcpToolRegistrationService.cs index 5f3e63f2..2623c9a1 100644 --- a/src/Tooling/Extensions/AzureAIFoundry/Services/IMcpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AzureAIFoundry/Services/IMcpToolRegistrationService.cs @@ -4,10 +4,13 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AzureFoundry.Services; using Microsoft.Agents.Builder; using Microsoft.Agents.Builder.App.UserAuth; +using Microsoft.Agents.A365.Runtime; +using Microsoft.Agents.A365.Tooling.Models; using Azure.AI.Agents; using Azure.AI.Agents.Persistent; using Azure.Identity; using System.Collections.Generic; +using System.Threading; /// /// Provides methods for managing MCP tool server registrations (Semantic Kernel independent). @@ -41,4 +44,90 @@ Task AddToolServersToAgentAsync( string agentInstanceId, string authToken, ITurnContext turnContext); + + /// + /// Sends chat history to the MCP platform for real-time threat protection. + /// Messages are provided directly by the caller as Azure AI Foundry messages. + /// + /// The turn context containing conversation information. + /// The Azure AI Foundry persistent thread messages to send. + /// A cancellation token to cancel the operation. + /// A task representing the asynchronous operation that returns an indicating success or failure. + /// Thrown when or is null. + /// Thrown when the operation is canceled via the . + /// + /// This method converts PersistentThreadMessage objects to the ChatHistoryMessage format and sends them to the MCP platform. + /// The CreatedAt timestamp from Azure AI Foundry is preserved for each message. + /// + Task SendChatHistoryAsync( + ITurnContext turnContext, + PersistentThreadMessage[] messages, + CancellationToken cancellationToken = default); + + /// + /// Sends chat history to the MCP platform for real-time threat protection. + /// Messages are provided directly by the caller as Azure AI Foundry messages. + /// + /// The turn context containing conversation information. + /// The Azure AI Foundry persistent thread messages to send. + /// Tool options for sending chat history. + /// A cancellation token to cancel the operation. + /// A task representing the asynchronous operation that returns an indicating success or failure. + /// Thrown when , , or is null. + /// Thrown when the operation is canceled via the . + /// + /// This method converts PersistentThreadMessage objects to the ChatHistoryMessage format and sends them to the MCP platform. + /// The CreatedAt timestamp from Azure AI Foundry is preserved for each message. + /// + Task SendChatHistoryAsync( + ITurnContext turnContext, + PersistentThreadMessage[] messages, + ToolOptions toolOptions, + CancellationToken cancellationToken = default); + + /// + /// Sends chat history to the MCP platform for real-time threat protection. + /// Messages are retrieved from the Azure AI Foundry Persistent Agents client. + /// + /// The PersistentAgentsClient instance to retrieve messages from. + /// The thread ID containing the messages to send. + /// The turn context containing conversation information. + /// A cancellation token to cancel the operation. + /// A task representing the asynchronous operation that returns an indicating success or failure. + /// Thrown when , , or is null. + /// Thrown when the operation is canceled via the . + /// + /// This method retrieves messages from the Azure AI Foundry Persistent Agents client using the specified thread ID, + /// converts them to the ChatHistoryMessage format, and sends them to the MCP platform. + /// The CreatedAt timestamp from Azure AI Foundry is preserved for each message. + /// + Task SendChatHistoryAsync( + PersistentAgentsClient agentClient, + string threadId, + ITurnContext turnContext, + CancellationToken cancellationToken = default); + + /// + /// Sends chat history to the MCP platform for real-time threat protection. + /// Messages are retrieved from the Azure AI Foundry Persistent Agents client. + /// + /// The PersistentAgentsClient instance to retrieve messages from. + /// The thread ID containing the messages to send. + /// The turn context containing conversation information. + /// Tool options for sending chat history. + /// A cancellation token to cancel the operation. + /// A task representing the asynchronous operation that returns an indicating success or failure. + /// Thrown when , , , or is null. + /// Thrown when the operation is canceled via the . + /// + /// This method retrieves messages from the Azure AI Foundry Persistent Agents client using the specified thread ID, + /// converts them to the ChatHistoryMessage format, and sends them to the MCP platform. + /// The CreatedAt timestamp from Azure AI Foundry is preserved for each message. + /// + Task SendChatHistoryAsync( + PersistentAgentsClient agentClient, + string threadId, + ITurnContext turnContext, + ToolOptions toolOptions, + CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs index 6fafa677..3725edbf 100644 --- a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs @@ -14,6 +14,8 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AzureFoundry.Services; using ModelContextProtocol.Client; using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Constants = Utils.Constants; @@ -236,4 +238,158 @@ public async Task AddToolServersToAgentAsync( return (toolDefinitions, combinedToolResources); } + + /// + public async Task SendChatHistoryAsync( + ITurnContext turnContext, + PersistentThreadMessage[] messages, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + ArgumentNullException.ThrowIfNull(messages); + cancellationToken.ThrowIfCancellationRequested(); + + var toolOptions = new ToolOptions + { + UserAgentConfiguration = Agent365AzureAIFoundrySdkUserAgentConfiguration.Instance + }; + + return await SendChatHistoryAsync(turnContext, messages, toolOptions, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SendChatHistoryAsync( + ITurnContext turnContext, + PersistentThreadMessage[] messages, + ToolOptions toolOptions, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + ArgumentNullException.ThrowIfNull(messages); + ArgumentNullException.ThrowIfNull(toolOptions); + cancellationToken.ThrowIfCancellationRequested(); + + // Convert PersistentThreadMessage[] to ChatHistoryMessage[] + var chatHistoryMessages = messages.Select(message => + { + // Validate message properties + if (message.Id == null) + { + throw new InvalidOperationException("PersistentThreadMessage.Id cannot be null"); + } + if (message.Role == null) + { + throw new InvalidOperationException("PersistentThreadMessage.Role cannot be null"); + } + + // Extract text content from ContentItems + var content = ExtractContentFromMessage(message); + + // CreatedAt is already a DateTimeOffset in Azure.AI.Agents.Persistent + var timestamp = message.CreatedAt; + + return new ChatHistoryMessage( + id: message.Id, + role: message.Role.ToString().ToLowerInvariant(), + content: content, + timestamp: timestamp + ); + }).ToArray(); + + return await _mcpServerConfigurationService.SendChatHistoryAsync( + turnContext, + chatHistoryMessages, + toolOptions, + cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SendChatHistoryAsync( + PersistentAgentsClient agentClient, + string threadId, + ITurnContext turnContext, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(agentClient); + ArgumentException.ThrowIfNullOrWhiteSpace(threadId); + ArgumentNullException.ThrowIfNull(turnContext); + cancellationToken.ThrowIfCancellationRequested(); + + var toolOptions = new ToolOptions + { + UserAgentConfiguration = Agent365AzureAIFoundrySdkUserAgentConfiguration.Instance + }; + + return await SendChatHistoryAsync(agentClient, threadId, turnContext, toolOptions, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SendChatHistoryAsync( + PersistentAgentsClient agentClient, + string threadId, + ITurnContext turnContext, + ToolOptions toolOptions, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(agentClient); + ArgumentException.ThrowIfNullOrWhiteSpace(threadId); + ArgumentNullException.ThrowIfNull(turnContext); + ArgumentNullException.ThrowIfNull(toolOptions); + cancellationToken.ThrowIfCancellationRequested(); + + try + { + // Retrieve messages from Azure AI Foundry + var messages = new List(); + + await foreach (var message in agentClient.Messages.GetMessagesAsync(threadId, cancellationToken: cancellationToken)) + { + messages.Add(message); + } + + _logger.LogInformation("Retrieved {MessageCount} messages from thread {ThreadId}", messages.Count, threadId); + + // Delegate to the overload that accepts PersistentThreadMessage[] directly + return await SendChatHistoryAsync(turnContext, messages.ToArray(), toolOptions, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _logger.LogInformation("SendChatHistoryAsync operation was canceled for thread {ThreadId}", threadId); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send chat history for thread {ThreadId}: {Message}", threadId, ex.Message); + return OperationResult.Failed(new OperationError(ex)); + } + } + + /// + /// Extracts text content from a PersistentThreadMessage. + /// + /// The message to extract content from. + /// The extracted text content, or an empty string if no text content is found. + private string ExtractContentFromMessage(PersistentThreadMessage message) + { + if (message.ContentItems == null || message.ContentItems.Count == 0) + { + return string.Empty; + } + + var textContent = new System.Text.StringBuilder(); + + foreach (var textContentItem in message.ContentItems.OfType()) + { + if (!string.IsNullOrEmpty(textContentItem.Text)) + { + if (textContent.Length > 0) + { + textContent.Append(" "); + } + textContent.Append(textContentItem.Text); + } + } + + return textContent.ToString(); + } } \ No newline at end of file