// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Moq; using Moq.Protected; namespace Microsoft.Agents.AI.Abstractions.UnitTests; /// /// Unit tests for the class. /// public class AIAgentTests { private readonly Mock _agentMock; private readonly Mock _agentSessionMock; private readonly AgentResponse _invokeResponse; private readonly List _invokeStreamingResponses = []; /// /// Initializes a new instance of the class. /// public AIAgentTests() { this._agentSessionMock = new Mock(MockBehavior.Strict); this._invokeResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hi")); this._invokeStreamingResponses.Add(new AgentResponseUpdate(ChatRole.Assistant, "Hi")); this._agentMock = new Mock { CallBase = true }; this._agentMock .Protected() .Setup>("RunCoreAsync", ItExpr.IsAny>(), ItExpr.Is(t => t == this._agentSessionMock.Object), ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(this._invokeResponse); this._agentMock .Protected() .Setup>("RunCoreStreamingAsync", ItExpr.IsAny>(), ItExpr.Is(t => t == this._agentSessionMock.Object), ItExpr.IsAny(), ItExpr.IsAny()) .Returns(ToAsyncEnumerableAsync(this._invokeStreamingResponses)); } /// /// Tests that invoking without a message calls the mocked invoke method with an empty array. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeWithoutMessageCallsMockedInvokeWithEmptyArrayAsync() { // Arrange var options = new AgentRunOptions(); var cancellationToken = default(CancellationToken); // Act var response = await this._agentMock.Object.RunAsync(this._agentSessionMock.Object, options, cancellationToken); Assert.Equal(this._invokeResponse, response); // Verify that the mocked method was called with the expected parameters this._agentMock .Protected() .Verify>("RunCoreAsync", Times.Once(), ItExpr.Is>(messages => !messages.Any()), ItExpr.Is(t => t == this._agentSessionMock.Object), ItExpr.Is(o => o == options), ItExpr.Is(ct => ct == cancellationToken)); } /// /// Tests that invoking with a string message calls the mocked invoke method with the message in the ICollection of messages. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync() { // Arrange const string Message = "Hello, Agent!"; var options = new AgentRunOptions(); var cancellationToken = default(CancellationToken); // Act var response = await this._agentMock.Object.RunAsync(Message, this._agentSessionMock.Object, options, cancellationToken); Assert.Equal(this._invokeResponse, response); // Verify that the mocked method was called with the expected parameters this._agentMock .Protected() .Verify>("RunCoreAsync", Times.Once(), ItExpr.Is>(messages => messages.Count() == 1 && messages.First().Text == Message), ItExpr.Is(t => t == this._agentSessionMock.Object), ItExpr.Is(o => o == options), ItExpr.Is(ct => ct == cancellationToken)); } /// /// Tests that invoking with a single message calls the mocked invoke method with the message in the ICollection of messages. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync() { // Arrange var message = new ChatMessage(ChatRole.User, "Hello, Agent!"); var options = new AgentRunOptions(); var cancellationToken = default(CancellationToken); // Act var response = await this._agentMock.Object.RunAsync(message, this._agentSessionMock.Object, options, cancellationToken); Assert.Equal(this._invokeResponse, response); // Verify that the mocked method was called with the expected parameters this._agentMock .Protected() .Verify>("RunCoreAsync", Times.Once(), ItExpr.Is>(messages => messages.Count() == 1 && messages.First() == message), ItExpr.Is(t => t == this._agentSessionMock.Object), ItExpr.Is(o => o == options), ItExpr.Is(ct => ct == cancellationToken)); } /// /// Tests that invoking streaming without a message calls the mocked invoke method with an empty array. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeStreamingWithoutMessageCallsMockedInvokeWithEmptyArrayAsync() { // Arrange var options = new AgentRunOptions(); var cancellationToken = default(CancellationToken); // Act await foreach (var response in this._agentMock.Object.RunStreamingAsync(this._agentSessionMock.Object, options, cancellationToken)) { // Assert Assert.Contains(response, this._invokeStreamingResponses); } // Verify that the mocked method was called with the expected parameters this._agentMock .Protected() .Verify>("RunCoreStreamingAsync", Times.Once(), ItExpr.Is>(messages => !messages.Any()), ItExpr.Is(t => t == this._agentSessionMock.Object), ItExpr.Is(o => o == options), ItExpr.Is(ct => ct == cancellationToken)); } /// /// Tests that invoking streaming with a string message calls the mocked invoke method with the message in the ICollection of messages. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeStreamingWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync() { // Arrange const string Message = "Hello, Agent!"; var options = new AgentRunOptions(); var cancellationToken = default(CancellationToken); // Act await foreach (var response in this._agentMock.Object.RunStreamingAsync(Message, this._agentSessionMock.Object, options, cancellationToken)) { // Assert Assert.Contains(response, this._invokeStreamingResponses); } // Verify that the mocked method was called with the expected parameters this._agentMock .Protected() .Verify>("RunCoreStreamingAsync", Times.Once(), ItExpr.Is>(messages => messages.Count() == 1 && messages.First().Text == Message), ItExpr.Is(t => t == this._agentSessionMock.Object), ItExpr.Is(o => o == options), ItExpr.Is(ct => ct == cancellationToken)); } /// /// Tests that invoking streaming with a single message calls the mocked invoke method with the message in the ICollection of messages. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeStreamingWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync() { // Arrange var message = new ChatMessage(ChatRole.User, "Hello, Agent!"); var options = new AgentRunOptions(); var cancellationToken = default(CancellationToken); // Act await foreach (var response in this._agentMock.Object.RunStreamingAsync(message, this._agentSessionMock.Object, options, cancellationToken)) { // Assert Assert.Contains(response, this._invokeStreamingResponses); } // Verify that the mocked method was called with the expected parameters this._agentMock .Protected() .Verify>("RunCoreStreamingAsync", Times.Once(), ItExpr.Is>(messages => messages.Count() == 1 && messages.First() == message), ItExpr.Is(t => t == this._agentSessionMock.Object), ItExpr.Is(o => o == options), ItExpr.Is(ct => ct == cancellationToken)); } /// /// Theory data for RunAsync overloads. /// public static TheoryData RunAsyncOverloads => new() { "NoMessage", "StringMessage", "ChatMessage", "MessagesCollection" }; /// /// Verifies that CurrentRunContext is properly set and accessible from RunCoreAsync for all RunAsync overloads. /// [Theory] [MemberData(nameof(RunAsyncOverloads))] public async Task RunAsync_SetsCurrentRunContext_AccessibleFromRunCoreAsync(string overload) { // Arrange AgentRunContext? capturedContext = null; var session = new TestAgentSession(); var options = new AgentRunOptions(); var agentMock = new Mock { CallBase = true }; agentMock .Protected() .Setup>("RunCoreAsync", ItExpr.IsAny>(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Returns((IEnumerable _, AgentSession? _, AgentRunOptions? _, CancellationToken _) => { capturedContext = AIAgent.CurrentRunContext; return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Response"))); }); // Act switch (overload) { case "NoMessage": await agentMock.Object.RunAsync(session, options); break; case "StringMessage": await agentMock.Object.RunAsync("Hello", session, options); break; case "ChatMessage": await agentMock.Object.RunAsync(new ChatMessage(ChatRole.User, "Hello"), session, options); break; case "MessagesCollection": await agentMock.Object.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session, options); break; } // Assert Assert.NotNull(capturedContext); Assert.Same(agentMock.Object, capturedContext!.Agent); Assert.Same(session, capturedContext.Session); Assert.Same(options, capturedContext.RunOptions); if (overload == "NoMessage") { Assert.Empty(capturedContext.RequestMessages); } else { Assert.Single(capturedContext.RequestMessages); } } /// /// Verifies that CurrentRunContext is properly set and accessible from RunCoreStreamingAsync for all RunStreamingAsync overloads. /// [Theory] [MemberData(nameof(RunAsyncOverloads))] public async Task RunStreamingAsync_SetsCurrentRunContext_AccessibleFromRunCoreStreamingAsync(string overload) { // Arrange AgentRunContext? capturedContext = null; var session = new TestAgentSession(); var options = new AgentRunOptions(); var agentMock = new Mock { CallBase = true }; agentMock .Protected() .Setup>("RunCoreStreamingAsync", ItExpr.IsAny>(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Returns((IEnumerable _, AgentSession? _, AgentRunOptions? _, CancellationToken _) => { capturedContext = AIAgent.CurrentRunContext; return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "Response")]); }); // Act IAsyncEnumerable stream = overload switch { "NoMessage" => agentMock.Object.RunStreamingAsync(session, options), "StringMessage" => agentMock.Object.RunStreamingAsync("Hello", session, options), "ChatMessage" => agentMock.Object.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"), session, options), "MessagesCollection" => agentMock.Object.RunStreamingAsync(new[] { new ChatMessage(ChatRole.User, "Hello") }, session, options), _ => throw new InvalidOperationException($"Unknown overload: {overload}") }; await foreach (AgentResponseUpdate _ in stream) { // Consume the stream } // Assert Assert.NotNull(capturedContext); Assert.Same(agentMock.Object, capturedContext!.Agent); Assert.Same(session, capturedContext.Session); Assert.Same(options, capturedContext.RunOptions); if (overload == "NoMessage") { Assert.Empty(capturedContext.RequestMessages); } else { Assert.Single(capturedContext.RequestMessages); } } [Fact] public void ValidateAgentIDIsIdempotent() { // Arrange var agent = new MockAgent(); // Act string id = agent.Id; // Assert Assert.NotNull(id); Assert.Equal(id, agent.Id); } [Fact] public void ValidateAgentIDCanBeProvidedByDerivedAgentClass() { // Arrange var agent = new MockAgent(id: "test-agent-id"); // Act string id = agent.Id; // Assert Assert.NotNull(id); Assert.Equal("test-agent-id", id); } #region GetService Method Tests /// /// Verify that GetService returns the agent itself when requesting the exact agent type. /// [Fact] public void GetService_RequestingExactAgentType_ReturnsAgent() { // Arrange var agent = new MockAgent(); // Act var result = agent.GetService(typeof(MockAgent)); // Assert Assert.NotNull(result); Assert.Same(agent, result); } /// /// Verify that GetService returns the agent itself when requesting the base AIAgent type. /// [Fact] public void GetService_RequestingAIAgentType_ReturnsAgent() { // Arrange var agent = new MockAgent(); // Act var result = agent.GetService(typeof(AIAgent)); // Assert Assert.NotNull(result); Assert.Same(agent, result); } /// /// Verify that GetService returns null when requesting an unrelated type. /// [Fact] public void GetService_RequestingUnrelatedType_ReturnsNull() { // Arrange var agent = new MockAgent(); // Act var result = agent.GetService(typeof(string)); // Assert Assert.Null(result); } /// /// Verify that GetService returns null when a service key is provided, even for matching types. /// [Fact] public void GetService_WithServiceKey_ReturnsNull() { // Arrange var agent = new MockAgent(); // Act var result = agent.GetService(typeof(MockAgent), "some-key"); // Assert Assert.Null(result); } /// /// Verify that GetService throws ArgumentNullException when serviceType is null. /// [Fact] public void GetService_WithNullServiceType_ThrowsArgumentNullException() { // Arrange var agent = new MockAgent(); // Act & Assert Assert.Throws(() => agent.GetService(null!)); } /// /// Verify that GetService generic method works correctly. /// [Fact] public void GetService_Generic_ReturnsCorrectType() { // Arrange var agent = new MockAgent(); // Act var result = agent.GetService(); // Assert Assert.NotNull(result); Assert.Same(agent, result); } /// /// Verify that GetService generic method returns null for unrelated types. /// [Fact] public void GetService_Generic_ReturnsNullForUnrelatedType() { // Arrange var agent = new MockAgent(); // Act var result = agent.GetService(); // Assert Assert.Null(result); } #endregion #region Name and Description Property Tests /// /// Verify that Name property returns the value from the derived class. /// [Fact] public void Name_ReturnsValueFromDerivedClass() { // Arrange var agent = new MockAgentWithName("TestAgentName", "TestAgentDescription"); // Act string? name = agent.Name; // Assert Assert.Equal("TestAgentName", name); } /// /// Verify that Description property returns the value from the derived class. /// [Fact] public void Description_ReturnsValueFromDerivedClass() { // Arrange var agent = new MockAgentWithName("TestAgentName", "TestAgentDescription"); // Act string? description = agent.Description; // Assert Assert.Equal("TestAgentDescription", description); } /// /// Verify that Name property returns null when not overridden. /// [Fact] public void Name_ReturnsNullByDefault() { // Arrange var agent = new MockAgent(); // Act string? name = agent.Name; // Assert Assert.Null(name); } /// /// Verify that Description property returns null when not overridden. /// [Fact] public void Description_ReturnsNullByDefault() { // Arrange var agent = new MockAgent(); // Act string? description = agent.Description; // Assert Assert.Null(description); } #endregion /// /// Typed mock session for testing purposes. /// private sealed class TestAgentSession : AgentSession; private sealed class MockAgent : AIAgent { public MockAgent(string? id = null) { this.IdCore = id; } protected override string? IdCore { get; } protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override Task RunCoreAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } private sealed class MockAgentWithName : AIAgent { private readonly string? _name; private readonly string? _description; public MockAgentWithName(string? name, string? description) { this._name = name; this._description = description; } public override string? Name => this._name; public override string? Description => this._description; protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override Task RunCoreAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); protected override IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable values) { await Task.Yield(); foreach (var update in values) { yield return update; } } }