Files
westey de80543302 .NET: Adding AgentRunContext to allow accessing agent run info in external downstream components (#3476)
* 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>
2026-02-05 09:45:24 +00:00

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;
}
}
}