chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
public class AgentHostingServiceCollectionExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that providing a null builder to AddAIAgent throws an ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullBuilder_ThrowsArgumentNullException() => Assert.Throws<ArgumentNullException>(
|
||||
() => AgentHostingServiceCollectionExtensions.AddAIAgent(null!, "agent", "instructions"));
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => services.AddAIAgent(null!, "instructions"));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key allows null instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullInstructions_AllowsNull()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var result = services.AddAIAgent("agentName", (string)null!);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with chat client key throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => services.AddAIAgent(null!, "instructions", "key"));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with chat client key allows null instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithKey_NullInstructions_AllowsNull()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var result = services.AddAIAgent("agentName", null, "key");
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null builder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullBuilder_ThrowsArgumentNullException() =>
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
AgentHostingServiceCollectionExtensions.AddAIAgent(null!, "agentName", (sp, key) => new Mock<AIAgent>().Object));
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => services.AddAIAgent(null!, (sp, key) => new Mock<AIAgent>().Object));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullFactory_ThrowsArgumentNullException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var exception = Assert.Throws<ArgumentNullException>(() => services.AddAIAgent("agentName", (Func<IServiceProvider, string, AIAgent>)null!));
|
||||
Assert.Equal("createAgentDelegate", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate returns the same builder instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var result = services.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers the agent as a keyed singleton service by default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_RegistersKeyedSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "testAgent";
|
||||
|
||||
services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object);
|
||||
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent can be called multiple times with different agent names.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_MultipleCalls_RegistersMultipleAgents()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddAIAgent("agent1", "instructions1");
|
||||
services.AddAIAgent("agent2", "instructions2");
|
||||
services.AddAIAgent("agent3", "instructions3");
|
||||
|
||||
var agentDescriptors = services
|
||||
.Where(d => d.ServiceType == typeof(AIAgent) && d.ServiceKey is string)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(3, agentDescriptors.Count);
|
||||
Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent1");
|
||||
Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent2");
|
||||
Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent3");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent handles empty strings for name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_EmptyName_ThrowsArgumentException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
Assert.Throws<ArgumentException>(() => services.AddAIAgent("", "instructions"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent allows empty strings for instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_EmptyInstructions_Succeeds()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var result = services.AddAIAgent("agentName", "");
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key calls the overload with null key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithoutKey_CallsOverloadWithNullKey()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
var result = builder.AddAIAgent("agentName", "instructions");
|
||||
|
||||
// The agent should be registered (proving the method chain worked)
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => d.ServiceKey is "agentName" &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with special characters in name works correctly for valid names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("agent_name")] // underscore is allowed
|
||||
[InlineData("Agent123")] // alphanumeric is allowed
|
||||
[InlineData("_agent")] // can start with underscore
|
||||
[InlineData("agent-name")] // dash is allowed
|
||||
[InlineData("agent.name")] // period is allowed
|
||||
[InlineData("agent:type")] // colon is allowed
|
||||
[InlineData("my.agent_1:type-name")] // complex valid name
|
||||
public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name)
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
var result = builder.AddAIAgent(name, "instructions");
|
||||
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == name &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers with the specified scoped lifetime.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "scopedAgent";
|
||||
|
||||
// Act
|
||||
var result = services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Scoped);
|
||||
|
||||
// Assert
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
Assert.Equal(ServiceLifetime.Scoped, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers with the specified transient lifetime.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "transientAgent";
|
||||
|
||||
// Act
|
||||
var result = services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Transient);
|
||||
|
||||
// Assert
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
|
||||
Assert.Equal(ServiceLifetime.Transient, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the builder exposes the correct lifetime for default registration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_DefaultLifetime_BuilderExposesSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
// Act
|
||||
var result = services.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ServiceLifetime.Singleton, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with instructions overload respects the lifetime parameter.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void AddAIAgent_InstructionsOverload_RespectsLifetime(ServiceLifetime lifetime)
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act
|
||||
var result = services.AddAIAgent("agent", "instructions", lifetime);
|
||||
|
||||
// Assert
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "agent" &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(lifetime, descriptor.Lifetime);
|
||||
Assert.Equal(lifetime, result.Lifetime);
|
||||
}
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
{
|
||||
private const string TestUserId = "test-user-id";
|
||||
private const string CustomClaimType = "custom-claim-type";
|
||||
private const string CustomClaimValue = "custom-claim-value";
|
||||
private const string TestAuthenticationType = "TestAuth";
|
||||
|
||||
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
|
||||
/// </summary>
|
||||
public ClaimsIdentitySessionIsolationKeyProviderTests()
|
||||
{
|
||||
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts null IHttpContextAccessor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotEmpty()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotWhitespace()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionIsolationKeyAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestUserId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the default claim type is the stable, unique NameIdentifier claim rather than the
|
||||
/// non-unique display name claim. This guards against the session-isolation collision described in
|
||||
/// the security report where two principals sharing the same name claim received the same key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncIgnoresNameClaimByDefaultAsync()
|
||||
{
|
||||
// Arrange - only a display-name claim is present; the default provider must not use it.
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType });
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(CustomClaimValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim("other-claim", "value");
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor returns null HttpContext.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor itself is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string FirstValue = "first-value";
|
||||
const string SecondValue = "second-value";
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, FirstValue),
|
||||
new Claim(ClaimTypes.NameIdentifier, SecondValue),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(FirstValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, string.Empty);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for the session-isolation collision security report: two distinct authenticated
|
||||
/// principals that share the same display-name claim but have different stable identifiers and tenants
|
||||
/// must produce distinct isolation keys under the default options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncDistinctForPrincipalsSharingNameClaimAsync()
|
||||
{
|
||||
// Arrange - both principals share the same name claim but differ by NameIdentifier and tenant.
|
||||
const string CommonName = "John Doe";
|
||||
|
||||
var principalA = CreatePrincipal(
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
|
||||
new Claim(ClaimTypes.NameIdentifier, "oid-user-a"),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-a"));
|
||||
|
||||
var principalB = CreatePrincipal(
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
|
||||
new Claim(ClaimTypes.NameIdentifier, "oid-user-b"),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-b"));
|
||||
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalA });
|
||||
string? principalAKey = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalB });
|
||||
string? principalBKey = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("oid-user-a", principalAKey);
|
||||
Assert.Equal("oid-user-b", principalBKey);
|
||||
Assert.NotEqual(principalAKey, principalBKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the request's user is not authenticated,
|
||||
/// even if a claim of the configured type is present. The provider must not derive an isolation key
|
||||
/// from claims on an unauthenticated identity.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenUserNotAuthenticatedAsync()
|
||||
{
|
||||
// Arrange - identity has the claim but no authentication type, so IsAuthenticated is false.
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, TestUserId) };
|
||||
var unauthenticatedIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(unauthenticatedIdentity);
|
||||
var httpContext = new DefaultHttpContext { User = principal };
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.False(unauthenticatedIdentity.IsAuthenticated);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupHttpContextWithClaim(string claimType, string claimValue)
|
||||
{
|
||||
var claims = new[] { new Claim(claimType, claimValue) };
|
||||
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(params Claim[] claims)
|
||||
=> new(new ClaimsIdentity(claims, TestAuthenticationType));
|
||||
|
||||
#endregion
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DelegatingAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
public class DelegatingAgentSessionStoreTests
|
||||
{
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly TestDelegatingAgentSessionStore _delegatingStore;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public DelegatingAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
// Setup inner store mock
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () => new TestDelegatingAgentSessionStore(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor sets the inner store correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithValidInnerStore_SetsInnerStore()
|
||||
{
|
||||
// Act
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Method Delegation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act
|
||||
var session = await this._delegatingStore.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, session);
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
var expectedSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<AgentSession>(s => s == expectedSession),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
// Act
|
||||
await this._delegatingStore.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync awaits the inner store's result before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncAwaitsInnerStoreResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var taskCompletionSource = new TaskCompletionSource<AgentSession>();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<AgentSession>(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult(this._testSession);
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
Assert.Same(this._testSession, await resultTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync awaits the inner store's completion before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedSession = new TestAgentSession();
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult();
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
await resultTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting the exact type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForExactType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting a base type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForBaseType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting AgentSessionStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForAgentSessionStoreType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(AgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains to inner store when type is not satisfied by outer store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through multiple delegation layers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsThoughMultipleDelegationLayers()
|
||||
{
|
||||
// Arrange - create a three-layer chain: outer -> middle -> inner
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
|
||||
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
|
||||
|
||||
// Act - request the innermost store type
|
||||
var result = outerStore.GetService(typeof(ConcreteAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can find a store in the middle of the delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceFindsMiddleStoreInChain()
|
||||
{
|
||||
// Arrange - create a three-layer chain: outer -> middle -> inner
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var middleStore = new AnotherDelegatingAgentSessionStore(innerStore);
|
||||
var outerStore = new TestDelegatingAgentSessionStore(middleStore);
|
||||
|
||||
// Act - request the middle store type
|
||||
var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(middleStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when the requested type is not found in the chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService(typeof(string));
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when a service key is provided but not matched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenServiceKeyProvided()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceThrowsWhenServiceTypeIsNull() =>
|
||||
Assert.Throws<ArgumentNullException>("serviceType", () => this._delegatingStore.GetService(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsItself()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<TestDelegatingAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method chains to inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method returns null when type not found.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Implementation
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of DelegatingAgentSessionStore for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore)
|
||||
{
|
||||
public new AgentSessionStore InnerStore => base.InnerStore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Another delegating store implementation for testing multi-layer chains.
|
||||
/// </summary>
|
||||
private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore);
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
#endregion
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
public class HostApplicationBuilderAgentExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that providing a null builder to AddAIAgent throws an ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullBuilder_ThrowsArgumentNullException() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => HostApplicationBuilderAgentExtensions.AddAIAgent(null!, "agent", "instructions"));
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent(null!, "instructions"));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key allows null instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_NullInstructions_AllowsNull()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
var result = builder.AddAIAgent("agentName", (string)null!);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with chat client key throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent(null!, "instructions", "key"));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with chat client key allows null instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithKey_NullInstructions_AllowsNull()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
var result = builder.AddAIAgent("agentName", null, "key");
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null builder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullBuilder_ThrowsArgumentNullException() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
HostApplicationBuilderAgentExtensions.AddAIAgent(
|
||||
null!,
|
||||
"agentName",
|
||||
(sp, key) => new Mock<AIAgent>().Object));
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent(null!, (sp, key) => new Mock<AIAgent>().Object));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_NullFactory_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddAIAgent("agentName", (Func<IServiceProvider, string, AIAgent>)null!));
|
||||
Assert.Equal("createAgentDelegate", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with factory delegate returns the same builder instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var result = builder.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
|
||||
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers the agent as a keyed singleton service by default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_RegistersKeyedSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "testAgent";
|
||||
|
||||
// Act
|
||||
builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent can be called multiple times with different agent names.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_MultipleCalls_RegistersMultipleAgents()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
builder.AddAIAgent("agent1", "instructions1");
|
||||
builder.AddAIAgent("agent2", "instructions2");
|
||||
builder.AddAIAgent("agent3", "instructions3");
|
||||
|
||||
// Assert
|
||||
var agentDescriptors = builder.Services
|
||||
.Where(d => d.ServiceType == typeof(AIAgent) && d.ServiceKey is string)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(3, agentDescriptors.Count);
|
||||
Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent1");
|
||||
Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent2");
|
||||
Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent3");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent handles empty strings for name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_EmptyName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
builder.AddAIAgent("", "instructions"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent allows empty strings for instructions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_EmptyInstructions_Succeeds()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
var result = builder.AddAIAgent("agentName", "");
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent without chat client key calls the overload with null key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithoutKey_CallsOverloadWithNullKey()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
var result = builder.AddAIAgent("agentName", "instructions");
|
||||
|
||||
// The agent should be registered (proving the method chain worked)
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => d.ServiceKey is "agentName" &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with special characters in name works correctly for valid names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("agent_name")] // underscore is allowed
|
||||
[InlineData("Agent123")] // alphanumeric is allowed
|
||||
[InlineData("_agent")] // can start with underscore
|
||||
[InlineData("agent-name")] // dash is allowed
|
||||
[InlineData("agent.name")] // period is allowed
|
||||
[InlineData("agent:type")] // colon is allowed
|
||||
[InlineData("my.agent_1:type-name")] // complex valid name
|
||||
public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name)
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
var result = builder.AddAIAgent(name, "instructions");
|
||||
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == name &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers with the specified scoped lifetime via the host builder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "scopedAgent";
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Scoped);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
Assert.Equal(ServiceLifetime.Scoped, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers with the specified transient lifetime via the host builder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "transientAgent";
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Transient);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
|
||||
Assert.Equal(ServiceLifetime.Transient, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with instructions overload respects the lifetime parameter via the host builder.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void AddAIAgent_InstructionsOverload_RespectsLifetime(ServiceLifetime lifetime)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agent", "instructions", lifetime);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "agent" &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(lifetime, descriptor.Lifetime);
|
||||
Assert.Equal(lifetime, result.Lifetime);
|
||||
}
|
||||
}
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
public class HostApplicationBuilderWorkflowExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that providing a null builder to AddWorkflow throws an ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_NullBuilder_ThrowsArgumentNullException() =>
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => HostApplicationBuilderWorkflowExtensions.AddWorkflow(
|
||||
null!,
|
||||
"workflow",
|
||||
(sp, key) => CreateTestWorkflow(key)));
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow throws ArgumentNullException for null name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddWorkflow(null!, (sp, key) => CreateTestWorkflow(key)));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow throws ArgumentNullException for null factory delegate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_NullFactory_ThrowsArgumentNullException()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
builder.AddWorkflow("workflowName", null!));
|
||||
Assert.Equal("createWorkflowDelegate", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow returns the IHostWorkflowBuilder instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_ValidParameters_ReturnsBuilder()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var result = builder.AddWorkflow("workflowName", (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.IsType<IHostedWorkflowBuilder>(result, exactMatch: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow registers the workflow as a keyed singleton service by default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_RegistersKeyedSingleton()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
|
||||
builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName &&
|
||||
d.ServiceType == typeof(Workflow));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow can be called multiple times with different workflow names.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_MultipleCalls_RegistersMultipleWorkflows()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
builder.AddWorkflow("workflow1", (sp, key) => CreateTestWorkflow(key));
|
||||
builder.AddWorkflow("workflow2", (sp, key) => CreateTestWorkflow(key));
|
||||
builder.AddWorkflow("workflow3", (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var workflowDescriptors = builder.Services
|
||||
.Where(d => d.ServiceType == typeof(Workflow) && d.ServiceKey is string)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(3, workflowDescriptors.Count);
|
||||
Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow1");
|
||||
Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow2");
|
||||
Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow3");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a handoff workflow can be named from the DI workflow key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_HandoffWorkflowWithName_ResolvesWorkflow()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "handoffWorkflow";
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.Name).Returns("handoffAgent");
|
||||
|
||||
#pragma warning disable MAAIW001 // This test covers hosting handoff workflows.
|
||||
builder.AddWorkflow(WorkflowName, (sp, key) =>
|
||||
AgentWorkflowBuilder.CreateHandoffBuilderWith(mockAgent.Object)
|
||||
.WithName(key)
|
||||
.Build());
|
||||
#pragma warning restore MAAIW001
|
||||
|
||||
var workflow = builder.Build().Services.GetRequiredKeyedService<Workflow>(WorkflowName);
|
||||
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow handles empty strings for name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_EmptyName_ThrowsArgumentException()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
var result = builder.AddWorkflow("", (sp, key) => CreateTestWorkflow(key));
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow with special characters in name works correctly for valid names.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("workflow_name")] // underscore is allowed
|
||||
[InlineData("Workflow123")] // alphanumeric is allowed
|
||||
[InlineData("_workflow")] // can start with underscore
|
||||
[InlineData("workflow-name")] // dash is allowed
|
||||
[InlineData("workflow.name")] // period is allowed
|
||||
[InlineData("workflow:type")] // colon is allowed
|
||||
[InlineData("my.workflow_1:type-name")] // complex valid name
|
||||
public void AddWorkflow_ValidSpecialCharactersInName_Succeeds(string name)
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
var result = builder.AddWorkflow(name, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == name &&
|
||||
d.ServiceType == typeof(Workflow));
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent without a name parameter uses the workflow name as the agent name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAsAIAgent_WithoutName_UsesWorkflowName()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var agentBuilder = workflowBuilder.AddAsAIAgent();
|
||||
|
||||
Assert.NotNull(agentBuilder);
|
||||
|
||||
// Verify workflow is registered with workflow name
|
||||
var workflowDescriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(Workflow));
|
||||
Assert.NotNull(workflowDescriptor);
|
||||
|
||||
// Verify agent is registered with workflow name
|
||||
var agentDescriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(agentDescriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent with a name parameter uses that name instead of the workflow name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAsAIAgent_WithName_UsesProvidedName()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
const string AgentName = "testAgent";
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var agentBuilder = workflowBuilder.AddAsAIAgent(AgentName);
|
||||
|
||||
Assert.NotNull(agentBuilder);
|
||||
|
||||
// Verify workflow is registered with workflow name
|
||||
var workflowDescriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(Workflow));
|
||||
Assert.NotNull(workflowDescriptor);
|
||||
|
||||
// Verify agent is registered with agent name (not workflow name)
|
||||
var agentDescriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName && d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(agentDescriptor);
|
||||
|
||||
// Verify no agent registered with workflow name
|
||||
var wrongAgentDescriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotSame(workflowDescriptor, wrongAgentDescriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent correctly retrieves the workflow using the workflow name, not the agent name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAsAIAgent_WithDifferentName_RetrievesWorkflowCorrectly()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "myWorkflow";
|
||||
const string AgentName = "myAgent";
|
||||
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
workflowBuilder.AddAsAIAgent(AgentName);
|
||||
|
||||
var serviceProvider = builder.Build().Services;
|
||||
|
||||
// Act - Get the agent using the agent name
|
||||
var agent = serviceProvider.GetRequiredKeyedService<AIAgent>(AgentName);
|
||||
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal(AgentName, agent.Name);
|
||||
|
||||
// Verify that we can still get the workflow using the workflow name
|
||||
var workflow = serviceProvider.GetRequiredKeyedService<Workflow>(WorkflowName);
|
||||
Assert.NotNull(workflow);
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent returns IHostedAgentBuilder with correct name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAsAIAgent_ReturnsHostedAgentBuilder()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
const string AgentName = "testAgent";
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var agentBuilder = workflowBuilder.AddAsAIAgent(AgentName);
|
||||
|
||||
Assert.NotNull(agentBuilder);
|
||||
Assert.IsType<IHostedAgentBuilder>(agentBuilder, exactMatch: false);
|
||||
Assert.Equal(AgentName, agentBuilder.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent without name returns IHostedAgentBuilder with workflow name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAsAIAgent_WithoutName_ReturnsHostedAgentBuilderWithWorkflowName()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var agentBuilder = workflowBuilder.AddAsAIAgent();
|
||||
|
||||
Assert.NotNull(agentBuilder);
|
||||
Assert.IsType<IHostedAgentBuilder>(agentBuilder, exactMatch: false);
|
||||
Assert.Equal(WorkflowName, agentBuilder.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent can chain multiple agents from the same workflow.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAsAIAgent_MultipleAgents_FromSameWorkflow()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var agentBuilder1 = workflowBuilder.AddAsAIAgent("agent1");
|
||||
var agentBuilder2 = workflowBuilder.AddAsAIAgent("agent2");
|
||||
|
||||
Assert.NotNull(agentBuilder1);
|
||||
Assert.NotNull(agentBuilder2);
|
||||
|
||||
// Verify both agents are registered
|
||||
var agentDescriptor1 = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "agent1" && d.ServiceType == typeof(AIAgent));
|
||||
var agentDescriptor2 = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "agent2" && d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(agentDescriptor1);
|
||||
Assert.NotNull(agentDescriptor2);
|
||||
|
||||
// Verify workflow is registered only once
|
||||
var workflowDescriptors = builder.Services.Where(
|
||||
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(Workflow)).ToList();
|
||||
Assert.Single(workflowDescriptors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent with null name behaves the same as the parameterless overload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAsAIAgent_WithNullName_UsesWorkflowName()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var agentBuilder = workflowBuilder.AddAsAIAgent(name: null);
|
||||
|
||||
Assert.NotNull(agentBuilder);
|
||||
Assert.Equal(WorkflowName, agentBuilder.Name);
|
||||
|
||||
// Verify agent is registered with workflow name
|
||||
var agentDescriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName && d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(agentDescriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent with empty string name uses empty string as agent name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAsAIAgent_WithEmptyName_UsesEmptyStringAsAgentName()
|
||||
{
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
var agentBuilder = workflowBuilder.AddAsAIAgent(name: "");
|
||||
|
||||
Assert.NotNull(agentBuilder);
|
||||
Assert.Equal("", agentBuilder.Name);
|
||||
|
||||
// Verify agent is registered with empty string name
|
||||
var agentDescriptor = builder.Services.FirstOrDefault(
|
||||
d => d.ServiceKey is string s && s.Length == 0 && d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(agentDescriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow registers with the specified scoped lifetime.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_WithScopedLifetime_RegistersKeyedScoped()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "scopedWorkflow";
|
||||
|
||||
// Act
|
||||
builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key), ServiceLifetime.Scoped);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName &&
|
||||
d.ServiceType == typeof(Workflow));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow registers with the specified transient lifetime.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_WithTransientLifetime_RegistersKeyedTransient()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "transientWorkflow";
|
||||
|
||||
// Act
|
||||
builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key), ServiceLifetime.Transient);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName &&
|
||||
d.ServiceType == typeof(Workflow));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent respects the lifetime parameter.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void AddAsAIAgent_RespectsLifetime(ServiceLifetime lifetime)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
// Act
|
||||
var agentBuilder = workflowBuilder.AddAsAIAgent("agent", lifetime);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "agent" &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(lifetime, descriptor.Lifetime);
|
||||
Assert.Equal(lifetime, agentBuilder.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to create a simple test workflow with a given name.
|
||||
/// </summary>
|
||||
private static Workflow CreateTestWorkflow(string name)
|
||||
{
|
||||
// Create a simple workflow using AgentWorkflowBuilder
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.Name).Returns("testAgent");
|
||||
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: [mockAgent.Object]);
|
||||
}
|
||||
}
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
// 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.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for AI tool registration extensions on <see cref="IHostedAgentBuilder"/>.
|
||||
/// </summary>
|
||||
public sealed class HostedAgentBuilderToolsExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void WithAITool_ThrowsWhenBuilderIsNull()
|
||||
{
|
||||
var tool = new DummyAITool();
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => HostedAgentBuilderExtensions.WithAITool(null!, tool));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAITool_ThrowsWhenToolIsNull()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => builder.WithAITool(tool: null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAITools_ThrowsWhenBuilderIsNull()
|
||||
{
|
||||
var tools = new[] { new DummyAITool() };
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => HostedAgentBuilderExtensions.WithAITools(null!, tools));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAITools_ThrowsWhenToolsArrayIsNull()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => builder.WithAITools(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredTools_ResolvesAllToolsForAgent()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IChatClient>(new MockChatClient());
|
||||
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
var tool1 = new DummyAITool();
|
||||
var tool2 = new DummyAITool();
|
||||
|
||||
builder
|
||||
.WithAITool(tool1)
|
||||
.WithAITool(tool2);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
var agent1Tools = ResolveToolsFromAgent(serviceProvider, "test-agent");
|
||||
Assert.Contains(tool1, agent1Tools);
|
||||
Assert.Contains(tool2, agent1Tools);
|
||||
|
||||
var agent1ToolsDI = ResolveToolsFromDI(serviceProvider, "test-agent");
|
||||
Assert.Contains(tool1, agent1ToolsDI);
|
||||
Assert.Contains(tool2, agent1ToolsDI);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredTools_IsolatedPerAgent()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IChatClient>(new MockChatClient());
|
||||
|
||||
var builder1 = services.AddAIAgent("agent1", "Agent 1 instructions");
|
||||
var builder2 = services.AddAIAgent("agent2", "Agent 2 instructions");
|
||||
|
||||
var tool1 = new DummyAITool();
|
||||
var tool2 = new DummyAITool();
|
||||
var tool3 = new DummyAITool();
|
||||
|
||||
builder1
|
||||
.WithAITool(tool1)
|
||||
.WithAITool(tool2);
|
||||
|
||||
builder2
|
||||
.WithAITool(tool3);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
var agent1Tools = ResolveToolsFromAgent(serviceProvider, "agent1");
|
||||
var agent2Tools = ResolveToolsFromAgent(serviceProvider, "agent2");
|
||||
|
||||
var agent1ToolsDI = ResolveToolsFromDI(serviceProvider, "agent1");
|
||||
var agent2ToolsDI = ResolveToolsFromDI(serviceProvider, "agent2");
|
||||
|
||||
Assert.Contains(tool1, agent1Tools);
|
||||
Assert.Contains(tool2, agent1Tools);
|
||||
Assert.Contains(tool1, agent1ToolsDI);
|
||||
Assert.Contains(tool2, agent1ToolsDI);
|
||||
|
||||
Assert.Contains(tool3, agent2Tools);
|
||||
Assert.Contains(tool3, agent2ToolsDI);
|
||||
}
|
||||
|
||||
private static IList<AITool> ResolveToolsFromAgent(IServiceProvider serviceProvider, string name)
|
||||
{
|
||||
var agent = serviceProvider.GetRequiredKeyedService<AIAgent>(name) as ChatClientAgent;
|
||||
Assert.NotNull(agent?.ChatOptions?.Tools);
|
||||
return agent.ChatOptions.Tools;
|
||||
}
|
||||
|
||||
private static List<AITool> ResolveToolsFromDI(IServiceProvider serviceProvider, string name)
|
||||
{
|
||||
var tools = serviceProvider.GetKeyedServices<AITool>(name);
|
||||
Assert.NotNull(tools);
|
||||
return tools.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAIToolFactory_ThrowsWhenBuilderIsNull()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => HostedAgentBuilderExtensions.WithAITool(null!, CreateTool));
|
||||
|
||||
static AITool CreateTool(IServiceProvider _) => new DummyAITool();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAIToolFactory_ThrowsWhenFactoryIsNull()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => builder.WithAITool(factory: null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAIToolFactory_RegistersToolFromFactory()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IChatClient>(new MockChatClient());
|
||||
|
||||
DummyAITool? createdTool = null;
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
builder.WithAITool(sp =>
|
||||
{
|
||||
createdTool = new DummyAITool();
|
||||
return createdTool;
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var tools = ResolveToolsFromDI(serviceProvider, "test-agent");
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.Same(createdTool, tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAIToolFactory_CanAccessServicesFromFactory()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var mockChatClient = new MockChatClient();
|
||||
services.AddSingleton<IChatClient>(mockChatClient);
|
||||
|
||||
IChatClient? resolvedChatClient = null;
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
builder.WithAITool(sp =>
|
||||
{
|
||||
resolvedChatClient = sp.GetService<IChatClient>();
|
||||
return new DummyAITool();
|
||||
});
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
_ = ResolveToolsFromDI(serviceProvider, "test-agent");
|
||||
|
||||
Assert.Same(mockChatClient, resolvedChatClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAIToolFactory_ToolsAreIsolatedPerAgent()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IChatClient>(new MockChatClient());
|
||||
|
||||
var tool1 = new DummyAITool();
|
||||
var tool2 = new DummyAITool();
|
||||
|
||||
var builder1 = services.AddAIAgent("agent1", "Agent 1 instructions");
|
||||
var builder2 = services.AddAIAgent("agent2", "Agent 2 instructions");
|
||||
|
||||
builder1.WithAITool(_ => tool1);
|
||||
builder2.WithAITool(_ => tool2);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var agent1Tools = ResolveToolsFromDI(serviceProvider, "agent1");
|
||||
var agent2Tools = ResolveToolsFromDI(serviceProvider, "agent2");
|
||||
|
||||
Assert.Single(agent1Tools);
|
||||
Assert.Contains(tool1, agent1Tools);
|
||||
Assert.DoesNotContain(tool2, agent1Tools);
|
||||
|
||||
Assert.Single(agent2Tools);
|
||||
Assert.Contains(tool2, agent2Tools);
|
||||
Assert.DoesNotContain(tool1, agent2Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAIToolFactory_CanCombineWithDirectToolRegistration()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IChatClient>(new MockChatClient());
|
||||
|
||||
var directTool = new DummyAITool();
|
||||
var factoryTool = new DummyAITool();
|
||||
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
builder
|
||||
.WithAITool(directTool)
|
||||
.WithAITool(_ => factoryTool);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var tools = ResolveToolsFromDI(serviceProvider, "test-agent");
|
||||
|
||||
Assert.Equal(2, tools.Count);
|
||||
Assert.Contains(directTool, tools);
|
||||
Assert.Contains(factoryTool, tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithAIToolFactory_ToolsAvailableOnAgent()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IChatClient>(new MockChatClient());
|
||||
|
||||
var factoryTool = new DummyAITool();
|
||||
var builder = services.AddAIAgent("test-agent", "Test instructions");
|
||||
builder.WithAITool(_ => factoryTool);
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var agentTools = ResolveToolsFromAgent(serviceProvider, "test-agent");
|
||||
|
||||
Assert.Contains(factoryTool, agentTools);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory method defaults to the agent's lifetime when no explicit lifetime is specified.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void WithAIToolFactory_DefaultsToAgentLifetime(ServiceLifetime agentLifetime)
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
|
||||
|
||||
// Act
|
||||
builder.WithAITool(_ => new DummyAITool());
|
||||
|
||||
// Assert
|
||||
var toolDescriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "test-agent" &&
|
||||
d.ServiceType == typeof(AITool));
|
||||
|
||||
Assert.NotNull(toolDescriptor);
|
||||
Assert.Equal(agentLifetime, toolDescriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory method accepts an explicit lifetime override.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAIToolFactory_ExplicitLifetimeOverridesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Transient);
|
||||
|
||||
// Act - Transient agent with Singleton tool is valid (longer-lived dependency)
|
||||
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Singleton);
|
||||
|
||||
// Assert
|
||||
var toolDescriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "test-agent" &&
|
||||
d.ServiceType == typeof(AITool));
|
||||
|
||||
Assert.NotNull(toolDescriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, toolDescriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory throws for singleton agent with scoped tool (captive dependency).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAIToolFactory_SingletonAgentWithScopedTool_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Singleton);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Scoped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory throws for singleton agent with transient tool (captive dependency).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAIToolFactory_SingletonAgentWithTransientTool_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Singleton);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory throws for scoped agent with transient tool (captive dependency).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAIToolFactory_ScopedAgentWithTransientTool_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Scoped);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies all valid tool lifetime combinations do not throw.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Transient)]
|
||||
public void WithAIToolFactory_ValidLifetimeCombinations_DoNotThrow(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
builder.WithAITool(_ => new DummyAITool(), toolLifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ValidateToolLifetime correctly identifies all invalid combinations.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Transient)]
|
||||
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Transient)]
|
||||
public void ValidateToolLifetime_InvalidCombinations_Throw(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
HostedAgentBuilderExtensions.ValidateToolLifetime(agentLifetime, toolLifetime));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the WithSessionStore factory method defaults to Singleton regardless of agent lifetime.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void WithSessionStoreFactory_DefaultsToSingleton(ServiceLifetime agentLifetime)
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
|
||||
|
||||
// Act
|
||||
builder.WithSessionStore((sp, name) => new InMemoryAgentSessionStore());
|
||||
|
||||
// Assert
|
||||
var storeDescriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "test-agent" &&
|
||||
d.ServiceType == typeof(AgentSessionStore));
|
||||
|
||||
Assert.NotNull(storeDescriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the WithSessionStore factory method accepts an explicit lifetime override.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithSessionStoreFactory_ExplicitLifetimeOverridesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Transient);
|
||||
|
||||
// Act
|
||||
builder.WithSessionStore((sp, name) => new InMemoryAgentSessionStore(), ServiceLifetime.Singleton);
|
||||
|
||||
// Assert
|
||||
var storeDescriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "test-agent" &&
|
||||
d.ServiceType == typeof(AgentSessionStore));
|
||||
|
||||
Assert.NotNull(storeDescriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dummy AITool implementation for testing.
|
||||
/// </summary>
|
||||
private sealed class DummyAITool : AITool;
|
||||
|
||||
/// <summary>
|
||||
/// Mock chat client for testing.
|
||||
/// </summary>
|
||||
private sealed class MockChatClient : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreTests
|
||||
{
|
||||
private const string TestIsolationKey = "test-key";
|
||||
private const string TestConversationId = "test-conversation-id";
|
||||
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public IsolationKeyScopedAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () =>
|
||||
new IsolationKeyScopedAgentSessionStore(null!, provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null);
|
||||
Assert.NotNull(store);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
|
||||
|
||||
// Act - should not throw
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - conversation ID should be passed through unmodified
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
TestConversationId,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync returns the session from the inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SaveSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act
|
||||
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
sessionToSave,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = false });
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act - should not throw
|
||||
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
|
||||
|
||||
// Assert - conversation ID should be passed through unmodified
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
TestConversationId,
|
||||
sessionToSave,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escaping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that colons in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithColon = "key:with:colons";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithColon);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - colons should be escaped as \:
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"key\\:with\\:colons::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that backslashes in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBackslashesInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBackslash = @"domain\key";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - backslashes should be escaped as \\
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"domain\\\\key::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that both backslashes and colons in the isolation key are escaped correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBoth = @"domain\key:role";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithBoth);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - backslashes escaped first, then colons
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"domain\\\\key\\:role::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Isolation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that different isolation keys result in different scoped conversation IDs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string Key1 = "key-1";
|
||||
const string Key2 = "key-2";
|
||||
string? capturedConversationId1 = null;
|
||||
string? capturedConversationId2 = null;
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIAgent, string, CancellationToken>((_, conversationId, _) =>
|
||||
{
|
||||
if (capturedConversationId1 == null)
|
||||
{
|
||||
capturedConversationId1 = conversationId;
|
||||
}
|
||||
else
|
||||
{
|
||||
capturedConversationId2 = conversationId;
|
||||
}
|
||||
})
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act - Key 1
|
||||
var provider1 = new TestSessionIsolationKeyProvider(Key1);
|
||||
var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1);
|
||||
await store1.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Act - Key 2
|
||||
var provider2 = new TestSessionIsolationKeyProvider(Key2);
|
||||
var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2);
|
||||
await store2.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1);
|
||||
Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2);
|
||||
Assert.NotEqual(capturedConversationId1, capturedConversationId2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<IsolationKeyScopedAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(store, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through to find inner store types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var concreteInnerStore = new ConcreteAgentSessionStore();
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(concreteInnerStore, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
|
||||
/// </summary>
|
||||
public class SessionIsolationKeyProviderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return a non-null isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedKey = "test-key";
|
||||
var provider = new TestSessionIsolationKeyProvider(ExpectedKey);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ExpectedKey, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return null when no key is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that cancellation token is passed through to the provider implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestCancellableSessionIsolationKeyProvider();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(
|
||||
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
|
||||
}
|
||||
|
||||
#region Test Implementations
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation that respects cancellation tokens.
|
||||
/// </summary>
|
||||
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
return "key";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user