mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
de80543302
* Add an AsyncLocal AgentRunContext * Update AgentRunContext session naming * Make AgentRunContext readonly and add ADR * Make session nullable and add unit tests * Add unit tests for setting the context in AIAgent * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix sample in ADR * Fix broken unit test * Add unit test for checking if middleware can access AgentRunContext * Fix build error after merge. * Fix AgentRunContextTests after merge from main --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
482 lines
16 KiB
C#
482 lines
16 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.AI;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Moq;
|
|
|
|
namespace Microsoft.Agents.AI.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the <see cref="AIAgentBuilder"/> class.
|
|
/// </summary>
|
|
public class AIAgentBuilderTests
|
|
{
|
|
/// <summary>
|
|
/// Verify that constructor throws ArgumentNullException when innerAgent is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Constructor_WithNullInnerAgent_ThrowsArgumentNullException()
|
|
{
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>("innerAgent", () => new AIAgentBuilder((AIAgent)null!));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that constructor throws ArgumentNullException when innerAgentFactory is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Constructor_WithNullInnerAgentFactory_ThrowsArgumentNullException()
|
|
{
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>("innerAgentFactory", () => new AIAgentBuilder((Func<IServiceProvider, AIAgent>)null!));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Build returns the inner agent when no middleware is added.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Build_WithNoMiddleware_ReturnsInnerAgent()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act
|
|
var result = builder.Build();
|
|
|
|
// Assert
|
|
Assert.Same(mockAgent.Object, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Build works with factory function.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Build_WithFactory_ReturnsAgentFromFactory()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(_ => mockAgent.Object);
|
|
|
|
// Act
|
|
var result = builder.Build();
|
|
|
|
// Assert
|
|
Assert.Same(mockAgent.Object, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use with simple factory works correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithSimpleFactory_AppliesMiddleware()
|
|
{
|
|
// Arrange
|
|
var mockInnerAgent = new Mock<AIAgent>();
|
|
var mockOuterAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockInnerAgent.Object);
|
|
|
|
// Act
|
|
var result = builder.Use(innerAgent =>
|
|
{
|
|
Assert.Same(mockInnerAgent.Object, innerAgent);
|
|
return mockOuterAgent.Object;
|
|
}).Build();
|
|
|
|
// Assert
|
|
Assert.Same(mockOuterAgent.Object, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use with service provider factory works correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithServiceProviderFactory_AppliesMiddleware()
|
|
{
|
|
// Arrange
|
|
var mockInnerAgent = new Mock<AIAgent>();
|
|
var mockOuterAgent = new Mock<AIAgent>();
|
|
var mockServiceProvider = new Mock<IServiceProvider>();
|
|
var builder = new AIAgentBuilder(mockInnerAgent.Object);
|
|
|
|
// Act
|
|
var result = builder.Use((innerAgent, services) =>
|
|
{
|
|
Assert.Same(mockInnerAgent.Object, innerAgent);
|
|
Assert.NotNull(services);
|
|
return mockOuterAgent.Object;
|
|
}).Build(mockServiceProvider.Object);
|
|
|
|
// Assert
|
|
Assert.Same(mockOuterAgent.Object, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that multiple middleware are applied in correct order (first added is outermost).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithMultipleMiddleware_AppliesInCorrectOrder()
|
|
{
|
|
// Arrange
|
|
var mockInnerAgent = new Mock<AIAgent>();
|
|
var mockMiddleAgent = new Mock<AIAgent>();
|
|
var mockOuterAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockInnerAgent.Object);
|
|
|
|
// Act
|
|
var result = builder
|
|
.Use(innerAgent =>
|
|
{
|
|
// First middleware added (will be outermost) - should receive result of second middleware
|
|
Assert.Same(mockMiddleAgent.Object, innerAgent);
|
|
return mockOuterAgent.Object;
|
|
})
|
|
.Use(innerAgent =>
|
|
{
|
|
// Second middleware added (will be applied first) - should receive the original inner agent
|
|
Assert.Same(mockInnerAgent.Object, innerAgent);
|
|
return mockMiddleAgent.Object;
|
|
})
|
|
.Build();
|
|
|
|
// Assert
|
|
// The result should be from the first middleware since it's the outermost
|
|
Assert.Same(mockOuterAgent.Object, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use throws ArgumentNullException when agentFactory is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithNullSimpleFactory_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>("agentFactory", () => builder.Use((Func<AIAgent, AIAgent>)null!));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use throws ArgumentNullException when agentFactory with service provider is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithNullServiceProviderFactory_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>("agentFactory", () => builder.Use((Func<AIAgent, IServiceProvider, AIAgent>)null!));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Build throws InvalidOperationException when middleware returns null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Build_WithMiddlewareReturningNull_ThrowsInvalidOperationException()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act & Assert
|
|
var exception = Assert.Throws<InvalidOperationException>(() =>
|
|
builder.Use(_ => null!).Build());
|
|
|
|
Assert.Contains("returned null", exception.Message);
|
|
Assert.Contains("AIAgentBuilder", exception.Message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Build uses EmptyServiceProvider when services is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Build_WithNullServices_UsesEmptyServiceProvider()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
IServiceProvider? capturedServices = null;
|
|
|
|
// Act
|
|
builder.Use((agent, services) =>
|
|
{
|
|
capturedServices = services;
|
|
return agent;
|
|
}).Build(null);
|
|
|
|
// Assert
|
|
Assert.NotNull(capturedServices);
|
|
Assert.Null(capturedServices.GetService(typeof(string))); // EmptyServiceProvider returns null for everything
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that service provider is passed correctly to factories.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PassesServiceProviderToFactories()
|
|
{
|
|
// Arrange
|
|
var expectedServiceProvider = new ServiceCollection().BuildServiceProvider();
|
|
var mockInnerAgent = new Mock<AIAgent>();
|
|
var mockOuterAgent = new Mock<AIAgent>();
|
|
|
|
var builder = new AIAgentBuilder(services =>
|
|
{
|
|
Assert.Same(expectedServiceProvider, services);
|
|
return mockInnerAgent.Object;
|
|
});
|
|
|
|
builder.Use((innerAgent, serviceProvider) =>
|
|
{
|
|
Assert.Same(expectedServiceProvider, serviceProvider);
|
|
Assert.Same(mockInnerAgent.Object, innerAgent);
|
|
return mockOuterAgent.Object;
|
|
});
|
|
|
|
// Act
|
|
var result = builder.Build(expectedServiceProvider);
|
|
|
|
// Assert
|
|
Assert.Same(mockOuterAgent.Object, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that pipeline is built in the order added (first added is outermost).
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildsPipelineInOrderAdded()
|
|
{
|
|
// Arrange
|
|
var mockInnerAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockInnerAgent.Object)
|
|
.Use(next => new InnerAgentCapturingAgent("First", next))
|
|
.Use(next => new InnerAgentCapturingAgent("Second", next))
|
|
.Use(next => new InnerAgentCapturingAgent("Third", next));
|
|
|
|
// Act
|
|
var first = (InnerAgentCapturingAgent)builder.Build();
|
|
|
|
// Assert
|
|
Assert.Equal("First", first.TestName);
|
|
var second = (InnerAgentCapturingAgent)first.InnerAgent;
|
|
Assert.Equal("Second", second.TestName);
|
|
var third = (InnerAgentCapturingAgent)second.InnerAgent;
|
|
Assert.Equal("Third", third.TestName);
|
|
Assert.Same(mockInnerAgent.Object, third.InnerAgent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that factories cannot return null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DoesNotAllowFactoriesToReturnNull()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
builder.Use(_ => null!);
|
|
|
|
// Act & Assert
|
|
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
|
|
Assert.Contains("entry at index 0", ex.Message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that EmptyServiceProvider is used when no services are provided and supports keyed services.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UsesEmptyServiceProviderWhenNoServicesProvided()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act & Assert
|
|
builder.Use((innerAgent, serviceProvider) =>
|
|
{
|
|
Assert.Null(serviceProvider.GetService(typeof(object)));
|
|
|
|
var keyedServiceProvider = Assert.IsType<IKeyedServiceProvider>(serviceProvider, exactMatch: false);
|
|
Assert.Null(keyedServiceProvider.GetKeyedService(typeof(object), "key"));
|
|
Assert.Throws<InvalidOperationException>(() => keyedServiceProvider.GetRequiredKeyedService(typeof(object), "key"));
|
|
|
|
return innerAgent;
|
|
});
|
|
builder.Build();
|
|
}
|
|
|
|
#region Delegate Overload Tests
|
|
|
|
/// <summary>
|
|
/// Verify that Use with shared delegate throws ArgumentNullException when sharedFunc is null.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithNullSharedFunc_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>("sharedFunc", () =>
|
|
builder.Use((Func<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, Func<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>)null!));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use with both delegates null throws ArgumentNullException.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithBothDelegatesNull_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act & Assert
|
|
var exception = Assert.Throws<ArgumentNullException>(() =>
|
|
builder.Use(null, null));
|
|
|
|
Assert.Contains("runFunc", exception.Message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use with shared delegate creates AnonymousDelegatingAIAgent.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithSharedDelegate_CreatesAnonymousDelegatingAgent()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act
|
|
var result = builder.Use((_, _, _, _, _) => Task.CompletedTask).Build();
|
|
|
|
// Assert
|
|
Assert.IsType<AnonymousDelegatingAIAgent>(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use with runFunc only creates AnonymousDelegatingAIAgent.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithRunFuncOnly_CreatesAnonymousDelegatingAgent()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act
|
|
var result = builder.Use((_, _, _, _, _) => Task.FromResult(new AgentResponse()), null).Build();
|
|
|
|
// Assert
|
|
Assert.IsType<AnonymousDelegatingAIAgent>(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use with runStreamingFunc only creates AnonymousDelegatingAIAgent.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithStreamingFuncOnly_CreatesAnonymousDelegatingAgent()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act
|
|
var result = builder.Use(null, (_, _, _, _, _) => AsyncEnumerable.Empty<AgentResponseUpdate>()).Build();
|
|
|
|
// Assert
|
|
Assert.IsType<AnonymousDelegatingAIAgent>(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use with both delegates creates AnonymousDelegatingAIAgent.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Use_WithBothDelegates_CreatesAnonymousDelegatingAgent()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
// Act
|
|
var result = builder.Use(
|
|
(_, _, _, _, _) => Task.FromResult(new AgentResponse()),
|
|
(_, _, _, _, _) => AsyncEnumerable.Empty<AgentResponseUpdate>()).Build();
|
|
|
|
// Assert
|
|
Assert.IsType<AnonymousDelegatingAIAgent>(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that Use with both delegates allows both to access AgentRunContext.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Use_WithBothDelegates_AllowsDelegateToAccessAgentRunContextAsync()
|
|
{
|
|
// Arrange
|
|
var mockAgent = new Mock<AIAgent>();
|
|
var mockSession = new Mock<AgentSession>();
|
|
var builder = new AIAgentBuilder(mockAgent.Object);
|
|
|
|
AIAgent? builtAgent = null;
|
|
|
|
bool nonStreamingMiddlewareExecuted = false;
|
|
bool streamingMiddlwareExecuted = true;
|
|
|
|
builtAgent = builder.Use(
|
|
(_, _, _, _, _) =>
|
|
{
|
|
Assert.NotNull(AIAgent.CurrentRunContext);
|
|
Assert.Same(builtAgent, AIAgent.CurrentRunContext.Agent);
|
|
Assert.Same(mockSession.Object, AIAgent.CurrentRunContext.Session);
|
|
nonStreamingMiddlewareExecuted = true;
|
|
return Task.FromResult(new AgentResponse());
|
|
},
|
|
(_, _, _, _, _) =>
|
|
{
|
|
Assert.NotNull(AIAgent.CurrentRunContext);
|
|
Assert.Same(builtAgent, AIAgent.CurrentRunContext.Agent);
|
|
Assert.Same(mockSession.Object, AIAgent.CurrentRunContext.Session);
|
|
streamingMiddlwareExecuted = true;
|
|
return AsyncEnumerable.Empty<AgentResponseUpdate>();
|
|
}).Build();
|
|
|
|
// Act
|
|
await builtAgent.RunAsync("Input message", mockSession.Object);
|
|
await foreach (var update in builtAgent.RunStreamingAsync("Input message", mockSession.Object))
|
|
{
|
|
}
|
|
|
|
// Assert
|
|
Assert.True(nonStreamingMiddlewareExecuted);
|
|
Assert.True(streamingMiddlwareExecuted);
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Helper class for testing pipeline order.
|
|
/// </summary>
|
|
private sealed class InnerAgentCapturingAgent : DelegatingAIAgent
|
|
{
|
|
public string TestName { get; }
|
|
public new AIAgent InnerAgent => base.InnerAgent;
|
|
|
|
public InnerAgentCapturingAgent(string name, AIAgent innerAgent) : base(innerAgent)
|
|
{
|
|
this.TestName = name;
|
|
}
|
|
}
|
|
}
|