test
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
Python - Merge - Tests / paths-filter (push) Has been cancelled
Python - Merge - Tests / Python Tests - Core (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / Python Tests - Azure AI (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / python-integration-tests-check (push) Has been cancelled
Python - Lab Tests / paths-filter (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.14) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.14) (push) Has been cancelled
Check .md links / markdown-link-check (push) Has been cancelled

This commit is contained in:
2026-01-24 03:05:12 +11:00
parent f78f2388b3
commit 539852f81c
2584 changed files with 287471 additions and 0 deletions

View File

@@ -0,0 +1,436 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIAgentBuilder"/> class.
/// </summary>
public class AIAgentBuilderTests
{
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerAgent is null.
/// </summary>
[Fact]
public void Constructor_WithNullInnerAgent_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("innerAgent", () => new AIAgentBuilder((AIAgent)null!));
}
/// <summary>
/// Verify that constructor throws ArgumentNullException when innerAgentFactory is null.
/// </summary>
[Fact]
public void Constructor_WithNullInnerAgentFactory_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("innerAgentFactory", () => new AIAgentBuilder((Func<IServiceProvider, AIAgent>)null!));
}
/// <summary>
/// Verify that Build returns the inner agent when no middleware is added.
/// </summary>
[Fact]
public void Build_WithNoMiddleware_ReturnsInnerAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Build();
// Assert
Assert.Same(mockAgent.Object, result);
}
/// <summary>
/// Verify that Build works with factory function.
/// </summary>
[Fact]
public void Build_WithFactory_ReturnsAgentFromFactory()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(_ => mockAgent.Object);
// Act
var result = builder.Build();
// Assert
Assert.Same(mockAgent.Object, result);
}
/// <summary>
/// Verify that Use with simple factory works correctly.
/// </summary>
[Fact]
public void Use_WithSimpleFactory_AppliesMiddleware()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
// Act
var result = builder.Use(innerAgent =>
{
Assert.Same(mockInnerAgent.Object, innerAgent);
return mockOuterAgent.Object;
}).Build();
// Assert
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that Use with service provider factory works correctly.
/// </summary>
[Fact]
public void Use_WithServiceProviderFactory_AppliesMiddleware()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var mockServiceProvider = new Mock<IServiceProvider>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
// Act
var result = builder.Use((innerAgent, services) =>
{
Assert.Same(mockInnerAgent.Object, innerAgent);
Assert.NotNull(services);
return mockOuterAgent.Object;
}).Build(mockServiceProvider.Object);
// Assert
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that multiple middleware are applied in correct order (first added is outermost).
/// </summary>
[Fact]
public void Use_WithMultipleMiddleware_AppliesInCorrectOrder()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var mockMiddleAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockInnerAgent.Object);
// Act
var result = builder
.Use(innerAgent =>
{
// First middleware added (will be outermost) - should receive result of second middleware
Assert.Same(mockMiddleAgent.Object, innerAgent);
return mockOuterAgent.Object;
})
.Use(innerAgent =>
{
// Second middleware added (will be applied first) - should receive the original inner agent
Assert.Same(mockInnerAgent.Object, innerAgent);
return mockMiddleAgent.Object;
})
.Build();
// Assert
// The result should be from the first middleware since it's the outermost
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that Use throws ArgumentNullException when agentFactory is null.
/// </summary>
[Fact]
public void Use_WithNullSimpleFactory_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>("agentFactory", () => builder.Use((Func<AIAgent, AIAgent>)null!));
}
/// <summary>
/// Verify that Use throws ArgumentNullException when agentFactory with service provider is null.
/// </summary>
[Fact]
public void Use_WithNullServiceProviderFactory_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>("agentFactory", () => builder.Use((Func<AIAgent, IServiceProvider, AIAgent>)null!));
}
/// <summary>
/// Verify that Build throws InvalidOperationException when middleware returns null.
/// </summary>
[Fact]
public void Build_WithMiddlewareReturningNull_ThrowsInvalidOperationException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
builder.Use(_ => null!).Build());
Assert.Contains("returned null", exception.Message);
Assert.Contains("AIAgentBuilder", exception.Message);
}
/// <summary>
/// Verify that Build uses EmptyServiceProvider when services is null.
/// </summary>
[Fact]
public void Build_WithNullServices_UsesEmptyServiceProvider()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
IServiceProvider? capturedServices = null;
// Act
builder.Use((agent, services) =>
{
capturedServices = services;
return agent;
}).Build(null);
// Assert
Assert.NotNull(capturedServices);
Assert.Null(capturedServices.GetService(typeof(string))); // EmptyServiceProvider returns null for everything
}
/// <summary>
/// Verify that service provider is passed correctly to factories.
/// </summary>
[Fact]
public void PassesServiceProviderToFactories()
{
// Arrange
var expectedServiceProvider = new ServiceCollection().BuildServiceProvider();
var mockInnerAgent = new Mock<AIAgent>();
var mockOuterAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(services =>
{
Assert.Same(expectedServiceProvider, services);
return mockInnerAgent.Object;
});
builder.Use((innerAgent, serviceProvider) =>
{
Assert.Same(expectedServiceProvider, serviceProvider);
Assert.Same(mockInnerAgent.Object, innerAgent);
return mockOuterAgent.Object;
});
// Act
var result = builder.Build(expectedServiceProvider);
// Assert
Assert.Same(mockOuterAgent.Object, result);
}
/// <summary>
/// Verify that pipeline is built in the order added (first added is outermost).
/// </summary>
[Fact]
public void BuildsPipelineInOrderAdded()
{
// Arrange
var mockInnerAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockInnerAgent.Object)
.Use(next => new InnerAgentCapturingAgent("First", next))
.Use(next => new InnerAgentCapturingAgent("Second", next))
.Use(next => new InnerAgentCapturingAgent("Third", next));
// Act
var first = (InnerAgentCapturingAgent)builder.Build();
// Assert
Assert.Equal("First", first.TestName);
var second = (InnerAgentCapturingAgent)first.InnerAgent;
Assert.Equal("Second", second.TestName);
var third = (InnerAgentCapturingAgent)second.InnerAgent;
Assert.Equal("Third", third.TestName);
Assert.Same(mockInnerAgent.Object, third.InnerAgent);
}
/// <summary>
/// Verify that factories cannot return null.
/// </summary>
[Fact]
public void DoesNotAllowFactoriesToReturnNull()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
builder.Use(_ => null!);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
Assert.Contains("entry at index 0", ex.Message);
}
/// <summary>
/// Verify that EmptyServiceProvider is used when no services are provided and supports keyed services.
/// </summary>
[Fact]
public void UsesEmptyServiceProviderWhenNoServicesProvided()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
builder.Use((innerAgent, serviceProvider) =>
{
Assert.Null(serviceProvider.GetService(typeof(object)));
var keyedServiceProvider = Assert.IsType<IKeyedServiceProvider>(serviceProvider, exactMatch: false);
Assert.Null(keyedServiceProvider.GetKeyedService(typeof(object), "key"));
Assert.Throws<InvalidOperationException>(() => keyedServiceProvider.GetRequiredKeyedService(typeof(object), "key"));
return innerAgent;
});
builder.Build();
}
#region Delegate Overload Tests
/// <summary>
/// Verify that Use with shared delegate throws ArgumentNullException when sharedFunc is null.
/// </summary>
[Fact]
public void Use_WithNullSharedFunc_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>("sharedFunc", () =>
builder.Use((Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>)null!));
}
/// <summary>
/// Verify that Use with both delegates null throws ArgumentNullException.
/// </summary>
[Fact]
public void Use_WithBothDelegatesNull_ThrowsArgumentNullException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.Use(null, null));
Assert.Contains("runFunc", exception.Message);
}
/// <summary>
/// Verify that Use with shared delegate creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithSharedDelegate_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use((_, _, _, _, _) => Task.CompletedTask).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with runFunc only creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithRunFuncOnly_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use((_, _, _, _, _) => Task.FromResult(new AgentResponse()), null).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with runStreamingFunc only creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithStreamingFuncOnly_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use(null, (_, _, _, _, _) => AsyncEnumerable.Empty<AgentResponseUpdate>()).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
/// <summary>
/// Verify that Use with both delegates creates AnonymousDelegatingAIAgent.
/// </summary>
[Fact]
public void Use_WithBothDelegates_CreatesAnonymousDelegatingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.Use(
(_, _, _, _, _) => Task.FromResult(new AgentResponse()),
(_, _, _, _, _) => AsyncEnumerable.Empty<AgentResponseUpdate>()).Build();
// Assert
Assert.IsType<AnonymousDelegatingAIAgent>(result);
}
#endregion
/// <summary>
/// Helper class for testing pipeline order.
/// </summary>
private sealed class InnerAgentCapturingAgent : DelegatingAIAgent
{
public string TestName { get; }
public new AIAgent InnerAgent => base.InnerAgent;
public InnerAgentCapturingAgent(string name, AIAgent innerAgent) : base(innerAgent)
{
this.TestName = name;
}
}
}

View File

@@ -0,0 +1,431 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIAgentExtensions.AsAIFunction"/> method.
/// </summary>
public class AgentExtensionsTests
{
[Fact]
public void CreateFromAgent_WithNullAgent_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
AIAgentExtensions.AsAIFunction(null!));
Assert.Equal("agent", exception.ParamName);
}
[Fact]
public void CreateFromAgent_WithValidAgent_ReturnsAIFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test agent description");
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal("Test agent description", result.Description);
}
[Fact]
public void CreateFromAgent_WithAgentHavingNullName_UsesDefaultName()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns((string?)null);
mockAgent.Setup(a => a.Description).Returns("Test description");
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Name);
Assert.Equal("Test description", result.Description);
}
[Fact]
public void CreateFromAgent_WithAgentHavingNullDescription_UsesDefaultDescription()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns((string?)null);
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal("Invoke an agent to retrieve some information.", result.Description);
}
[Fact]
public void CreateFromAgent_WithCustomOptions_UsesCustomOptions()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test agent description");
var customOptions = new AIFunctionFactoryOptions
{
Name = "CustomName",
Description = "Custom description"
};
// Act
var result = mockAgent.Object.AsAIFunction(customOptions);
// Assert
Assert.NotNull(result);
Assert.Equal("CustomName", result.Name);
Assert.Equal("Custom description", result.Description);
}
[Fact]
public void CreateFromAgent_WithNullOptions_UsesAgentProperties()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test agent description");
// Act
var result = mockAgent.Object.AsAIFunction(null);
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal("Test agent description", result.Description);
}
[Fact]
public async Task CreateFromAgent_WhenFunctionInvokedAsync_CallsAgentRunAsync()
{
// Arrange
var expectedResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
var aiFunction = testAgent.AsAIFunction();
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("Test response", result.ToString());
}
[Fact]
public async Task CreateFromAgent_WhenFunctionInvokedWithCancellationTokenAsync_PassesCancellationTokenAsync()
{
// Arrange
var expectedResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
using var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;
var aiFunction = testAgent.AsAIFunction();
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments, cancellationToken);
// Assert
Assert.NotNull(result);
Assert.Equal("Test response", result.ToString());
}
[Fact]
public async Task CreateFromAgent_WhenAgentThrowsExceptionAsync_PropagatesExceptionAsync()
{
// Arrange
var expectedException = new InvalidOperationException("Test exception");
var testAgent = new TestAgent("TestAgent", "Test description", expectedException);
var aiFunction = testAgent.AsAIFunction();
// Act & Assert
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await aiFunction.InvokeAsync(arguments));
Assert.Same(expectedException, actualException);
}
[Fact]
public void CreateFromAgent_ReturnsInvokableFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns("Test description");
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
// Verify the function has the expected parameter schema
var parameters = result.JsonSchema;
// Verify it has a query parameter
Assert.True(parameters.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("query", out var queryProperty));
Assert.True(queryProperty.TryGetProperty("type", out var typeProperty));
Assert.Equal("string", typeProperty.GetString());
}
[Fact]
public void CreateFromAgent_WithEmptyAgentName_CreatesValidFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns(string.Empty);
mockAgent.Setup(a => a.Description).Returns("Test description");
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal(string.Empty, result.Name);
Assert.Equal("Test description", result.Description);
}
[Fact]
public void CreateFromAgent_WithEmptyAgentDescription_CreatesValidFunction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("TestAgent");
mockAgent.Setup(a => a.Description).Returns(string.Empty);
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal("TestAgent", result.Name);
Assert.Equal(string.Empty, result.Description);
}
[Fact]
public void CreateFromAgent_WithCustomOptionsOverridingNullAgentProperties_UsesCustomOptions()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns((string?)null);
mockAgent.Setup(a => a.Description).Returns((string?)null);
var customOptions = new AIFunctionFactoryOptions
{
Name = "OverrideName",
Description = "Override description"
};
// Act
var result = mockAgent.Object.AsAIFunction(customOptions);
// Assert
Assert.NotNull(result);
Assert.Equal("OverrideName", result.Name);
Assert.Equal("Override description", result.Description);
}
[Fact]
public async Task CreateFromAgent_InvokeWithComplexResponseFromAgentAsync_ReturnsCorrectResponseAsync()
{
// Arrange
var expectedResponse = new AgentResponse
{
AgentId = "agent-123",
ResponseId = "response-456",
CreatedAt = DateTimeOffset.UtcNow,
Messages = { new ChatMessage(ChatRole.Assistant, "Complex response") }
};
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
var aiFunction = testAgent.AsAIFunction();
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("Complex response", result.ToString());
}
[Fact]
public async Task CreateFromAgent_InvokeWithAdditionalProperties_PropagatesAdditionalPropertiesToChildAgentAsync()
{
// Arrange
var expectedResponse = new AgentResponse
{
AgentId = "agent-123",
ResponseId = "response-456",
CreatedAt = DateTimeOffset.UtcNow,
Messages = { new ChatMessage(ChatRole.Assistant, "Complex response") }
};
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
var aiFunction = testAgent.AsAIFunction();
// Use reflection to set the protected CurrentContext property
var context = new FunctionInvocationContext()
{
Options = new()
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
{ "customProperty1", "value1" },
{ "customProperty2", 42 }
}
}
};
SetFunctionInvokingChatClientCurrentContext(context);
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("Complex response", result.ToString());
Assert.NotNull(testAgent.ReceivedAgentRunOptions);
Assert.NotNull(testAgent.ReceivedAgentRunOptions!.AdditionalProperties);
Assert.Equal("value1", testAgent.ReceivedAgentRunOptions!.AdditionalProperties["customProperty1"]);
Assert.Equal(42, testAgent.ReceivedAgentRunOptions!.AdditionalProperties["customProperty2"]);
}
[Theory]
[InlineData("MyAgent", "MyAgent")]
[InlineData("Agent123", "Agent123")]
[InlineData("Agent_With_Underscores", "Agent_With_Underscores")]
[InlineData("Agent_With_________@@@@_Underscores", "Agent_With_Underscores")]
[InlineData("123Agent", "123Agent")]
[InlineData("My-Agent", "My_Agent")]
[InlineData("My Agent", "My_Agent")]
[InlineData("Agent@123", "Agent_123")]
[InlineData("Agent/With\\Slashes", "Agent_With_Slashes")]
[InlineData("Agent.With.Dots", "Agent_With_Dots")]
public void CreateFromAgent_SanitizesAgentName(string agentName, string expectedFunctionName)
{
// Arrange
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns(agentName);
// Act
var result = mockAgent.Object.AsAIFunction();
// Assert
Assert.NotNull(result);
Assert.Equal(expectedFunctionName, result.Name);
}
/// <summary>
/// Uses reflection to set the protected static CurrentContext property on FunctionInvokingChatClient.
/// </summary>
private static void SetFunctionInvokingChatClientCurrentContext(FunctionInvocationContext? context)
{
// Access the private static field _currentContext which is an AsyncLocal<FunctionInvocationContext?>
var currentContextField = typeof(FunctionInvokingChatClient).GetField(
"_currentContext",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (currentContextField?.GetValue(null) is AsyncLocal<FunctionInvocationContext?> asyncLocal)
{
asyncLocal.Value = context;
}
}
/// <summary>
/// Test implementation of AIAgent for testing purposes.
/// </summary>
private sealed class TestAgent : AIAgent
{
private readonly AgentResponse? _responseToReturn;
private readonly Exception? _exceptionToThrow;
public TestAgent(string? name, string? description, AgentResponse responseToReturn)
{
this.Name = name;
this.Description = description;
this._responseToReturn = responseToReturn;
}
public TestAgent(string? name, string? description, Exception exceptionToThrow)
{
this.Name = name;
this.Description = description;
this._exceptionToThrow = exceptionToThrow;
}
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public override string? Name { get; }
public override string? Description { get; }
public List<ChatMessage> ReceivedMessages { get; } = [];
public AgentRunOptions? ReceivedAgentRunOptions { get; private set; }
public CancellationToken LastCancellationToken { get; private set; }
public int RunAsyncCallCount { get; private set; }
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
this.RunAsyncCallCount++;
this.LastCancellationToken = cancellationToken;
this.ReceivedMessages.AddRange(messages);
this.ReceivedAgentRunOptions = options;
if (this._exceptionToThrow is not null)
{
throw this._exceptionToThrow;
}
return Task.FromResult(this._responseToReturn!);
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await this.RunAsync(messages, thread, options, cancellationToken);
foreach (var update in response.ToAgentResponseUpdates())
{
yield return update;
}
}
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Tests for <see cref="AgentJsonUtilities"/>
/// </summary>
public class AgentJsonUtilitiesTests
{
[Fact]
public void DefaultOptions_HasExpectedConfiguration()
{
var options = AgentJsonUtilities.DefaultOptions;
// Must be read-only singleton.
Assert.NotNull(options);
Assert.Same(options, AgentJsonUtilities.DefaultOptions);
Assert.True(options.IsReadOnly);
// Must conform to JsonSerializerDefaults.Web
Assert.Equal(JsonNamingPolicy.CamelCase, options.PropertyNamingPolicy);
Assert.True(options.PropertyNameCaseInsensitive);
Assert.Equal(JsonNumberHandling.AllowReadingFromString, options.NumberHandling);
// Additional settings
Assert.Equal(JsonIgnoreCondition.WhenWritingNull, options.DefaultIgnoreCondition);
Assert.Same(JavaScriptEncoder.UnsafeRelaxedJsonEscaping, options.Encoder);
}
[Theory]
[InlineData("<script>alert('XSS')</script>", "<script>alert('XSS')</script>")]
[InlineData("""{"forecast":"sunny", "temperature":"75"}""", """{\"forecast\":\"sunny\", \"temperature\":\"75\"}""")]
[InlineData("""{"message":"Πάντα ῥεῖ."}""", """{\"message\":\"Πάντα ῥεῖ.\"}""")]
[InlineData("""{"message":"七転び八起き"}""", """{\"message\":\"七転び八起き\"}""")]
[InlineData("""☺️🤖🌍𝄞""", """☺️\uD83E\uDD16\uD83C\uDF0D\uD834\uDD1E""")]
public void DefaultOptions_UsesExpectedEscaping(string input, string expectedJsonString)
{
var options = AgentJsonUtilities.DefaultOptions;
string json = JsonSerializer.Serialize(input, options);
Assert.Equal($@"""{expectedJsonString}""", json);
}
[Fact]
public void DefaultOptions_UsesReflectionWhenDefault()
{
Type anonType = new { Name = 42 }.GetType();
Assert.Equal(JsonSerializer.IsReflectionEnabledByDefault, AgentJsonUtilities.DefaultOptions.TryGetTypeInfo(anonType, out _));
}
// The following two tests validate behaviors of reflection-based serialization
// which is only available in .NET Framework builds.
#if NETFRAMEWORK
[Fact]
public void DefaultOptions_AllowsReadingNumbersFromStrings_AndOmitsNulls()
{
var obj = JsonSerializer.Deserialize<NumberContainer>(
"{\"value\":\"42\",\"optional\":null}", // value as string, optional null
AgentJsonUtilities.DefaultOptions);
Assert.NotNull(obj);
Assert.Equal(42, obj!.Value);
Assert.Null(obj.Optional);
Assert.Equal("{\"value\":42}",
JsonSerializer.Serialize(obj, AgentJsonUtilities.DefaultOptions)); // null omitted
}
[Fact]
public void DefaultOptions_SerializesEnumsAsStrings()
{
Assert.Equal("\"Monday\"", JsonSerializer.Serialize(DayOfWeek.Monday, AgentJsonUtilities.DefaultOptions));
}
#endif
[Fact]
public void DefaultOptions_UsesCamelCasePropertyNames_ForAgentResponse()
{
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hello"));
string json = JsonSerializer.Serialize(response, AgentJsonUtilities.DefaultOptions);
Assert.Contains("\"messages\"", json);
Assert.DoesNotContain("\"Messages\"", json);
}
private sealed class NumberContainer
{
public int Value { get; set; }
public string? Optional { get; set; }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests;
public class ChatClientAgentContinuationTokenTests
{
[Fact]
public void ToBytes_Roundtrip()
{
// Arrange
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
{
InputMessages =
[
new ChatMessage(ChatRole.User, "Hello!"),
new ChatMessage(ChatRole.User, "How are you?")
],
ResponseUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
]
};
// Act
ReadOnlyMemory<byte> bytes = chatClientToken.ToBytes();
ChatClientAgentContinuationToken tokenFromBytes = ChatClientAgentContinuationToken.FromToken(ResponseContinuationToken.FromBytes(bytes));
// Assert
Assert.NotNull(tokenFromBytes);
Assert.Equal(chatClientToken.ToBytes().ToArray(), tokenFromBytes.ToBytes().ToArray());
// Verify InnerToken
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), tokenFromBytes.InnerToken.ToBytes().ToArray());
// Verify InputMessages
Assert.NotNull(tokenFromBytes.InputMessages);
Assert.Equal(chatClientToken.InputMessages.Count(), tokenFromBytes.InputMessages.Count());
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
{
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, tokenFromBytes.InputMessages.ElementAt(i).Role);
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, tokenFromBytes.InputMessages.ElementAt(i).Text);
}
// Verify ResponseUpdates
Assert.NotNull(tokenFromBytes.ResponseUpdates);
Assert.Equal(chatClientToken.ResponseUpdates.Count, tokenFromBytes.ResponseUpdates.Count);
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
{
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, tokenFromBytes.ResponseUpdates.ElementAt(i).Role);
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, tokenFromBytes.ResponseUpdates.ElementAt(i).Text);
}
}
[Fact]
public void Serialization_Roundtrip()
{
// Arrange
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
{
InputMessages =
[
new ChatMessage(ChatRole.User, "Hello!"),
new ChatMessage(ChatRole.User, "How are you?")
],
ResponseUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
]
};
// Act
string json = JsonSerializer.Serialize(chatClientToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
ResponseContinuationToken? deserializedToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(json, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
ChatClientAgentContinuationToken deserializedChatClientToken = ChatClientAgentContinuationToken.FromToken(deserializedToken!);
// Assert
Assert.NotNull(deserializedChatClientToken);
Assert.Equal(chatClientToken.ToBytes().ToArray(), deserializedChatClientToken.ToBytes().ToArray());
// Verify InnerToken
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), deserializedChatClientToken.InnerToken.ToBytes().ToArray());
// Verify InputMessages
Assert.NotNull(deserializedChatClientToken.InputMessages);
Assert.Equal(chatClientToken.InputMessages.Count(), deserializedChatClientToken.InputMessages.Count());
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
{
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, deserializedChatClientToken.InputMessages.ElementAt(i).Role);
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, deserializedChatClientToken.InputMessages.ElementAt(i).Text);
}
// Verify ResponseUpdates
Assert.NotNull(deserializedChatClientToken.ResponseUpdates);
Assert.Equal(chatClientToken.ResponseUpdates.Count, deserializedChatClientToken.ResponseUpdates.Count);
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
{
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Role);
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Text);
}
}
[Fact]
public void FromToken_WithChatClientAgentContinuationToken_ReturnsSameInstance()
{
// Arrange
ChatClientAgentContinuationToken originalToken = new(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 }));
// Act
ChatClientAgentContinuationToken fromToken = ChatClientAgentContinuationToken.FromToken(originalToken);
// Assert
Assert.Same(originalToken, fromToken);
}
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ChatClientAgentOptions"/> class.
/// </summary>
public class ChatClientAgentOptionsTests
{
[Fact]
public void DefaultConstructor_InitializesWithNullValues()
{
// Act
var options = new ChatClientAgentOptions();
// Assert
Assert.Null(options.Name);
Assert.Null(options.Description);
Assert.Null(options.ChatOptions);
Assert.Null(options.ChatMessageStoreFactory);
Assert.Null(options.AIContextProviderFactory);
}
[Fact]
public void Constructor_WithNullValues_SetsPropertiesCorrectly()
{
// Act
var options = new ChatClientAgentOptions() { Name = null, Description = null, ChatOptions = new() { Tools = null, Instructions = null } };
// Assert
Assert.Null(options.Name);
Assert.Null(options.Description);
Assert.Null(options.AIContextProviderFactory);
Assert.Null(options.ChatMessageStoreFactory);
Assert.NotNull(options.ChatOptions);
Assert.Null(options.ChatOptions.Instructions);
Assert.Null(options.ChatOptions.Tools);
}
[Fact]
public void Constructor_WithToolsOnly_SetsChatOptionsWithTools()
{
// Arrange
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
// Act
var options = new ChatClientAgentOptions()
{
Name = null,
Description = null,
ChatOptions = new() { Tools = tools }
};
// Assert
Assert.Null(options.Name);
Assert.Null(options.Description);
Assert.NotNull(options.ChatOptions);
AssertSameTools(tools, options.ChatOptions.Tools);
}
[Fact]
public void Constructor_WithAllParameters_SetsAllPropertiesCorrectly()
{
// Arrange
const string Instructions = "Test instructions";
const string Name = "Test name";
const string Description = "Test description";
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
// Act
var options = new ChatClientAgentOptions()
{
Name = Name,
Description = Description,
ChatOptions = new() { Tools = tools, Instructions = Instructions }
};
// Assert
Assert.Equal(Name, options.Name);
Assert.Equal(Instructions, options.ChatOptions.Instructions);
Assert.Equal(Description, options.Description);
Assert.NotNull(options.ChatOptions);
AssertSameTools(tools, options.ChatOptions.Tools);
}
[Fact]
public void Constructor_WithNameAndDescriptionOnly_DoesNotCreateChatOptions()
{
// Arrange
const string Name = "Test name";
const string Description = "Test description";
// Act
var options = new ChatClientAgentOptions()
{
Name = Name,
Description = Description,
};
// Assert
Assert.Equal(Name, options.Name);
Assert.Equal(Description, options.Description);
Assert.Null(options.ChatOptions);
}
[Fact]
public void Clone_CreatesDeepCopyWithSameValues()
{
// Arrange
const string Name = "Test name";
const string Description = "Test description";
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
static ValueTask<ChatMessageStore> ChatMessageStoreFactoryAsync(
ChatClientAgentOptions.ChatMessageStoreFactoryContext ctx, CancellationToken ct) => new(new Mock<ChatMessageStore>().Object);
static ValueTask<AIContextProvider> AIContextProviderFactoryAsync(
ChatClientAgentOptions.AIContextProviderFactoryContext ctx, CancellationToken ct) => new(new Mock<AIContextProvider>().Object);
var original = new ChatClientAgentOptions()
{
Name = Name,
Description = Description,
ChatOptions = new() { Tools = tools },
Id = "test-id",
ChatMessageStoreFactory = ChatMessageStoreFactoryAsync,
AIContextProviderFactory = AIContextProviderFactoryAsync
};
// Act
var clone = original.Clone();
// Assert
Assert.NotSame(original, clone);
Assert.Equal(original.Id, clone.Id);
Assert.Equal(original.Name, clone.Name);
Assert.Equal(original.Description, clone.Description);
Assert.Same(original.ChatMessageStoreFactory, clone.ChatMessageStoreFactory);
Assert.Same(original.AIContextProviderFactory, clone.AIContextProviderFactory);
// ChatOptions should be cloned, not the same reference
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
Assert.Equal(original.ChatOptions?.Instructions, clone.ChatOptions?.Instructions);
Assert.Equal(original.ChatOptions?.Tools, clone.ChatOptions?.Tools);
}
[Fact]
public void Clone_WithoutProvidingChatOptions_ClonesCorrectly()
{
// Arrange
var original = new ChatClientAgentOptions
{
Id = "test-id",
Name = "Test name",
Description = "Test description"
};
// Act
var clone = original.Clone();
// Assert
Assert.NotSame(original, clone);
Assert.Equal(original.Id, clone.Id);
Assert.Equal(original.Name, clone.Name);
Assert.Equal(original.Description, clone.Description);
Assert.Null(original.ChatOptions);
Assert.Null(clone.ChatMessageStoreFactory);
Assert.Null(clone.AIContextProviderFactory);
}
private static void AssertSameTools(IList<AITool>? expected, IList<AITool>? actual)
{
var index = 0;
foreach (var tool in expected ?? [])
{
Assert.Same(tool, actual?[index]);
index++;
}
}
}

View File

@@ -0,0 +1,335 @@
// 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 Moq;
namespace Microsoft.Agents.AI.UnitTests;
public class ChatClientAgentRunOptionsTests
{
/// <summary>
/// Verify that ChatClientAgentRunOptions constructor works with null chatOptions.
/// </summary>
[Fact]
public void ConstructorWorksWithNullChatOptions()
{
// Act
var runOptions = new ChatClientAgentRunOptions();
// Assert
Assert.Null(runOptions.ChatOptions);
}
/// <summary>
/// Verify that ChatClientAgentRunOptions ChatOptions property is set and mutable.
/// </summary>
[Fact]
public void ChatOptionsPropertyIsReadOnly()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
var runOptions = new ChatClientAgentRunOptions(chatOptions);
chatOptions.MaxOutputTokens = 200; // Change the property to verify mutability
// Act & Assert
Assert.Same(chatOptions, runOptions.ChatOptions);
// Verify that the property doesn't have a setter by checking if it's the same instance
var retrievedOptions = runOptions.ChatOptions!;
Assert.Same(chatOptions, retrievedOptions);
Assert.Equal(200, retrievedOptions.MaxOutputTokens); // Ensure the change is reflected
}
#region ChatClientFactory Tests
/// <summary>
/// Tests that ChatClientFactory is called and transforms the client for RunAsync.
/// </summary>
[Fact]
public async Task RunAsync_WithChatClientFactory_UsesTransformedClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
var factoryCallCount = 0;
// Setup the original client to throw if called (should not be used)
originalClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Original client should not be called"));
// Setup the transformed client to return a response
transformedClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")]));
// Create the factory that transforms the client
IChatClient ClientFactory(IChatClient client)
{
factoryCallCount++;
Assert.Same(originalClient.Object, client); // Verify original client is passed
return transformedClient.Object;
}
var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act
var response = await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.NotNull(response);
Assert.Equal(1, factoryCallCount); // Factory should be called exactly once
transformedClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
originalClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>
/// Tests that ChatClientFactory is called and transforms the client for RunStreamingAsync.
/// </summary>
[Fact]
public async Task RunStreamingAsync_WithChatClientFactory_UsesTransformedClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
var factoryCallCount = 0;
// Setup the original client to throw if called (should not be used)
originalClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Original client should not be called"));
// Setup the transformed client to return streaming responses
var streamingResponses = new[]
{
new ChatResponseUpdate { Contents = [new TextContent("Streaming ")] },
new ChatResponseUpdate { Contents = [new TextContent("response")] }
};
transformedClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(streamingResponses.ToAsyncEnumerable());
// Create the factory that transforms the client
IChatClient ClientFactory(IChatClient client)
{
factoryCallCount++;
Assert.Same(originalClient.Object, client); // Verify original client is passed
return transformedClient.Object;
}
var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act
var responseUpdates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, null, options, CancellationToken.None))
{
responseUpdates.Add(update);
}
// Assert
Assert.NotEmpty(responseUpdates);
Assert.Equal(1, factoryCallCount); // Factory should be called exactly once
transformedClient.Verify(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
originalClient.Verify(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>
/// Tests that without ChatClientFactory, the original client is used for RunAsync.
/// </summary>
[Fact]
public async Task RunAsync_WithoutChatClientFactory_UsesOriginalClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
originalClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")]));
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act - No ChatClientFactory provided
var response = await agent.RunAsync(messages, null, null, CancellationToken.None);
// Assert
Assert.NotNull(response);
originalClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Tests that without ChatClientFactory, the original client is used for RunStreamingAsync.
/// </summary>
[Fact]
public async Task RunStreamingAsync_WithoutChatClientFactory_UsesOriginalClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var streamingResponses = new[]
{
new ChatResponseUpdate { Contents = [new TextContent("Original ")] },
new ChatResponseUpdate { Contents = [new TextContent("streaming")] }
};
originalClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(streamingResponses.ToAsyncEnumerable());
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act - No ChatClientFactory provided
var responseUpdates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, null, null, CancellationToken.None))
{
responseUpdates.Add(update);
}
// Assert
Assert.NotEmpty(responseUpdates);
originalClient.Verify(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Tests that ChatClientFactory is called for each separate RunAsync call.
/// </summary>
[Fact]
public async Task RunAsync_MultipleCalls_ChatClientFactoryCalledEachTimeAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
var factoryCallCount = 0;
transformedClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
IChatClient ClientFactory(IChatClient client)
{
factoryCallCount++;
return transformedClient.Object;
}
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act - Call RunAsync multiple times
await agent.RunAsync(messages, null, options, CancellationToken.None);
await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Equal(2, factoryCallCount); // Factory should be called for each run
transformedClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
/// <summary>
/// Tests that subsequent calls without ChatClientFactory use the original client.
/// </summary>
[Fact]
public async Task RunAsync_AfterFactoryCall_WithoutFactory_UsesOriginalClientAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
var transformedClient = new Mock<IChatClient>();
originalClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")]));
transformedClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")]));
IChatClient ClientFactory(IChatClient client) => transformedClient.Object;
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var optionsWithFactory = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act - First call with factory, second call without
await agent.RunAsync(messages, null, optionsWithFactory, CancellationToken.None);
await agent.RunAsync(messages, null, null, CancellationToken.None);
// Assert
transformedClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
originalClient.Verify(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Tests that ChatClientFactory returning null throws an exception.
/// </summary>
[Fact]
public async Task RunAsync_ChatClientFactoryReturnsNull_ThrowsExceptionAsync()
{
// Arrange
var originalClient = new Mock<IChatClient>();
static IChatClient ClientFactory(IChatClient client) => null!;
var agent = new ChatClientAgent(originalClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory };
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
await agent.RunAsync(messages, null, options, CancellationToken.None));
}
#endregion
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
#pragma warning disable CA1861 // Avoid constant arrays as arguments
namespace Microsoft.Agents.AI.UnitTests;
public class ChatClientAgentThreadTests
{
#region Constructor and Property Tests
[Fact]
public void ConstructorSetsDefaults()
{
// Arrange & Act
var thread = new ChatClientAgentThread();
// Assert
Assert.Null(thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public void SetConversationIdRoundtrips()
{
// Arrange
var thread = new ChatClientAgentThread();
const string ConversationId = "test-thread-id";
// Act
thread.ConversationId = ConversationId;
// Assert
Assert.Equal(ConversationId, thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public void SetChatMessageStoreRoundtrips()
{
// Arrange
var thread = new ChatClientAgentThread();
var messageStore = new InMemoryChatMessageStore();
// Act
thread.MessageStore = messageStore;
// Assert
Assert.Same(messageStore, thread.MessageStore);
Assert.Null(thread.ConversationId);
}
[Fact]
public void SetConversationIdThrowsWhenMessageStoreIsSet()
{
// Arrange
var thread = new ChatClientAgentThread
{
MessageStore = new InMemoryChatMessageStore()
};
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => thread.ConversationId = "new-thread-id");
Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message);
Assert.NotNull(thread.MessageStore);
}
[Fact]
public void SetChatMessageStoreThrowsWhenConversationIdIsSet()
{
// Arrange
var thread = new ChatClientAgentThread
{
ConversationId = "existing-thread-id"
};
var store = new InMemoryChatMessageStore();
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => thread.MessageStore = store);
Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message);
Assert.NotNull(thread.ConversationId);
}
#endregion Constructor and Property Tests
#region Deserialize Tests
[Fact]
public async Task VerifyDeserializeWithMessagesAsync()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"storeState": { "messages": [{"authorName": "testAuthor"}] }
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act.
var thread = await ChatClientAgentThread.DeserializeAsync(json);
// Assert
Assert.Null(thread.ConversationId);
var messageStore = thread.MessageStore as InMemoryChatMessageStore;
Assert.NotNull(messageStore);
Assert.Single(messageStore);
Assert.Equal("testAuthor", messageStore[0].AuthorName);
}
[Fact]
public async Task VerifyDeserializeWithIdAsync()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"conversationId": "TestConvId"
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act
var thread = await ChatClientAgentThread.DeserializeAsync(json);
// Assert
Assert.Equal("TestConvId", thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public async Task VerifyDeserializeWithAIContextProviderAsync()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"conversationId": "TestConvId",
"aiContextProviderState": ["CP1"]
}
""", TestJsonSerializerContext.Default.JsonElement);
Mock<AIContextProvider> mockProvider = new();
// Act
var thread = await ChatClientAgentThread.DeserializeAsync(json, aiContextProviderFactory: (_, _, _) => new(mockProvider.Object));
// Assert
Assert.Null(thread.MessageStore);
Assert.Same(thread.AIContextProvider, mockProvider.Object);
}
[Fact]
public async Task DeserializeWithInvalidJsonThrowsAsync()
{
// Arrange
var invalidJson = JsonSerializer.Deserialize("[42]", TestJsonSerializerContext.Default.JsonElement);
var thread = new ChatClientAgentThread();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => ChatClientAgentThread.DeserializeAsync(invalidJson));
}
#endregion Deserialize Tests
#region Serialize Tests
/// <summary>
/// Verify thread serialization to JSON when the thread has an id.
/// </summary>
[Fact]
public void VerifyThreadSerializationWithId()
{
// Arrange
var thread = new ChatClientAgentThread { ConversationId = "TestConvId" };
// Act
var json = thread.Serialize();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("conversationId", out var idProperty));
Assert.Equal("TestConvId", idProperty.GetString());
Assert.False(json.TryGetProperty("storeState", out _));
}
/// <summary>
/// Verify thread serialization to JSON when the thread has messages.
/// </summary>
[Fact]
public void VerifyThreadSerializationWithMessages()
{
// Arrange
InMemoryChatMessageStore store = [new(ChatRole.User, "TestContent") { AuthorName = "TestAuthor" }];
var thread = new ChatClientAgentThread { MessageStore = store };
// Act
var json = thread.Serialize();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("conversationId", out _));
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("messages", out var messagesProperty));
Assert.Equal(JsonValueKind.Array, messagesProperty.ValueKind);
Assert.Single(messagesProperty.EnumerateArray());
var message = messagesProperty.EnumerateArray().First();
Assert.Equal("TestAuthor", message.GetProperty("authorName").GetString());
Assert.True(message.TryGetProperty("contents", out var contentsProperty));
Assert.Equal(JsonValueKind.Array, contentsProperty.ValueKind);
Assert.Single(contentsProperty.EnumerateArray());
var textContent = contentsProperty.EnumerateArray().First();
Assert.Equal("TestContent", textContent.GetProperty("text").GetString());
}
[Fact]
public void VerifyThreadSerializationWithWithAIContextProvider()
{
// Arrange
Mock<AIContextProvider> mockProvider = new();
mockProvider
.Setup(m => m.Serialize(It.IsAny<JsonSerializerOptions?>()))
.Returns(JsonSerializer.SerializeToElement(["CP1"], TestJsonSerializerContext.Default.StringArray));
var thread = new ChatClientAgentThread
{
AIContextProvider = mockProvider.Object
};
// Act
var json = thread.Serialize();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("aiContextProviderState", out var providerStateProperty));
Assert.Equal(JsonValueKind.Array, providerStateProperty.ValueKind);
Assert.Single(providerStateProperty.EnumerateArray());
Assert.Equal("CP1", providerStateProperty.EnumerateArray().First().GetString());
mockProvider.Verify(m => m.Serialize(It.IsAny<JsonSerializerOptions?>()), Times.Once);
}
/// <summary>
/// Verify thread serialization to JSON with custom options.
/// </summary>
[Fact]
public void VerifyThreadSerializationWithCustomOptions()
{
// Arrange
var thread = new ChatClientAgentThread();
JsonSerializerOptions options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
var storeStateElement = JsonSerializer.SerializeToElement(
new Dictionary<string, object> { ["Key"] = "TestValue" },
TestJsonSerializerContext.Default.DictionaryStringObject);
var messageStoreMock = new Mock<ChatMessageStore>();
messageStoreMock
.Setup(m => m.Serialize(options))
.Returns(storeStateElement);
thread.MessageStore = messageStoreMock.Object;
// Act
var json = thread.Serialize(options);
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("conversationId", out var idProperty));
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("Key", out var keyProperty));
Assert.Equal("TestValue", keyProperty.GetString());
messageStoreMock.Verify(m => m.Serialize(options), Times.Once);
}
#endregion Serialize Tests
#region GetService Tests
[Fact]
public void GetService_RequestingAIContextProvider_ReturnsAIContextProvider()
{
// Arrange
var thread = new ChatClientAgentThread();
var mockProvider = new Mock<AIContextProvider>();
mockProvider
.Setup(m => m.GetService(It.Is<Type>(x => x == typeof(AIContextProvider)), null))
.Returns(mockProvider.Object);
thread.AIContextProvider = mockProvider.Object;
// Act
var result = thread.GetService(typeof(AIContextProvider));
// Assert
Assert.NotNull(result);
Assert.Same(mockProvider.Object, result);
}
[Fact]
public void GetService_RequestingChatMessageStore_ReturnsChatMessageStore()
{
// Arrange
var thread = new ChatClientAgentThread();
var messageStore = new InMemoryChatMessageStore();
thread.MessageStore = messageStore;
// Act
var result = thread.GetService(typeof(ChatMessageStore));
// Assert
Assert.NotNull(result);
Assert.Same(messageStore, result);
}
#endregion
}

View File

@@ -0,0 +1,808 @@
// 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 Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for ChatClientAgent background responses functionality.
/// </summary>
public class ChatClientAgent_BackgroundResponsesTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RunAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
{
// Arrange
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null, ConversationId = "conversation-id" });
AgentRunOptions agentRunOptions;
if (providePropsViaChatOptions)
{
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
agentRunOptions = new ChatClientAgentRunOptions(chatOptions);
}
else
{
agentRunOptions = new AgentRunOptions()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
}
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new() { ConversationId = "conversation-id" };
// Act
await agent.RunAsync(thread, options: agentRunOptions);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.True(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
{
// Arrange
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ContinuationToken = null, ConversationId = "conversation-id" });
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken1
};
ChatClientAgentRunOptions agentRunOptions = new(chatOptions)
{
AllowBackgroundResponses = false,
ContinuationToken = continuationToken2
};
ChatClientAgentThread thread = new() { ConversationId = "conversation-id" };
ChatClientAgent agent = new(mockChatClient.Object);
// Act
await agent.RunAsync(thread, options: agentRunOptions);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.False(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RunStreamingAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" },
new ChatResponseUpdate(role: ChatRole.Assistant, content: "at?") { ConversationId = "conversation-id" },
];
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.Returns(ToAsyncEnumerableAsync(returnUpdates));
AgentRunOptions agentRunOptions;
if (providePropsViaChatOptions)
{
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
agentRunOptions = new ChatClientAgentRunOptions(chatOptions);
}
else
{
agentRunOptions = new AgentRunOptions()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken
};
}
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new() { ConversationId = "conversation-id" };
// Act
await foreach (var _ in agent.RunStreamingAsync(thread, options: agentRunOptions))
{
}
// Assert
Assert.NotNull(capturedChatOptions);
Assert.True(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunStreamingAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" },
];
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((m, co, ct) => capturedChatOptions = co)
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatOptions chatOptions = new()
{
AllowBackgroundResponses = true,
ContinuationToken = continuationToken1
};
ChatClientAgentRunOptions agentRunOptions = new(chatOptions)
{
AllowBackgroundResponses = false,
ContinuationToken = continuationToken2
};
ChatClientAgent agent = new(mockChatClient.Object);
var thread = new ChatClientAgentThread() { ConversationId = "conversation-id" };
// Act
await foreach (var _ in agent.RunStreamingAsync(thread, options: agentRunOptions))
{
}
// Assert
Assert.NotNull(capturedChatOptions);
Assert.False(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunAsync_WhenContinuationTokenReceivedFromChatResponse_WrapsContinuationTokenAsync()
{
// Arrange
var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "partial")]) { ContinuationToken = continuationToken });
ChatClientAgent agent = new(mockChatClient.Object);
var runOptions = new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true });
ChatClientAgentThread thread = new();
// Act
var response = await agent.RunAsync([new(ChatRole.User, "hi")], thread, options: runOptions);
// Assert
Assert.Same(continuationToken, (response.ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
}
[Fact]
public async Task RunStreamingAsync_WhenContinuationTokenReceived_WrapsContinuationTokenAsync()
{
// Arrange
var token1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
ChatResponseUpdate[] expectedUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "pa") { ContinuationToken = token1 },
new ChatResponseUpdate(ChatRole.Assistant, "rt") { ContinuationToken = null } // terminal
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(expectedUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new();
// Act
var actualUpdates = new List<AgentResponseUpdate>();
await foreach (var u in agent.RunStreamingAsync([new(ChatRole.User, "hi")], thread, options: new ChatClientAgentRunOptions(new ChatOptions { AllowBackgroundResponses = true })))
{
actualUpdates.Add(u);
}
// Assert
Assert.Equal(2, actualUpdates.Count);
Assert.Same(token1, (actualUpdates[0].ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
Assert.Null(actualUpdates[1].ContinuationToken); // last update has null token
}
[Fact]
public async Task RunAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(inputMessages, options: runOptions));
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions))
{
// Should not reach here
}
});
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunAsync_WhenContinuationTokenProvided_SkipsThreadMessagePopulationAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
// Create a mock message store that would normally provide messages
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.InvokingAsync(It.IsAny<ChatMessageStore.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new(ChatRole.User, "Message from message store")]);
// Create a mock AI context provider that would normally provide context
var mockContextProvider = new Mock<AIContextProvider>();
mockContextProvider
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AIContext
{
Messages = [new(ChatRole.System, "Message from AI context")],
Instructions = "context instructions"
});
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedMessages.AddRange(msgs))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "continued response")]));
ChatClientAgent agent = new(mockChatClient.Object);
// Create a thread with both message store and AI context provider
ChatClientAgentThread thread = new()
{
MessageStore = mockMessageStore.Object,
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
};
// Act
await agent.RunAsync([], thread, options: runOptions);
// Assert
// With continuation token, thread message population should be skipped
Assert.Empty(capturedMessages);
// Verify that message store was never called due to continuation token
mockMessageStore.Verify(
ms => ms.InvokingAsync(It.IsAny<ChatMessageStore.InvokingContext>(), It.IsAny<CancellationToken>()),
Times.Never);
// Verify that AI context provider was never called due to continuation token
mockContextProvider.Verify(
p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsync_WhenContinuationTokenProvided_SkipsThreadMessagePopulationAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
// Create a mock message store that would normally provide messages
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.InvokingAsync(It.IsAny<ChatMessageStore.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new(ChatRole.User, "Message from message store")]);
// Create a mock AI context provider that would normally provide context
var mockContextProvider = new Mock<AIContextProvider>();
mockContextProvider
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AIContext
{
Messages = [new(ChatRole.System, "Message from AI context")],
Instructions = "context instructions"
});
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedMessages.AddRange(msgs))
.Returns(ToAsyncEnumerableAsync([new ChatResponseUpdate(role: ChatRole.Assistant, content: "continued response")]));
ChatClientAgent agent = new(mockChatClient.Object);
// Create a thread with both message store and AI context provider
ChatClientAgentThread thread = new()
{
MessageStore = mockMessageStore.Object,
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] }
};
// Act
await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync();
// Assert
// With continuation token, thread message population should be skipped
Assert.Empty(capturedMessages);
// Verify that message store was never called due to continuation token
mockMessageStore.Verify(
ms => ms.InvokingAsync(It.IsAny<ChatMessageStore.InvokingContext>(), It.IsAny<CancellationToken>()),
Times.Never);
// Verify that AI context provider was never called due to continuation token
mockContextProvider.Verify(
p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunAsync_WhenNoThreadProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { AllowBackgroundResponses = true };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(inputMessages, options: runOptions));
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsync_WhenNoThreadProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { AllowBackgroundResponses = true };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(inputMessages, options: runOptions))
{
// Should not reach here
}
});
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RunStreamingAsync_WhenInputMessagesPresentInContinuationToken_ResumesStreamingAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new() { ConversationId = "conversation-id" };
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
InputMessages = [new ChatMessage(ChatRole.User, "previous message")]
}
};
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(thread, options: runOptions))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
// Verify that the IChatClient was called
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WhenResponseUpdatesPresentInContinuationToken_ResumesStreamingAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new() { ConversationId = "conversation-id" };
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "previous update")]
}
};
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(thread, options: runOptions))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
// Verify that the IChatClient was called
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WhenResumingStreaming_UsesUpdatesFromInitialRunForContextProviderAndMessageStoreAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "upon"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"),
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
List<ChatMessage> capturedMessagesAddedToStore = [];
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()))
.Callback<ChatMessageStore.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToStore.AddRange(ctx.ResponseMessages ?? []))
.Returns(new ValueTask());
AIContextProvider.InvokedContext? capturedInvokedContext = null;
var mockContextProvider = new Mock<AIContextProvider>();
mockContextProvider
.Setup(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
.Returns(new ValueTask());
ChatClientAgentThread thread = new()
{
MessageStore = mockMessageStore.Object,
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "once ")]
}
};
// Act
await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync();
// Assert
mockMessageStore.Verify(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.Single(capturedMessagesAddedToStore);
Assert.Contains("once upon a time", capturedMessagesAddedToStore[0].Text);
mockContextProvider.Verify(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(capturedInvokedContext?.ResponseMessages);
Assert.Single(capturedInvokedContext.ResponseMessages);
Assert.Contains("once upon a time", capturedInvokedContext.ResponseMessages.ElementAt(0).Text);
}
[Fact]
public async Task RunStreamingAsync_WhenResumingStreaming_UsesInputMessagesFromInitialRunForContextProviderAndMessageStoreAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(Array.Empty<ChatResponseUpdate>()));
ChatClientAgent agent = new(mockChatClient.Object);
List<ChatMessage> capturedMessagesAddedToStore = [];
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()))
.Callback<ChatMessageStore.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToStore.AddRange(ctx.RequestMessages))
.Returns(new ValueTask());
AIContextProvider.InvokedContext? capturedInvokedContext = null;
var mockContextProvider = new Mock<AIContextProvider>();
mockContextProvider
.Setup(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
.Returns(new ValueTask());
ChatClientAgentThread thread = new()
{
MessageStore = mockMessageStore.Object,
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
InputMessages = [new ChatMessage(ChatRole.User, "Tell me a story")],
}
};
// Act
await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync();
// Assert
mockMessageStore.Verify(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.Single(capturedMessagesAddedToStore);
Assert.Contains("Tell me a story", capturedMessagesAddedToStore[0].Text);
mockContextProvider.Verify(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(capturedInvokedContext?.RequestMessages);
Assert.Single(capturedInvokedContext.RequestMessages);
Assert.Contains("Tell me a story", capturedInvokedContext.RequestMessages.ElementAt(0).Text);
}
[Fact]
public async Task RunStreamingAsync_WhenResumingStreaming_SavesInputMessagesAndUpdatesInContinuationTokenAsync()
{
// Arrange
List<ChatResponseUpdate> returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Once") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
new ChatResponseUpdate(role: ChatRole.Assistant, content: " upon") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"){ ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new() { };
List<ChatClientAgentContinuationToken> capturedContinuationTokens = [];
ChatMessage userMessage = new(ChatRole.User, "Tell me a story");
// Act
// Do the initial run
await foreach (var update in agent.RunStreamingAsync(userMessage, thread))
{
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
break;
}
// Now resume the run using the captured continuation token
returnUpdates.RemoveAt(0); // remove the first mock update as it was already processed
var options = new AgentRunOptions { ContinuationToken = capturedContinuationTokens[0] };
await foreach (var update in agent.RunStreamingAsync(thread, options: options))
{
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
}
// Assert
Assert.Equal(4, capturedContinuationTokens.Count);
// Verify that the first continuation token has the initial input and first update
Assert.NotNull(capturedContinuationTokens[0].InputMessages);
Assert.Single(capturedContinuationTokens[0].InputMessages!);
Assert.Equal("Tell me a story", capturedContinuationTokens[0].InputMessages!.Last().Text);
Assert.NotNull(capturedContinuationTokens[0].ResponseUpdates);
Assert.Single(capturedContinuationTokens[0].ResponseUpdates!);
Assert.Equal("Once", capturedContinuationTokens[0].ResponseUpdates![0].Text);
// Verify the last continuation token has the input and all updates
var lastToken = capturedContinuationTokens[^1];
Assert.NotNull(lastToken.InputMessages);
Assert.Single(lastToken.InputMessages!);
Assert.Equal("Tell me a story", lastToken.InputMessages!.Last().Text);
Assert.NotNull(lastToken.ResponseUpdates);
Assert.Equal(4, lastToken.ResponseUpdates!.Count);
Assert.Equal("Once", lastToken.ResponseUpdates!.ElementAt(0).Text);
Assert.Equal(" upon", lastToken.ResponseUpdates!.ElementAt(1).Text);
Assert.Equal(" a", lastToken.ResponseUpdates!.ElementAt(2).Text);
Assert.Equal(" time", lastToken.ResponseUpdates!.ElementAt(3).Text);
}
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
{
await Task.Yield();
foreach (var update in values)
{
yield return update;
}
}
}

View File

@@ -0,0 +1,371 @@
// 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 Moq;
using Xunit.Sdk;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests that verify the chat history management functionality of the <see cref="ChatClientAgent"/> class,
/// e.g. that it correctly reads and updates chat history in any available <see cref="ChatMessageStore"/> or that
/// it uses conversation id correctly for service managed chat history.
/// </summary>
public class ChatClientAgent_ChatHistoryManagementTests
{
#region ConversationId Tests
/// <summary>
/// Verify that RunAsync does not throw when providing a ConversationId via both AgentThread and
/// via ChatOptions and the two are the same.
/// </summary>
[Fact]
public async Task RunAsync_DoesNotThrow_WhenSpecifyingTwoSameConversationIdsAsync()
{
// Arrange
var chatOptions = new ChatOptions { ConversationId = "ConvId" };
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.ConversationId == "ConvId"),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentThread thread = new() { ConversationId = "ConvId" };
// Act & Assert
var response = await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions));
Assert.NotNull(response);
}
/// <summary>
/// Verify that RunAsync throws when providing a ConversationId via both AgentThread and
/// via ChatOptions and the two are different.
/// </summary>
[Fact]
public async Task RunAsync_Throws_WhenSpecifyingTwoDifferentConversationIdsAsync()
{
// Arrange
var chatOptions = new ChatOptions { ConversationId = "ConvId" };
Mock<IChatClient> mockService = new();
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentThread thread = new() { ConversationId = "ThreadId" };
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions)));
}
/// <summary>
/// Verify that RunAsync clones the ChatOptions when providing a thread with a ConversationId and a ChatOptions.
/// </summary>
[Fact]
public async Task RunAsync_ClonesChatOptions_ToAddConversationIdAsync()
{
// Arrange
var chatOptions = new ChatOptions { MaxOutputTokens = 100 };
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.MaxOutputTokens == 100 && opts.ConversationId == "ConvId"),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentThread thread = new() { ConversationId = "ConvId" };
// Act
await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions));
// Assert
Assert.Null(chatOptions.ConversationId);
}
/// <summary>
/// Verify that RunAsync throws if a thread is provided that uses a conversation id already, but the service does not return one on invoke.
/// </summary>
[Fact]
public async Task RunAsync_Throws_ForMissingConversationIdWithConversationIdThreadAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentThread thread = new() { ConversationId = "ConvId" };
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], thread));
}
/// <summary>
/// Verify that RunAsync sets the ConversationId on the thread when the service returns one.
/// </summary>
[Fact]
public async Task RunAsync_SetsConversationIdOnThread_WhenReturnedByChatClientAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
ChatClientAgentThread thread = new();
// Act
await agent.RunAsync([new(ChatRole.User, "test")], thread);
// Assert
Assert.Equal("ConvId", thread.ConversationId);
}
#endregion
#region ChatMessageStore Tests
/// <summary>
/// Verify that RunAsync uses the default InMemoryChatMessageStore when the chat client returns no conversation id.
/// </summary>
[Fact]
public async Task RunAsync_UsesDefaultInMemoryChatMessageStore_WhenNoConversationIdReturnedByChatClientAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
});
// Act
ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread;
await agent.RunAsync([new(ChatRole.User, "test")], thread);
// Assert
var messageStore = Assert.IsType<InMemoryChatMessageStore>(thread!.MessageStore);
Assert.Equal(2, messageStore.Count);
Assert.Equal("test", messageStore[0].Text);
Assert.Equal("response", messageStore[1].Text);
}
/// <summary>
/// Verify that RunAsync uses the ChatMessageStore factory when the chat client returns no conversation id.
/// </summary>
[Fact]
public async Task RunAsync_UsesChatMessageStoreFactory_WhenProvidedAndNoConversationIdReturnedByChatClientAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatMessageStore> mockChatMessageStore = new();
mockChatMessageStore.Setup(s => s.InvokingAsync(
It.IsAny<ChatMessageStore.InvokingContext>(),
It.IsAny<CancellationToken>())).ReturnsAsync([new ChatMessage(ChatRole.User, "Existing Chat History")]);
mockChatMessageStore.Setup(s => s.InvokedAsync(
It.IsAny<ChatMessageStore.InvokedContext>(),
It.IsAny<CancellationToken>())).Returns(new ValueTask());
Mock<Func<ChatClientAgentOptions.ChatMessageStoreFactoryContext, CancellationToken, ValueTask<ChatMessageStore>>> mockFactory = new();
mockFactory.Setup(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>(), It.IsAny<CancellationToken>())).ReturnsAsync(mockChatMessageStore.Object);
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatMessageStoreFactory = mockFactory.Object
});
// Act
ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread;
await agent.RunAsync([new(ChatRole.User, "test")], thread);
// Assert
Assert.IsType<ChatMessageStore>(thread!.MessageStore, exactMatch: false);
mockService.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
mockChatMessageStore.Verify(s => s.InvokingAsync(
It.Is<ChatMessageStore.InvokingContext>(x => x.RequestMessages.Count() == 1),
It.IsAny<CancellationToken>()),
Times.Once);
mockChatMessageStore.Verify(s => s.InvokedAsync(
It.Is<ChatMessageStore.InvokedContext>(x => x.RequestMessages.Count() == 1 && x.ChatMessageStoreMessages != null && x.ChatMessageStoreMessages.Count() == 1 && x.ResponseMessages!.Count() == 1),
It.IsAny<CancellationToken>()),
Times.Once);
mockFactory.Verify(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>(), It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verify that RunAsync notifies the ChatMessageStore on failure.
/// </summary>
[Fact]
public async Task RunAsync_NotifiesChatMessageStore_OnFailureAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Throws(new InvalidOperationException("Test Error"));
Mock<ChatMessageStore> mockChatMessageStore = new();
Mock<Func<ChatClientAgentOptions.ChatMessageStoreFactoryContext, CancellationToken, ValueTask<ChatMessageStore>>> mockFactory = new();
mockFactory.Setup(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>(), It.IsAny<CancellationToken>())).ReturnsAsync(mockChatMessageStore.Object);
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatMessageStoreFactory = mockFactory.Object
});
// Act
ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread;
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], thread));
// Assert
Assert.IsType<ChatMessageStore>(thread!.MessageStore, exactMatch: false);
mockChatMessageStore.Verify(s => s.InvokedAsync(
It.Is<ChatMessageStore.InvokedContext>(x => x.RequestMessages.Count() == 1 && x.ResponseMessages == null && x.InvokeException!.Message == "Test Error"),
It.IsAny<CancellationToken>()),
Times.Once);
mockFactory.Verify(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>(), It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verify that RunAsync throws when a ChatMessageStore Factory is provided and the chat client returns a conversation id.
/// </summary>
[Fact]
public async Task RunAsync_Throws_WhenChatMessageStoreFactoryProvidedAndConversationIdReturnedByChatClientAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
Mock<Func<ChatClientAgentOptions.ChatMessageStoreFactoryContext, CancellationToken, ValueTask<ChatMessageStore>>> mockFactory = new();
mockFactory.Setup(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>(), It.IsAny<CancellationToken>())).ReturnsAsync(new InMemoryChatMessageStore());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatMessageStoreFactory = mockFactory.Object
});
// Act & Assert
ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread;
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], thread));
Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message);
}
#endregion
#region ChatMessageStore Override Tests
/// <summary>
/// Tests that RunAsync uses an override ChatMessageStore provided via AdditionalProperties instead of the store from a factory
/// if one is supplied.
/// </summary>
[Fact]
public async Task RunAsync_UsesOverrideChatMessageStore_WhenProvidedViaAdditionalPropertiesAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
// Arrange a chat message store to override the factory provided one.
Mock<ChatMessageStore> mockOverrideChatMessageStore = new();
mockOverrideChatMessageStore.Setup(s => s.InvokingAsync(
It.IsAny<ChatMessageStore.InvokingContext>(),
It.IsAny<CancellationToken>())).ReturnsAsync([new ChatMessage(ChatRole.User, "Existing Chat History")]);
mockOverrideChatMessageStore.Setup(s => s.InvokedAsync(
It.IsAny<ChatMessageStore.InvokedContext>(),
It.IsAny<CancellationToken>())).Returns(new ValueTask());
// Arrange a chat message store to provide to the agent via a factory at construction time.
// This one shouldn't be used since it is being overridden.
Mock<ChatMessageStore> mockFactoryChatMessageStore = new();
mockFactoryChatMessageStore.Setup(s => s.InvokingAsync(
It.IsAny<ChatMessageStore.InvokingContext>(),
It.IsAny<CancellationToken>())).ThrowsAsync(FailException.ForFailure("Base ChatMessageStore shouldn't be used."));
mockFactoryChatMessageStore.Setup(s => s.InvokedAsync(
It.IsAny<ChatMessageStore.InvokedContext>(),
It.IsAny<CancellationToken>())).Throws(FailException.ForFailure("Base ChatMessageStore shouldn't be used."));
Mock<Func<ChatClientAgentOptions.ChatMessageStoreFactoryContext, CancellationToken, ValueTask<ChatMessageStore>>> mockFactory = new();
mockFactory.Setup(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>(), It.IsAny<CancellationToken>())).ReturnsAsync(mockFactoryChatMessageStore.Object);
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test instructions" },
ChatMessageStoreFactory = mockFactory.Object
});
// Act
ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread;
var additionalProperties = new AdditionalPropertiesDictionary();
additionalProperties.Add(mockOverrideChatMessageStore.Object);
await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new AgentRunOptions { AdditionalProperties = additionalProperties });
// Assert
Assert.Same(mockFactoryChatMessageStore.Object, thread!.MessageStore);
mockService.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
mockOverrideChatMessageStore.Verify(s => s.InvokingAsync(
It.Is<ChatMessageStore.InvokingContext>(x => x.RequestMessages.Count() == 1),
It.IsAny<CancellationToken>()),
Times.Once);
mockOverrideChatMessageStore.Verify(s => s.InvokedAsync(
It.Is<ChatMessageStore.InvokedContext>(x => x.RequestMessages.Count() == 1 && x.ChatMessageStoreMessages != null && x.ChatMessageStoreMessages.Count() == 1 && x.ResponseMessages!.Count() == 1),
It.IsAny<CancellationToken>()),
Times.Once);
mockFactoryChatMessageStore.Verify(s => s.InvokingAsync(
It.IsAny<ChatMessageStore.InvokingContext>(),
It.IsAny<CancellationToken>()),
Times.Never);
mockFactoryChatMessageStore.Verify(s => s.InvokedAsync(
It.IsAny<ChatMessageStore.InvokedContext>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
#endregion
}

View File

@@ -0,0 +1,442 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains tests for <see cref="ChatOptions"/> merging in <see cref="ChatClientAgent"/>.
/// </summary>
public class ChatClientAgent_ChatOptionsMergingTests
{
/// <summary>
/// Verify that ChatOptions merging works when agent has ChatOptions but request doesn't.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync()
{
// Arrange
var agentChatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f, Instructions = "test instructions" };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equal(100, capturedChatOptions.MaxOutputTokens);
Assert.Equal(0.7f, capturedChatOptions.Temperature);
Assert.Equal("test instructions", capturedChatOptions.Instructions);
}
[Fact]
public async Task ChatOptionsMergingUsesAgentOptionsConstructorWhenRequestHasNoneAsync()
{
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } });
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages);
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equal("test instructions", capturedChatOptions.Instructions);
}
/// <summary>
/// Verify that ChatOptions merging works when request has ChatOptions but agent doesn't.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesRequestOptionsWhenAgentHasNoneAsync()
{
// Arrange
var requestChatOptions = new ChatOptions { MaxOutputTokens = 200, Temperature = 0.3f, Instructions = "test instructions" };
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equivalent(requestChatOptions, capturedChatOptions); // Should be the same instance since no merging needed
Assert.Equal(200, capturedChatOptions.MaxOutputTokens);
Assert.Equal(0.3f, capturedChatOptions.Temperature);
Assert.Equal("test instructions", capturedChatOptions.Instructions);
}
/// <summary>
/// Verify that <see cref="ChatOptions"/> merging prioritizes <see cref="AgentRunOptions"/> over request <see cref="ChatOptions"/> and that in turn over agent level <see cref="ChatOptions"/>.
/// </summary>
[Fact]
public async Task ChatOptionsMergingPrioritizesRequestOptionsOverAgentOptionsAsync()
{
// Arrange
var agentChatOptions = new ChatOptions
{
Instructions = "test instructions",
MaxOutputTokens = 100,
Temperature = 0.7f,
TopP = 0.9f,
ModelId = "agent-model",
AdditionalProperties = new AdditionalPropertiesDictionary { ["key1"] = "agent-value", ["key2"] = "agent-value", ["key3"] = "agent-value" }
};
var requestChatOptions = new ChatOptions
{
// TopP and ModelId not set, should use agent values
MaxOutputTokens = 200,
Temperature = 0.3f,
AdditionalProperties = new AdditionalPropertiesDictionary { ["key2"] = "request-value", ["key3"] = "request-value" },
Instructions = "request instructions"
};
var agentRunOptionsAdditionalProperties = new AdditionalPropertiesDictionary { ["key3"] = "runoptions-value" };
var expectedChatOptionsMerge = new ChatOptions
{
MaxOutputTokens = 200, // Request value takes priority
Temperature = 0.3f, // Request value takes priority
// Check that each level of precedence is respected in AdditionalProperties
AdditionalProperties = new AdditionalPropertiesDictionary { ["key1"] = "agent-value", ["key2"] = "request-value", ["key3"] = "runoptions-value" },
TopP = 0.9f, // Agent value used when request doesn't specify
ModelId = "agent-model", // Agent value used when request doesn't specify
Instructions = "test instructions\nrequest instructions" // Request is in addition to agent instructions
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions) { AdditionalProperties = agentRunOptionsAdditionalProperties });
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equivalent(expectedChatOptionsMerge, capturedChatOptions); // Should be the same instance (modified in place)
Assert.Equal(200, capturedChatOptions.MaxOutputTokens); // Request value takes priority
Assert.Equal(0.3f, capturedChatOptions.Temperature); // Request value takes priority
Assert.NotNull(capturedChatOptions.AdditionalProperties);
Assert.Equal("agent-value", capturedChatOptions.AdditionalProperties["key1"]); // Agent value used when request doesn't specify
Assert.Equal("request-value", capturedChatOptions.AdditionalProperties["key2"]); // Request ChatOptions value takes priority over agent ChatOptions value
Assert.Equal("runoptions-value", capturedChatOptions.AdditionalProperties["key3"]); // Run options value takes priority over request and agent ChatOptions values
Assert.Equal(0.9f, capturedChatOptions.TopP); // Agent value used when request doesn't specify
Assert.Equal("agent-model", capturedChatOptions.ModelId); // Agent value used when request doesn't specify
}
/// <summary>
/// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions.
/// </summary>
[Fact]
public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages);
// Assert
Assert.Null(capturedChatOptions);
}
/// <summary>
/// Verify that ChatOptions merging concatenates Tools from agent and request.
/// </summary>
[Fact]
public async Task ChatOptionsMergingConcatenatesToolsFromAgentAndRequestAsync()
{
// Arrange
var agentTool = AIFunctionFactory.Create(() => "agent tool");
var requestTool = AIFunctionFactory.Create(() => "request tool");
var agentChatOptions = new ChatOptions
{
Instructions = "test instructions",
Tools = [agentTool]
};
var requestChatOptions = new ChatOptions
{
Tools = [requestTool]
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Tools);
Assert.Equal(2, capturedChatOptions.Tools.Count);
// Request tools should come first, then agent tools
Assert.Contains(requestTool, capturedChatOptions.Tools);
Assert.Contains(agentTool, capturedChatOptions.Tools);
}
/// <summary>
/// Verify that ChatOptions merging uses agent Tools when request has no Tools.
/// </summary>
[Fact]
public async Task ChatOptionsMergingUsesAgentToolsWhenRequestHasNoToolsAsync()
{
// Arrange
var agentTool = AIFunctionFactory.Create(() => "agent tool");
var agentChatOptions = new ChatOptions
{
Instructions = "test instructions",
Tools = [agentTool]
};
var requestChatOptions = new ChatOptions
{
// No Tools specified
MaxOutputTokens = 100
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.Tools);
Assert.Single(capturedChatOptions.Tools);
Assert.Contains(agentTool, capturedChatOptions.Tools); // Should contain the agent's tool
}
/// <summary>
/// Verify that ChatOptions merging uses RawRepresentationFactory from request first, with fallback to agent.
/// </summary>
[Theory]
[InlineData("MockAgentSetting", "MockRequestSetting", "MockRequestSetting")]
[InlineData("MockAgentSetting", null, "MockAgentSetting")]
[InlineData(null, "MockRequestSetting", "MockRequestSetting")]
public async Task ChatOptionsMergingUsesRawRepresentationFactoryWithFallbackAsync(string? agentSetting, string? requestSetting, string expectedSetting)
{
// Arrange
var agentChatOptions = new ChatOptions
{
Instructions = "test instructions",
RawRepresentationFactory = _ => agentSetting
};
var requestChatOptions = new ChatOptions
{
RawRepresentationFactory = _ => requestSetting
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.NotNull(capturedChatOptions.RawRepresentationFactory);
Assert.Equal(expectedSetting, capturedChatOptions.RawRepresentationFactory(null!));
}
/// <summary>
/// Verify that ChatOptions merging handles all scalar properties correctly.
/// </summary>
[Fact]
public async Task ChatOptionsMergingHandlesAllScalarPropertiesCorrectlyAsync()
{
// Arrange
var agentChatOptions = new ChatOptions
{
MaxOutputTokens = 100,
Temperature = 0.7f,
TopP = 0.9f,
TopK = 50,
PresencePenalty = 0.1f,
FrequencyPenalty = 0.2f,
Instructions = "agent instructions",
ModelId = "agent-model",
Seed = 12345,
ConversationId = "agent-conversation",
AllowMultipleToolCalls = true,
StopSequences = ["agent-stop"]
};
var requestChatOptions = new ChatOptions
{
MaxOutputTokens = 200,
Temperature = 0.3f,
Instructions = "request instructions",
// Other properties not set, should use agent values
StopSequences = ["request-stop"]
};
var expectedChatOptionsMerge = new ChatOptions
{
MaxOutputTokens = 200,
Temperature = 0.3f,
// Agent value used when request doesn't specify
TopP = 0.9f,
TopK = 50,
PresencePenalty = 0.1f,
FrequencyPenalty = 0.2f,
Instructions = "agent instructions\nrequest instructions",
ModelId = "agent-model",
Seed = 12345,
ConversationId = "agent-conversation",
AllowMultipleToolCalls = true,
// Merged StopSequences
StopSequences = ["request-stop", "agent-stop"]
};
Mock<IChatClient> mockService = new();
ChatOptions? capturedChatOptions = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
capturedChatOptions = opts)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = agentChatOptions
});
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
await agent.RunAsync(messages, options: new ChatClientAgentRunOptions(requestChatOptions));
// Assert
Assert.NotNull(capturedChatOptions);
Assert.Equivalent(expectedChatOptionsMerge, capturedChatOptions); // Should be the equivalent instance (modified in place)
// Request values should take priority
Assert.Equal(200, capturedChatOptions.MaxOutputTokens);
Assert.Equal(0.3f, capturedChatOptions.Temperature);
// Merge StopSequences
Assert.Equal(["request-stop", "agent-stop"], capturedChatOptions.StopSequences);
// Agent values should be used when request doesn't specify
Assert.Equal(0.9f, capturedChatOptions.TopP);
Assert.Equal(50, capturedChatOptions.TopK);
Assert.Equal(0.1f, capturedChatOptions.PresencePenalty);
Assert.Equal(0.2f, capturedChatOptions.FrequencyPenalty);
Assert.Equal("agent-model", capturedChatOptions.ModelId);
Assert.Equal(12345, capturedChatOptions.Seed);
Assert.Equal("agent-conversation", capturedChatOptions.ConversationId);
Assert.Equal(true, capturedChatOptions.AllowMultipleToolCalls);
}
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the ChatClientAgent.DeserializeThread methods.
/// </summary>
public class ChatClientAgent_DeserializeThreadTests
{
[Fact]
public async Task DeserializeThread_UsesAIContextProviderFactory_IfProvidedAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var mockContextProvider = new Mock<AIContextProvider>();
var factoryCalled = false;
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "Test instructions" },
AIContextProviderFactory = (_, _) =>
{
factoryCalled = true;
return new ValueTask<AIContextProvider>(mockContextProvider.Object);
}
});
var json = JsonSerializer.Deserialize("""
{
"aiContextProviderState": ["CP1"]
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act
var thread = await agent.DeserializeThreadAsync(json);
// Assert
Assert.True(factoryCalled, "AIContextProviderFactory was not called.");
Assert.IsType<ChatClientAgentThread>(thread);
var typedThread = (ChatClientAgentThread)thread;
Assert.Same(mockContextProvider.Object, typedThread.AIContextProvider);
}
[Fact]
public async Task DeserializeThread_UsesChatMessageStoreFactory_IfProvidedAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var mockMessageStore = new Mock<ChatMessageStore>();
var factoryCalled = false;
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "Test instructions" },
ChatMessageStoreFactory = (_, _) =>
{
factoryCalled = true;
return new ValueTask<ChatMessageStore>(mockMessageStore.Object);
}
});
var json = JsonSerializer.Deserialize("""
{
"storeState": { }
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act
var thread = await agent.DeserializeThreadAsync(json);
// Assert
Assert.True(factoryCalled, "ChatMessageStoreFactory was not called.");
Assert.IsType<ChatClientAgentThread>(thread);
var typedThread = (ChatClientAgentThread)thread;
Assert.Same(mockMessageStore.Object, typedThread.MessageStore);
}
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the ChatClientAgent.GetNewThreadAsync methods.
/// </summary>
public class ChatClientAgent_GetNewThreadTests
{
[Fact]
public async Task GetNewThread_UsesAIContextProviderFactory_IfProvidedAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var mockContextProvider = new Mock<AIContextProvider>();
var factoryCalled = false;
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "Test instructions" },
AIContextProviderFactory = (_, _) =>
{
factoryCalled = true;
return new ValueTask<AIContextProvider>(mockContextProvider.Object);
}
});
// Act
var thread = await agent.GetNewThreadAsync();
// Assert
Assert.True(factoryCalled, "AIContextProviderFactory was not called.");
Assert.IsType<ChatClientAgentThread>(thread);
var typedThread = (ChatClientAgentThread)thread;
Assert.Same(mockContextProvider.Object, typedThread.AIContextProvider);
}
[Fact]
public async Task GetNewThread_UsesChatMessageStoreFactory_IfProvidedAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var mockMessageStore = new Mock<ChatMessageStore>();
var factoryCalled = false;
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "Test instructions" },
ChatMessageStoreFactory = (_, _) =>
{
factoryCalled = true;
return new ValueTask<ChatMessageStore>(mockMessageStore.Object);
}
});
// Act
var thread = await agent.GetNewThreadAsync();
// Assert
Assert.True(factoryCalled, "ChatMessageStoreFactory was not called.");
Assert.IsType<ChatClientAgentThread>(thread);
var typedThread = (ChatClientAgentThread)thread;
Assert.Same(mockMessageStore.Object, typedThread.MessageStore);
}
[Fact]
public async Task GetNewThread_UsesChatMessageStore_FromTypedOverloadAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var mockMessageStore = new Mock<ChatMessageStore>();
var agent = new ChatClientAgent(mockChatClient.Object);
// Act
var thread = await agent.GetNewThreadAsync(mockMessageStore.Object);
// Assert
Assert.IsType<ChatClientAgentThread>(thread);
var typedThread = (ChatClientAgentThread)thread;
Assert.Same(mockMessageStore.Object, typedThread.MessageStore);
}
[Fact]
public async Task GetNewThread_UsesConversationId_FromTypedOverloadAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
const string TestConversationId = "test_conversation_id";
var agent = new ChatClientAgent(mockChatClient.Object);
// Act
var thread = await agent.GetNewThreadAsync(TestConversationId);
// Assert
Assert.IsType<ChatClientAgentThread>(thread);
var typedThread = (ChatClientAgentThread)thread;
Assert.Equal(TestConversationId, typedThread.ConversationId);
}
}

View File

@@ -0,0 +1,456 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Tests for <see cref="ChatClientAgent"/> run methods with <see cref="ChatClientAgentRunOptions"/>.
/// </summary>
public sealed partial class ChatClientAgent_RunWithCustomOptionsTests
{
#region RunAsync Tests
[Fact]
public async Task RunAsync_WithThreadAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
ChatClientAgentRunOptions options = new();
// Act
AgentResponse result = await agent.RunAsync(thread, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
ChatClientAgentRunOptions options = new();
// Act
AgentResponse result = await agent.RunAsync("Test message", thread, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
ChatMessage message = new(ChatRole.User, "Test message");
ChatClientAgentRunOptions options = new();
// Act
AgentResponse result = await agent.RunAsync(message, thread, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
ChatClientAgentRunOptions options = new();
// Act
AgentResponse result = await agent.RunAsync(messages, thread, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithChatOptionsInRunOptions_UsesChatOptionsAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentRunOptions options = new(new ChatOptions { Temperature = 0.5f });
// Act
AgentResponse result = await agent.RunAsync("Test", null, options);
// Assert
Assert.NotNull(result);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.Temperature == 0.5f),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region RunStreamingAsync Tests
[Fact]
public async Task RunStreamingAsync_WithThreadAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(thread, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync("Test message", thread, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
ChatMessage message = new(ChatRole.User, "Test message");
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(message, thread, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Message 1"), new ChatMessage(ChatRole.User, "Message 2")];
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, thread, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Helper Methods
private static async IAsyncEnumerable<ChatResponseUpdate> GetAsyncUpdatesAsync()
{
yield return new ChatResponseUpdate { Contents = new[] { new TextContent("Hello") } };
yield return new ChatResponseUpdate { Contents = new[] { new TextContent(" World") } };
await Task.CompletedTask;
}
#endregion
#region RunAsync{T} Tests
[Fact]
public async Task RunAsyncOfT_WithThreadAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
ChatClientAgentRunOptions options = new();
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(thread, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentResponse);
Assert.Single(agentResponse.Messages);
Assert.Equal("Tigger", agentResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsyncOfT_WithStringMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
ChatClientAgentRunOptions options = new();
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>("Test message", thread, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentResponse);
Assert.Single(agentResponse.Messages);
Assert.Equal("Tigger", agentResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsyncOfT_WithChatMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
ChatMessage message = new(ChatRole.User, "Test message");
ChatClientAgentRunOptions options = new();
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(message, thread, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentResponse);
Assert.Single(agentResponse.Messages);
Assert.Equal("Tigger", agentResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsyncOfT_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = await agent.GetNewThreadAsync();
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
ChatClientAgentRunOptions options = new();
// Act
AgentResponse<Animal> agentResponse = await agent.RunAsync<Animal>(messages, thread, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentResponse);
Assert.Single(agentResponse.Messages);
Assert.Equal("Tigger", agentResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
private sealed class Animal
{
public int Id { get; set; }
public string? FullName { get; set; }
public Species Species { get; set; }
}
private enum Species
{
Bear,
Tiger,
Walrus,
}
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Animal))]
private sealed partial class JsonContext_WithCustomRunOptions : JsonSerializerContext;
}

View File

@@ -0,0 +1,215 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the <see cref="ChatClientBuilderExtensions"/> class.
/// </summary>
public sealed class ChatClientBuilderExtensionsTests
{
[Fact]
public void BuildAIAgent_WithBasicParameters_CreatesAgent()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
// Act
var agent = builder.BuildAIAgent(
instructions: "Test instructions",
name: "TestAgent",
description: "Test description"
);
// Assert
Assert.NotNull(agent);
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("Test description", agent.Description);
Assert.Equal("Test instructions", agent.Instructions);
}
[Fact]
public void BuildAIAgent_WithTools_SetsToolsInOptions()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
var tools = new List<AITool> { new Mock<AITool>().Object };
// Act
var agent = builder.BuildAIAgent(tools: tools);
// Assert
Assert.NotNull(agent);
Assert.NotNull(agent.ChatOptions);
Assert.Equal(tools, agent.ChatOptions.Tools);
}
[Fact]
public void BuildAIAgent_WithAllParameters_CreatesAgentCorrectly()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
var tools = new List<AITool> { new Mock<AITool>().Object };
var loggerFactoryMock = new Mock<ILoggerFactory>();
var serviceProviderMock = new Mock<IServiceProvider>();
// Act
var agent = builder.BuildAIAgent(
instructions: "Complex instructions",
name: "ComplexAgent",
description: "Complex description",
tools: tools,
loggerFactory: loggerFactoryMock.Object,
services: serviceProviderMock.Object
);
// Assert
Assert.NotNull(agent);
Assert.Equal("ComplexAgent", agent.Name);
Assert.Equal("Complex description", agent.Description);
Assert.Equal("Complex instructions", agent.Instructions);
Assert.NotNull(agent.ChatOptions);
Assert.Equal(tools, agent.ChatOptions.Tools);
}
[Fact]
public void BuildAIAgent_WithOptions_CreatesAgentWithOptions()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
var options = new ChatClientAgentOptions
{
Name = "AgentWithOptions",
Description = "Desc",
ChatOptions = new() { Instructions = "Instr" },
UseProvidedChatClientAsIs = true
};
// Act
var agent = builder.BuildAIAgent(options);
// Assert
Assert.NotNull(agent);
Assert.Equal("AgentWithOptions", agent.Name);
Assert.Equal("Desc", agent.Description);
Assert.Equal("Instr", agent.Instructions);
}
[Fact]
public void BuildAIAgent_WithOptionsAndServices_CreatesAgentCorrectly()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
var loggerFactoryMock = new Mock<ILoggerFactory>();
var serviceProviderMock = new Mock<IServiceProvider>();
var options = new ChatClientAgentOptions
{
Name = "ServiceAgent",
ChatOptions = new() { Instructions = "Service instructions" }
};
// Act
var agent = builder.BuildAIAgent(
options: options,
loggerFactory: loggerFactoryMock.Object,
services: serviceProviderMock.Object
);
// Assert
Assert.NotNull(agent);
Assert.Equal("ServiceAgent", agent.Name);
Assert.Equal("Service instructions", agent.Instructions);
}
[Fact]
public void BuildAIAgent_WithNullBuilder_Throws()
{
// Arrange
ChatClientBuilder builder = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => builder.BuildAIAgent(instructions: "instructions"));
}
[Fact]
public void BuildAIAgent_WithNullBuilderAndOptions_Throws()
{
// Arrange
ChatClientBuilder builder = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => builder.BuildAIAgent(options: new() { ChatOptions = new() { Instructions = "instructions" } }));
}
[Fact]
public void BuildAIAgent_WithMiddleware_BuildsCorrectPipeline()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var middlewareChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
// Add middleware that returns our mock
builder.Use((client, services) => middlewareChatClientMock.Object);
// Act
var agent = builder.BuildAIAgent(
new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "Middleware test" },
UseProvidedChatClientAsIs = true
}
);
// Assert
Assert.NotNull(agent);
Assert.Equal("Middleware test", agent.Instructions);
// When UseProvidedChatClientAsIs is true, the agent should use the middleware chat client directly
Assert.Same(middlewareChatClientMock.Object, agent.ChatClient);
}
[Fact]
public void BuildAIAgent_WithNullOptions_CreatesAgentWithDefaults()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
// Act
var agent = builder.BuildAIAgent(options: null);
// Assert
Assert.NotNull(agent);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
Assert.Null(agent.Instructions);
}
[Fact]
public void BuildAIAgent_WithEmptyParameters_CreatesMinimalAgent()
{
// Arrange
var innerChatClientMock = new Mock<IChatClient>();
var builder = new ChatClientBuilder(innerChatClientMock.Object);
// Act
var agent = builder.BuildAIAgent();
// Assert
Assert.NotNull(agent);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
Assert.Null(agent.Instructions);
Assert.Null(agent.ChatOptions);
}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the ChatClientExtensions class.
/// </summary>
public sealed class ChatClientExtensionsTests
{
[Fact]
public void CreateAIAgent_WithBasicParameters_CreatesAgent()
{
// Arrange
var chatClientMock = new Mock<IChatClient>();
// Act
var agent = chatClientMock.Object.AsAIAgent(
instructions: "Test instructions",
name: "TestAgent",
description: "Test description"
);
// Assert
Assert.NotNull(agent);
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("Test description", agent.Description);
Assert.Equal("Test instructions", agent.Instructions);
}
[Fact]
public void CreateAIAgent_WithTools_SetsToolsInOptions()
{
// Arrange
var chatClientMock = new Mock<IChatClient>();
var tools = new List<AITool> { new Mock<AITool>().Object };
// Act
var agent = chatClientMock.Object.AsAIAgent(tools: tools);
// Assert
Assert.NotNull(agent);
Assert.NotNull(agent.ChatOptions);
Assert.Equal(tools, agent.ChatOptions.Tools);
}
[Fact]
public void CreateAIAgent_WithOptions_CreatesAgentWithOptions()
{
// Arrange
var chatClientMock = new Mock<IChatClient>();
var options = new ChatClientAgentOptions
{
Name = "AgentWithOptions",
Description = "Desc",
ChatOptions = new() { Instructions = "Instr" },
UseProvidedChatClientAsIs = true
};
// Act
var agent = chatClientMock.Object.AsAIAgent(options);
// Assert
Assert.NotNull(agent);
Assert.Equal("AgentWithOptions", agent.Name);
Assert.Equal("Desc", agent.Description);
Assert.Equal("Instr", agent.Instructions);
Assert.Same(chatClientMock.Object, agent.ChatClient);
}
[Fact]
public void CreateAIAgent_WithNullClient_Throws()
{
// Arrange
IChatClient chatClient = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => chatClient.AsAIAgent(instructions: "instructions"));
}
[Fact]
public void CreateAIAgent_WithNullClientAndOptions_Throws()
{
// Arrange
IChatClient chatClient = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => chatClient.AsAIAgent(options: new() { ChatOptions = new() { Instructions = "instructions" } }));
}
}

View File

@@ -0,0 +1,179 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Http;
using Microsoft.Agents.AI.CopilotStudio;
using Microsoft.Agents.CopilotStudio.Client;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="CopilotStudioAgent"/> class.
/// </summary>
public class CopilotStudioAgentTests
{
private static CopilotClient CreateTestCopilotClient()
{
// Create mock dependencies for CopilotClient
var mockSettings = new Mock<ConnectionSettings>();
var mockHttpClientFactory = new Mock<IHttpClientFactory>();
var mockHttpClient = new Mock<HttpClient>();
mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(mockHttpClient.Object);
return new CopilotClient(mockSettings.Object, mockHttpClientFactory.Object, NullLogger.Instance, "test-client");
}
#region GetService Method Tests
/// <summary>
/// Verify that GetService returns CopilotClient when requested.
/// </summary>
[Fact]
public void GetService_RequestingCopilotClient_ReturnsCopilotClient()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(CopilotClient));
// Assert
Assert.NotNull(result);
Assert.Same(client, result);
}
/// <summary>
/// Verify that GetService returns AIAgentMetadata when requested.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentMetadata_ReturnsMetadata()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result);
Assert.IsType<AIAgentMetadata>(result);
var metadata = (AIAgentMetadata)result;
Assert.Equal("copilot-studio", metadata.ProviderName);
}
/// <summary>
/// Verify that GetService returns null for unknown service types.
/// </summary>
[Fact]
public void GetService_RequestingUnknownServiceType_ReturnsNull()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(string));
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService with serviceKey parameter returns null for unknown service types.
/// </summary>
[Fact]
public void GetService_WithServiceKey_ReturnsNull()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(string), "test-key");
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService calls base.GetService() first and returns the agent itself when requesting CopilotStudioAgent type.
/// </summary>
[Fact]
public void GetService_RequestingCopilotStudioAgentType_ReturnsBaseImplementation()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(CopilotStudioAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
/// <summary>
/// Verify that GetService calls base.GetService() first and returns the agent itself when requesting AIAgent type.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentType_ReturnsBaseImplementation()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(AIAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
/// <summary>
/// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null.
/// </summary>
[Fact]
public void GetService_RequestingCopilotClientWithServiceKey_CallsBaseFirstThenDerivedLogic()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act - Request CopilotClient with a service key (base.GetService will return null due to serviceKey)
var result = agent.GetService(typeof(CopilotClient), "some-key");
// Assert
Assert.NotNull(result);
Assert.Same(client, result);
}
/// <summary>
/// Verify that GetService returns consistent AIAgentMetadata across multiple calls.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentMetadata_ReturnsConsistentMetadata()
{
// Arrange
var client = CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result1 = agent.GetService(typeof(AIAgentMetadata));
var result2 = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Same(result1, result2); // Should return the same instance
Assert.IsType<AIAgentMetadata>(result1);
var metadata = (AIAgentMetadata)result1;
Assert.Equal("copilot-studio", metadata.ProviderName);
}
#endregion
}

View File

@@ -0,0 +1,657 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests.Data;
/// <summary>
/// Contains unit tests for <see cref="TextSearchProvider"/>.
/// </summary>
public sealed class TextSearchProviderTests
{
private readonly Mock<ILogger<TextSearchProvider>> _loggerMock;
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
public TextSearchProviderTests()
{
this._loggerMock = new();
this._loggerFactoryMock = new();
this._loggerFactoryMock
.Setup(f => f.CreateLogger(It.IsAny<string>()))
.Returns(this._loggerMock.Object);
this._loggerFactoryMock
.Setup(f => f.CreateLogger(typeof(TextSearchProvider).FullName!))
.Returns(this._loggerMock.Object);
this._loggerMock
.Setup(f => f.IsEnabled(It.IsAny<LogLevel>()))
.Returns(true);
}
[Theory]
[InlineData(null, null, true)]
[InlineData("Custom context prompt", "Custom citations prompt", false)]
public async Task InvokingAsync_ShouldInjectFormattedResultsAsync(string? overrideContextPrompt, string? overrideCitationsPrompt, bool withLogging)
{
// Arrange
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
];
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt
};
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options, withLogging ? this._loggerFactoryMock.Object : null);
var invokingContext = new AIContextProvider.InvokingContext(
[
new ChatMessage(ChatRole.User, "Sample user question?"),
new ChatMessage(ChatRole.User, "Additional part")
]);
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("Sample user question?\nAdditional part", capturedInput);
Assert.Null(aiContext.Instructions); // TextSearchProvider uses a user message for context injection.
Assert.NotNull(aiContext.Messages);
Assert.Single(aiContext.Messages!);
var message = aiContext.Messages!.Single();
Assert.Equal(ChatRole.User, message.Role);
string text = message.Text!;
if (overrideContextPrompt is null)
{
Assert.Contains("## Additional Context", text);
Assert.Contains("Consider the following information from source documents when responding to the user:", text);
}
else
{
Assert.Contains(overrideContextPrompt, text);
}
Assert.Contains("SourceDocName: Doc1", text);
Assert.Contains("SourceDocLink: http://example.com/doc1", text);
Assert.Contains("Contents: Content of Doc1", text);
Assert.Contains("SourceDocName: Doc2", text);
Assert.Contains("SourceDocLink: http://example.com/doc2", text);
Assert.Contains("Contents: Content of Doc2", text);
if (overrideCitationsPrompt is null)
{
Assert.Contains("Include citations to the source document with document name and link if document name and link is available.", text);
}
else
{
Assert.Contains(overrideCitationsPrompt, text);
}
if (withLogging)
{
this._loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Retrieved 2 search results.")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
this._loggerMock.Verify(
l => l.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Search Results\nInput:Sample user question?\nAdditional part\nOutput")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
}
[Theory]
[InlineData(null, null, "Search", "Allows searching for additional information to help answer the user question.")]
[InlineData("CustomSearch", "CustomDescription", "CustomSearch", "CustomDescription")]
public async Task InvokingAsync_OnDemand_ShouldExposeSearchToolAsync(string? overrideName, string? overrideDescription, string expectedName, string expectedDescription)
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling,
FunctionToolName = overrideName,
FunctionToolDescription = overrideDescription
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Null(aiContext.Messages); // No automatic injection.
Assert.NotNull(aiContext.Tools);
Assert.Single(aiContext.Tools);
var tool = aiContext.Tools.Single();
Assert.Equal(expectedName, tool.Name);
Assert.Equal(expectedDescription, tool.Description);
}
[Fact]
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
{
// Arrange
var provider = new TextSearchProvider(this.FailingSearchAsync, default, null, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Null(aiContext.Messages);
Assert.Null(aiContext.Tools);
this._loggerMock.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Failed to search for data due to error")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.AtLeastOnce);
}
[Theory]
[InlineData(null, null)]
[InlineData("Custom context prompt", "Custom citations prompt")]
public async Task SearchAsync_ShouldReturnFormattedResultsAsync(string? overrideContextPrompt, string? overrideCitationsPrompt)
{
// Arrange
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
];
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
var options = new TextSearchProviderOptions
{
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt
};
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Act
var formatted = await provider.SearchAsync("Sample user question?", CancellationToken.None);
// Assert
if (overrideContextPrompt is null)
{
Assert.Contains("## Additional Context", formatted);
Assert.Contains("Consider the following information from source documents when responding to the user:", formatted);
}
else
{
Assert.Contains(overrideContextPrompt, formatted);
}
Assert.Contains("SourceDocName: Doc1", formatted);
Assert.Contains("SourceDocLink: http://example.com/doc1", formatted);
Assert.Contains("Contents: Content of Doc1", formatted);
Assert.Contains("SourceDocName: Doc2", formatted);
Assert.Contains("SourceDocLink: http://example.com/doc2", formatted);
Assert.Contains("Contents: Content of Doc2", formatted);
if (overrideCitationsPrompt is null)
{
Assert.Contains("Include citations to the source document with document name and link if document name and link is available.", formatted);
}
else
{
Assert.Contains(overrideCitationsPrompt, formatted);
}
}
[Fact]
public async Task InvokingAsync_ShouldUseContextFormatterWhenProvidedAsync()
{
// Arrange
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
];
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => $"Custom formatted context with {r.Count} results."
};
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(aiContext.Messages);
Assert.Single(aiContext.Messages!);
Assert.Equal("Custom formatted context with 2 results.", aiContext.Messages![0].Text);
}
[Fact]
public async Task InvokingAsync_WithRawRepresentations_ContextFormatterCanAccessAsync()
{
// Arrange
var payload1 = new RawPayload { Id = "R1" };
var payload2 = new RawPayload { Id = "R2" };
List<TextSearchProvider.TextSearchResult> results =
[
new() { SourceName = "Doc1", Text = "Content 1", RawRepresentation = payload1 },
new() { SourceName = "Doc2", Text = "Content 2", RawRepresentation = payload2 }
];
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => string.Join(",", r.Select(x => ((RawPayload)x.RawRepresentation!).Id))
};
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(aiContext.Messages);
Assert.Single(aiContext.Messages!);
Assert.Equal("R1,R2", aiContext.Messages![0].Text);
}
[Fact]
public async Task InvokingAsync_WithNoResults_ShouldReturnEmptyContextAsync()
{
// Arrange
var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke };
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Null(aiContext.Messages);
Assert.Null(aiContext.Instructions);
Assert.Null(aiContext.Tools);
}
#region Recent Message Memory Tests
[Fact]
public async Task InvokingAsync_WithPreviousFailedRequest_ShouldNotIncludeFailedRequestInputInSearchInputAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
{
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
new ChatMessage(ChatRole.Assistant, "D"),
};
await provider.InvokedAsync(new(initialMessages, aiContextProviderMessages: null) { InvokeException = new InvalidOperationException("Request Failed") });
var invokingContext = new AIContextProvider.InvokingContext(
[
new ChatMessage(ChatRole.User, "E")
]);
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("E", capturedInput); // Only the messages from the current request, since previous failed request should not be stored.
}
[Fact]
public async Task InvokingAsync_WithRecentMessageMemory_ShouldIncludeStoredMessagesInSearchInputAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
{
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
new ChatMessage(ChatRole.Assistant, "D"),
};
await provider.InvokedAsync(new(initialMessages, aiContextProviderMessages: null));
var invokingContext = new AIContextProvider.InvokingContext(
[
new ChatMessage(ChatRole.User, "E")
]);
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("B\nC\nD\nE", capturedInput); // Memory first (truncated) then current request.
}
[Fact]
public async Task InvokingAsync_WithAccumulatedMemoryAcrossInvocations_ShouldIncludeAllUpToLimitAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 5,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// First memory update (A,B)
await provider.InvokedAsync(new(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
], aiContextProviderMessages: null));
// Second memory update (C,D,E)
await provider.InvokedAsync(new(
[
new ChatMessage(ChatRole.User, "C"),
new ChatMessage(ChatRole.Assistant, "D"),
new ChatMessage(ChatRole.User, "E"),
], aiContextProviderMessages: null));
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "F")]);
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("A\nB\nC\nD\nE\nF", capturedInput); // All retained (limit 5) + current request message.
}
[Fact]
public async Task InvokingAsync_WithRecentMessageRolesIncluded_ShouldFilterRolesAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 4,
RecentMessageRolesIncluded = [ChatRole.Assistant] // Only retain assistant messages.
};
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed for this test.
}
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained.
var initialMessages = new[]
{
new ChatMessage(ChatRole.User, "U1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "U2"),
new ChatMessage(ChatRole.Assistant, "A2"),
};
await provider.InvokedAsync(new(initialMessages, null));
var invokingContext = new AIContextProvider.InvokingContext(
[
new ChatMessage(ChatRole.User, "Question?") // Current request message always appended.
]);
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal("A1\nA2\nQuestion?", capturedInput); // Only assistant messages from memory + current request.
}
#endregion
#region Serialization Tests
[Fact]
public void Serialize_WithNoRecentMessages_ShouldReturnEmptyState()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
// Act
var state = provider.Serialize();
// Assert
Assert.Equal(JsonValueKind.Object, state.ValueKind);
Assert.False(state.TryGetProperty("recentMessagesText", out _));
}
[Fact]
public async Task Serialize_WithRecentMessages_ShouldPersistMessagesUpToLimitAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var messages = new[]
{
new ChatMessage(ChatRole.User, "M1"),
new ChatMessage(ChatRole.Assistant, "M2"),
new ChatMessage(ChatRole.User, "M3"),
};
// Act
await provider.InvokedAsync(new(messages, aiContextProviderMessages: null)); // Populate recent memory.
var state = provider.Serialize();
// Assert
Assert.True(state.TryGetProperty("recentMessagesText", out var recentProperty));
Assert.Equal(JsonValueKind.Array, recentProperty.ValueKind);
var list = recentProperty.EnumerateArray().Select(e => e.GetString()).ToList();
Assert.Equal(3, list.Count);
Assert.Equal(["M1", "M2", "M3"], list);
}
[Fact]
public async Task SerializeAndDeserialize_RoundtripRestoresMessagesAsync()
{
// Arrange
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 4,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var messages = new[]
{
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
new ChatMessage(ChatRole.Assistant, "D"),
};
await provider.InvokedAsync(new(messages, aiContextProviderMessages: null));
// Act
var state = provider.Serialize();
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var roundTrippedProvider = new TextSearchProvider(SearchDelegate2Async, state, options: new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 4
});
var emptyMessages = Array.Empty<ChatMessage>();
await roundTrippedProvider.InvokingAsync(new(emptyMessages), CancellationToken.None); // Trigger search to read memory.
// Assert
Assert.NotNull(capturedInput);
Assert.Equal("A\nB\nC\nD", capturedInput);
}
[Fact]
public async Task Deserialize_WithChangedLowerLimit_ShouldTruncateToNewLimitAsync()
{
// Arrange
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, default, null, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 5,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
});
var messages = new[]
{
new ChatMessage(ChatRole.User, "L1"),
new ChatMessage(ChatRole.Assistant, "L2"),
new ChatMessage(ChatRole.User, "L3"),
new ChatMessage(ChatRole.Assistant, "L4"),
new ChatMessage(ChatRole.User, "L5"),
};
await initialProvider.InvokedAsync(new(messages, aiContextProviderMessages: null));
var state = initialProvider.Serialize();
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
// Act
var restoredProvider = new TextSearchProvider(SearchDelegate2Async, state, options: new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3 // Lower limit
});
await restoredProvider.InvokingAsync(new(Array.Empty<ChatMessage>()), CancellationToken.None);
// Assert
Assert.NotNull(capturedInput);
Assert.Equal("L1\nL2\nL3", capturedInput);
}
[Fact]
public async Task Deserialize_WithEmptyState_ShouldHaveNoMessagesAsync()
{
// Arrange
var emptyState = JsonSerializer.Deserialize("{}", TestJsonSerializerContext.Default.JsonElement);
string? capturedInput = null;
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
{
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
// Act
var provider = new TextSearchProvider(SearchDelegate2Async, emptyState, options: new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3
});
var emptyMessages = Array.Empty<ChatMessage>();
await provider.InvokingAsync(new(emptyMessages), CancellationToken.None);
// Assert
Assert.NotNull(capturedInput);
Assert.Equal(string.Empty, capturedInput); // No recent messages serialized => empty input.
}
#endregion
private Task<IEnumerable<TextSearchProvider.TextSearchResult>> NoResultSearchAsync(string input, CancellationToken ct)
{
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
private Task<IEnumerable<TextSearchProvider.TextSearchResult>> FailingSearchAsync(string input, CancellationToken ct)
{
throw new InvalidOperationException("Search Failed");
}
private sealed class RawPayload
{
public string Id { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,985 @@
// 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 Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for FunctionCallMiddlewareAgent functionality.
/// </summary>
public sealed class FunctionInvocationDelegatingAgentTests
{
#region Basic Functionality Tests
/// <summary>
/// Tests that FunctionCallMiddlewareAgent can be created with valid parameters.
/// </summary>
[Fact]
public void Constructor_ValidParameters_CreatesInstance()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var innerAgent = new ChatClientAgent(mockChatClient.Object);
static ValueTask<object?> CallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
=> next(context, cancellationToken);
// Act
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, CallbackAsync);
// Assert
Assert.NotNull(middleware);
Assert.Equal(innerAgent.Id, middleware.Id);
Assert.Equal(innerAgent.Name, middleware.Name);
Assert.Equal(innerAgent.Description, middleware.Description);
}
/// <summary>
/// Tests that constructor throws ArgumentNullException for null inner agent.
/// </summary>
[Fact]
public void Constructor_NullInnerAgent_ThrowsArgumentNullException()
{
// Arrange
static ValueTask<object?> CallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
=> next(context, cancellationToken);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new FunctionInvocationDelegatingAgent(null!, CallbackAsync));
}
#endregion
#region Function Invocation Tests
/// <summary>
/// Tests that middleware is invoked when functions are called during agent execution without options.
/// </summary>
[Fact]
public async Task RunAsync_WithFunctionCall_NoOptions_InvokesMiddlewareAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object, tools: [testFunction]);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Middleware-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
await middleware.RunAsync(messages, null, null, CancellationToken.None);
// Assert
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
// Verify execution order
var middlewarePreIndex = executionOrder.IndexOf("Middleware-Pre");
var functionIndex = executionOrder.IndexOf("Function-Executed");
var middlewarePostIndex = executionOrder.IndexOf("Middleware-Post");
Assert.True(middlewarePreIndex < functionIndex);
Assert.True(functionIndex < middlewarePostIndex);
}
/// <summary>
/// Tests that middleware is invoked when functions are called during agent execution without options.
/// </summary>
[Fact]
public async Task RunAsync_WithFunctionCall_AgentRunOptions_InvokesMiddlewareAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object, tools: [testFunction]);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Middleware-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
await middleware.RunAsync(messages, null, new AgentRunOptions(), CancellationToken.None);
// Assert
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
// Verify execution order
var middlewarePreIndex = executionOrder.IndexOf("Middleware-Pre");
var functionIndex = executionOrder.IndexOf("Function-Executed");
var middlewarePostIndex = executionOrder.IndexOf("Middleware-Post");
Assert.True(middlewarePreIndex < functionIndex);
Assert.True(functionIndex < middlewarePostIndex);
}
/// <summary>
/// Tests that middleware is invoked when functions are called during agent execution without options.
/// </summary>
[Fact]
public async Task RunAsync_WithFunctionCall_CustomAgentRunOptions_ThrowsNotSupportedAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object, tools: [testFunction]);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Middleware-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
await Assert.ThrowsAsync<NotSupportedException>(() =>
middleware.RunAsync(messages, null, new CustomAgentRunOptions(), CancellationToken.None));
}
/// <summary>
/// Tests that middleware is invoked when functions are called during agent execution.
/// </summary>
[Fact]
public async Task RunAsync_WithFunctionCall_InvokesMiddlewareAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Middleware-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
// Verify execution order
var middlewarePreIndex = executionOrder.IndexOf("Middleware-Pre");
var functionIndex = executionOrder.IndexOf("Function-Executed");
var middlewarePostIndex = executionOrder.IndexOf("Middleware-Post");
Assert.True(middlewarePreIndex < functionIndex);
Assert.True(functionIndex < middlewarePostIndex);
}
/// <summary>
/// Tests that multiple function calls trigger middleware for each invocation.
/// </summary>
[Fact]
public async Task RunAsync_WithMultipleFunctionCalls_InvokesMiddlewareForEachAsync()
{
// Arrange
var executionOrder = new List<string>();
var function1 = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function1-Executed");
return "Function1 result";
}, "Function1", "First test function");
var function2 = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function2-Executed");
return "Function2 result";
}, "Function2", "Second test function");
var functionCall1 = new FunctionCallContent("call_1", "Function1", new Dictionary<string, object?>());
var functionCall2 = new FunctionCallContent("call_2", "Function2", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall1, functionCall2);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add($"Middleware-Pre-{context.Function.Name}");
var result = await next(context, cancellationToken);
executionOrder.Add($"Middleware-Post-{context.Function.Name}");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [function1, function2] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Middleware-Pre-Function1", executionOrder);
Assert.Contains("Function1-Executed", executionOrder);
Assert.Contains("Middleware-Post-Function1", executionOrder);
Assert.Contains("Middleware-Pre-Function2", executionOrder);
Assert.Contains("Function2-Executed", executionOrder);
Assert.Contains("Middleware-Post-Function2", executionOrder);
}
#endregion
#region Context Validation Tests
/// <summary>
/// Tests that FunctionInvocationContext contains correct values during middleware execution.
/// </summary>
[Fact]
public async Task RunAsync_MiddlewareContext_ContainsCorrectValuesAsync()
{
// Arrange
var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?> { ["param"] = "value" });
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
FunctionInvocationContext? capturedContext = null;
AIAgent? capturedAgent = null;
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
capturedContext = context;
capturedAgent = agent;
return await next(context, cancellationToken);
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.NotNull(capturedContext);
Assert.Equal("TestFunction", capturedContext.Function.Name);
Assert.Same(innerAgent, capturedAgent); // The agent passed should be the inner agent
Assert.NotNull(capturedContext.Arguments);
// Note: Additional context properties would need to be verified based on actual FunctionInvocationContext structure
}
#endregion
#region AIAgentBuilder Use Method Tests
/// <summary>
/// Verify that AIAgentBuilder.Use method works correctly with function invocation middleware.
/// </summary>
[Fact]
public async Task AIAgentBuilder_Use_FunctionInvocationMiddleware_WorksCorrectlyAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var testFunction = AIFunctionFactory.Create(() => "test result", name: "TestFunction");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var executionOrder = new List<string>();
// Mock the chat client to return a function call, then a response
mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, [functionCall])));
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act
var agent = new AIAgentBuilder(innerAgent)
.Use((agent, context, next, cancellationToken) =>
{
executionOrder.Add("Middleware-Pre");
var result = next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
})
.Build();
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
}
/// <summary>
/// Verify that multiple function invocation middleware are executed.
/// </summary>
[Fact]
public async Task AIAgentBuilder_Use_MultipleFunctionMiddleware_BothExecuteAsync()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var testFunction = AIFunctionFactory.Create(() => "test result", name: "TestFunction");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var firstMiddlewareExecuted = false;
var secondMiddlewareExecuted = false;
// Mock the chat client to return a function call, then a response
mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, [functionCall])));
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act
var agent = new AIAgentBuilder(innerAgent)
.Use((agent, context, next, cancellationToken) =>
{
firstMiddlewareExecuted = true;
return next(context, cancellationToken);
})
.Use((agent, context, next, cancellationToken) =>
{
secondMiddlewareExecuted = true;
return next(context, cancellationToken);
})
.Build();
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await agent.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.True(firstMiddlewareExecuted, "First middleware should have executed");
Assert.True(secondMiddlewareExecuted, "Second middleware should have executed");
}
/// <summary>
/// Verify that AIAgentBuilder.Use method throws InvalidOperationException when inner agent is doesn't use a FunctinInvocking.
/// </summary>
[Fact]
public void AIAgentBuilder_Use_NonFICCEnabledAgent_ThrowsInvalidOperationException()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
// Act & Assert
var builder = new AIAgentBuilder(mockAgent.Object);
var exception = Assert.Throws<InvalidOperationException>(() =>
{
builder.Use((agent, context, next, cancellationToken) => next(context, cancellationToken));
builder.Build();
});
}
/// <summary>
/// Verify that AIAgentBuilder.Use method throws InvalidOperationException when inner agent is doesn't use a FunctinInvokingChatClient.
/// </summary>
[Fact]
public void AIAgentBuilder_Use_NonFICCDecoratedChatClientInAgent_ThrowsInvalidOperationException()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true });
// Act & Assert
var builder = new AIAgentBuilder(agent);
var exception = Assert.Throws<InvalidOperationException>(() =>
{
builder.Use((agent, context, next, cancellationToken) => next(context, cancellationToken));
builder.Build();
});
}
/// <summary>
/// Tests function invocation middleware when FunctionInvokingChatClient.CurrentContext is null (direct function invocation).
/// </summary>
[Fact]
public async Task RunAsync_DirectFunctionInvocation_MiddlewareHandlesNullCurrentContextAsync()
{
// Arrange
var executionOrder = new List<string>();
var capturedContext = new List<FunctionInvocationContext>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var mockChatClient = new Mock<IChatClient>();
// Setup mock to directly invoke the function (bypassing FunctionInvokingChatClient)
mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Returns<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>(async (messages, options, ct) =>
{
// Directly invoke the function to simulate null CurrentContext scenario
if (options?.Tools?.FirstOrDefault() is AIFunction function)
{
executionOrder.Add("Direct-Function-Invocation");
await function.InvokeAsync([], ct);
}
return new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response after direct invocation")]);
});
var innerAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
UseProvidedChatClientAsIs = true
});
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Middleware-Pre");
capturedContext.Add(context);
var result = await next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Direct-Function-Invocation", executionOrder);
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
// Verify that the context was created with Iteration = -1 (indicating no ambient context)
Assert.Single(capturedContext);
Assert.Equal(0, capturedContext[0].Iteration);
Assert.Equal("TestFunction", capturedContext[0].Function.Name);
Assert.NotNull(capturedContext[0].Arguments);
}
#endregion
#region Error Handling Tests
/// <summary>
/// Tests that exceptions thrown by middleware during pre-invocation surface to the caller.
/// </summary>
[Fact]
public async Task RunAsync_MiddlewareThrowsPreInvocation_ExceptionSurfacesAsync()
{
// Arrange
var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var expectedException = new InvalidOperationException("Pre-invocation error");
ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
throw expectedException;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act & Assert
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
var actualException = await Assert.ThrowsAsync<InvalidOperationException>(
() => middleware.RunAsync(messages, null, options, CancellationToken.None));
Assert.Same(expectedException, actualException);
}
/// <summary>
/// Tests that exceptions thrown by the function are handled by middleware.
/// </summary>
[Fact]
public async Task RunAsync_FunctionThrowsException_MiddlewareCanHandleAsync()
{
// Arrange
var functionException = new InvalidOperationException("Function error");
string ThrowingFunction() => throw functionException;
var testFunction = AIFunctionFactory.Create(ThrowingFunction, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var middlewareHandledException = false;
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
try
{
return await next(context, cancellationToken);
}
catch (InvalidOperationException)
{
middlewareHandledException = true;
return "Error handled by middleware";
}
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.True(middlewareHandledException);
}
#endregion
#region Result Modification Tests
/// <summary>
/// Tests that middleware can modify function results.
/// </summary>
[Fact]
public async Task RunAsync_MiddlewareModifiesResult_ModifiedResultUsedAsync()
{
// Arrange
var testFunction = AIFunctionFactory.Create(() => "Original result", "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
const string ModifiedResult = "Modified by middleware";
static async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
await next(context, cancellationToken);
return ModifiedResult; // Return the modified result instead of setting context property
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
var response = await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.NotNull(response);
// The modified result should be reflected in the response messages
var functionResultContent = response.Messages
.SelectMany(m => m.Contents)
.OfType<FunctionResultContent>()
.FirstOrDefault();
Assert.NotNull(functionResultContent);
Assert.Equal(ModifiedResult, functionResultContent.Result);
}
#endregion
#region Middleware Chaining Tests
/// <summary>
/// Tests execution order with multiple function middleware instances in a chain.
/// </summary>
[Fact]
public async Task RunAsync_MultipleFunctionMiddleware_ExecutesInCorrectOrderAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = new Mock<IChatClient>();
// Setup sequence: first call returns function call, subsequent calls return final response
var responseWithFunctionCall = new ChatResponse([
new ChatMessage(ChatRole.Assistant, [functionCall])
]);
var finalResponse = new ChatResponse([
new ChatMessage(ChatRole.Assistant, "Final response")
]);
mockChatClient.SetupSequence(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(responseWithFunctionCall)
.ReturnsAsync(finalResponse);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> FirstMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("First-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("First-Post");
return result;
}
async ValueTask<object?> SecondMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Second-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Second-Post");
return result;
}
// Create nested middleware chain
var firstMiddleware = new FunctionInvocationDelegatingAgent(innerAgent, FirstMiddlewareAsync);
var secondMiddleware = new FunctionInvocationDelegatingAgent(firstMiddleware, SecondMiddlewareAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await secondMiddleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
var expectedOrder = new[] { "First-Pre", "Second-Pre", "Function-Executed", "Second-Post", "First-Post" };
Assert.Equal(expectedOrder, executionOrder);
}
/// <summary>
/// Tests that function middleware works correctly when combined with running middleware.
/// </summary>
[Fact]
public async Task RunAsync_FunctionMiddlewareWithRunningMiddleware_BothExecuteAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async Task<AgentResponse> RunningMiddlewareCallbackAsync(IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken)
{
executionOrder.Add("Running-Pre");
var result = await innerAgent.RunAsync(messages, thread, options, cancellationToken);
executionOrder.Add("Running-Post");
return result;
}
async ValueTask<object?> FunctionMiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Function-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Function-Post");
return result;
}
// Create middleware chain: Function -> Running -> Inner using AIAgentBuilder
var runningMiddleware = new AIAgentBuilder(innerAgent)
.Use(RunningMiddlewareCallbackAsync, null)
.Build();
var functionMiddleware = new FunctionInvocationDelegatingAgent(runningMiddleware, FunctionMiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await functionMiddleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.Contains("Running-Pre", executionOrder);
Assert.Contains("Running-Post", executionOrder);
Assert.Contains("Function-Pre", executionOrder);
Assert.Contains("Function-Post", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
}
#endregion
#region Streaming Tests
/// <summary>
/// Tests that function middleware works correctly with streaming responses.
/// </summary>
[Fact]
public async Task RunStreamingAsync_WithFunctionCall_InvokesMiddlewareAsync()
{
// Arrange
var executionOrder = new List<string>();
var testFunction = AIFunctionFactory.Create(() =>
{
executionOrder.Add("Function-Executed");
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
// Setup streaming response with function calls
var streamingResponse = new ChatResponseUpdate[]
{
new() { Contents = [functionCall] }, // Include function call in streaming response
new() { Contents = [new TextContent("Streaming response")] }
};
mockChatClient.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(streamingResponse.ToAsyncEnumerable());
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
executionOrder.Add("Middleware-Pre");
var result = await next(context, cancellationToken);
executionOrder.Add("Middleware-Post");
return result;
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
var responseUpdates = new List<AgentResponseUpdate>();
await foreach (var update in middleware.RunStreamingAsync(messages, null, options, CancellationToken.None))
{
responseUpdates.Add(update);
}
// Assert
Assert.NotEmpty(responseUpdates);
Assert.Contains("Middleware-Pre", executionOrder);
Assert.Contains("Function-Executed", executionOrder);
Assert.Contains("Middleware-Post", executionOrder);
}
#endregion
#region Edge Cases
/// <summary>
/// Tests that middleware is not invoked when no function calls are made.
/// </summary>
[Fact]
public async Task RunAsync_NoFunctionCalls_MiddlewareNotInvokedAsync()
{
// Arrange
var middlewareInvoked = false;
var mockChatClient = CreateMockChatClient(
new ChatResponse([new ChatMessage(ChatRole.Assistant, "Regular response")]));
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
middlewareInvoked = true;
return await next(context, cancellationToken);
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
await middleware.RunAsync(messages, null, null, CancellationToken.None);
// Assert
Assert.False(middlewareInvoked);
}
/// <summary>
/// Tests that middleware handles cancellation tokens correctly.
/// </summary>
[Fact]
public async Task RunAsync_CancellationToken_PropagatedToMiddlewareAsync()
{
// Arrange
var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
var cancellationTokenSource = new CancellationTokenSource();
var expectedToken = cancellationTokenSource.Token;
CancellationToken? capturedToken = null;
async ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
capturedToken = cancellationToken;
return await next(context, cancellationToken);
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
await middleware.RunAsync(messages, null, options, expectedToken);
// Assert
Assert.Equal(expectedToken, capturedToken);
}
/// <summary>
/// Tests that middleware can prevent function execution by not calling next().
/// </summary>
[Fact]
public async Task RunAsync_MiddlewareDoesNotCallNext_FunctionNotExecutedAsync()
{
// Arrange
var functionExecuted = false;
var testFunction = AIFunctionFactory.Create(() =>
{
functionExecuted = true;
return "Function result";
}, "TestFunction", "A test function");
var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary<string, object?>());
var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall);
var innerAgent = new ChatClientAgent(mockChatClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "Test message") };
static ValueTask<object?> MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
// Don't call next() - this should prevent function execution
// Return the blocked result directly
return new ValueTask<object?>("Blocked by middleware");
}
var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync);
// Act
var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] });
var response = await middleware.RunAsync(messages, null, options, CancellationToken.None);
// Assert
Assert.False(functionExecuted);
Assert.NotNull(response);
// Verify the middleware result is used
var functionResultContent = response.Messages
.SelectMany(m => m.Contents)
.OfType<FunctionResultContent>()
.FirstOrDefault();
Assert.NotNull(functionResultContent);
Assert.Equal("Blocked by middleware", functionResultContent.Result);
}
#endregion
/// <summary>
/// Creates a mock IChatClient with predefined responses for testing.
/// </summary>
/// <param name="responses">The responses to return in sequence.</param>
/// <returns>A configured mock IChatClient.</returns>
private static Mock<IChatClient> CreateMockChatClient(params ChatResponse[] responses)
{
var mockChatClient = new Mock<IChatClient>();
var responseQueue = new Queue<ChatResponse>(responses);
mockChatClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => responseQueue.Count > 0 ? responseQueue.Dequeue() : responses.LastOrDefault() ?? CreateDefaultResponse());
return mockChatClient;
}
/// <summary>
/// Creates a mock IChatClient that returns responses with function calls for testing function middleware.
/// </summary>
/// <param name="functionCalls">The function calls to include in responses.</param>
/// <returns>A configured mock IChatClient.</returns>
private static Mock<IChatClient> CreateMockChatClientWithFunctionCalls(params FunctionCallContent[] functionCalls)
{
var mockChatClient = new Mock<IChatClient>();
var responseWithFunctionCalls = new ChatResponse([
new ChatMessage(ChatRole.Assistant, functionCalls.Cast<AIContent>().ToList())
]);
mockChatClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(responseWithFunctionCalls);
return mockChatClient;
}
/// <summary>
/// Creates a default ChatResponse for fallback scenarios.
/// </summary>
/// <returns>A default ChatResponse.</returns>
private static ChatResponse CreateDefaultResponse()
{
return new ChatResponse([new ChatMessage(ChatRole.Assistant, "Default response")]);
}
/// <summary>
/// Custom AgentRunOptions class for testing
/// </summary>
private sealed class CustomAgentRunOptions : AgentRunOptions;
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="LoggingAgentBuilderExtensions"/> UseLogging extension method.
/// </summary>
public class LoggingAgentBuilderExtensionsTests
{
/// <summary>
/// Verify that UseLogging throws ArgumentNullException when builder is null.
/// </summary>
[Fact]
public void UseLogging_WithNullBuilder_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("builder", () => ((AIAgentBuilder)null!).UseLogging());
}
/// <summary>
/// Verify that UseLogging returns a LoggingAgent when logger factory is provided.
/// </summary>
[Fact]
public void UseLogging_WithLoggerFactory_ReturnsLoggingAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
using var loggerFactory = LoggerFactory.Create(builder => { });
// Act
AIAgent result = builder.UseLogging(loggerFactory: loggerFactory).Build();
// Assert
Assert.IsType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging returns the inner agent when NullLoggerFactory is provided.
/// </summary>
[Fact]
public void UseLogging_WithNullLoggerFactory_ReturnsInnerAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
AIAgent result = builder.UseLogging(loggerFactory: NullLoggerFactory.Instance).Build();
// Assert
Assert.NotNull(result);
Assert.IsNotType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging with configure action works correctly.
/// </summary>
[Fact]
public void UseLogging_WithConfigureAction_CallsConfigureAction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
using var loggerFactory = LoggerFactory.Create(builder => { });
var configureWasCalled = false;
// Act
AIAgent result = builder.UseLogging(
loggerFactory: loggerFactory,
configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
Assert.IsType<LoggingAgent>(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging returns the same builder instance for chaining.
/// </summary>
[Fact]
public void UseLogging_ReturnsBuilderForChaining()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
using var loggerFactory = LoggerFactory.Create(builder => { });
// Act
AIAgentBuilder result = builder.UseLogging(loggerFactory: loggerFactory);
// Assert
Assert.Same(builder, result);
}
/// <summary>
/// Verify that UseLogging with all parameters works correctly.
/// </summary>
[Fact]
public void UseLogging_WithAllParameters_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
using var loggerFactory = LoggerFactory.Create(builder => { });
var builder = new AIAgentBuilder(mockAgent.Object);
var configureWasCalled = false;
// Act
AIAgent result = builder.UseLogging(
loggerFactory: loggerFactory,
configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging resolves ILoggerFactory from service provider when not provided.
/// </summary>
[Fact]
public void UseLogging_WithoutLoggerFactory_ResolvesFromServiceProvider()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
var services = new ServiceCollection();
using var loggerFactory = LoggerFactory.Create(builder => { });
services.AddSingleton(loggerFactory);
builder.Use((innerAgent, serviceProvider) =>
{
Assert.NotNull(serviceProvider);
return innerAgent;
});
// Act
AIAgent result = builder.UseLogging().Build(services.BuildServiceProvider());
// Assert
Assert.IsType<LoggingAgent>(result);
}
/// <summary>
/// Verify that UseLogging with configure action can customize JsonSerializerOptions.
/// </summary>
[Fact]
public void UseLogging_ConfigureJsonSerializerOptions_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
using var loggerFactory = LoggerFactory.Create(builder => { });
var customOptions = new System.Text.Json.JsonSerializerOptions();
// Act
AIAgent result = builder.UseLogging(
loggerFactory: loggerFactory,
configure: agent => agent.JsonSerializerOptions = customOptions).Build();
// Assert
Assert.IsType<LoggingAgent>(result);
Assert.Same(customOptions, ((LoggingAgent)result).JsonSerializerOptions);
}
}

View File

@@ -0,0 +1,400 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="LoggingAgent"/> class.
/// </summary>
public class LoggingAgentTests
{
[Fact]
public void Ctor_InvalidArgs_Throws()
{
var mockLogger = new Mock<ILogger>();
Assert.Throws<ArgumentNullException>("innerAgent", () => new LoggingAgent(null!, mockLogger.Object));
Assert.Throws<ArgumentNullException>("logger", () => new LoggingAgent(new TestAIAgent(), null!));
}
[Fact]
public void Properties_DelegateToInnerAgent()
{
// Arrange
TestAIAgent innerAgent = new()
{
NameFunc = () => "TestAgent",
DescriptionFunc = () => "This is a test agent.",
};
var mockLogger = new Mock<ILogger>();
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
// Act & Assert
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("This is a test agent.", agent.Description);
Assert.Equal(innerAgent.Id, agent.Id);
}
[Fact]
public void JsonSerializerOptions_Roundtrips()
{
// Arrange
var mockLogger = new Mock<ILogger>();
var agent = new LoggingAgent(new TestAIAgent(), mockLogger.Object);
JsonSerializerOptions options = new();
// Act
agent.JsonSerializerOptions = options;
// Assert
Assert.Same(options, agent.JsonSerializerOptions);
}
[Fact]
public void JsonSerializerOptions_SetNull_Throws()
{
// Arrange
var mockLogger = new Mock<ILogger>();
var agent = new LoggingAgent(new TestAIAgent(), mockLogger.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => agent.JsonSerializerOptions = null!);
}
[Fact]
public async Task RunAsync_LogsAtDebugLevelAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(false);
var innerAgent = new TestAIAgent
{
RunAsyncFunc = async (messages, thread, options, cancellationToken) =>
{
await Task.Yield();
return new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
}
};
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
await agent.RunAsync(messages);
// Assert
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync invoked")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync completed")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunAsync_LogsAtTraceLevel_IncludesSensitiveDataAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(true);
var innerAgent = new TestAIAgent
{
RunAsyncFunc = async (messages, thread, options, cancellationToken) =>
{
await Task.Yield();
return new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
}
};
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
await agent.RunAsync(messages);
// Assert
mockLogger.Verify(
l => l.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync invoked")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
mockLogger.Verify(
l => l.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunAsync completed")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunAsync_OnCancellation_LogsCanceledAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
var innerAgent = new TestAIAgent
{
RunAsyncFunc = (messages, thread, options, cancellationToken) =>
throw new OperationCanceledException()
};
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() => agent.RunAsync(messages));
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("canceled")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunAsync_OnException_LogsFailedAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
var innerAgent = new TestAIAgent
{
RunAsyncFunc = (messages, thread, options, cancellationToken) =>
throw new InvalidOperationException("Test exception")
};
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(messages));
mockLogger.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_LogsAtDebugLevelAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(false);
var innerAgent = new TestAIAgent
{
RunStreamingAsyncFunc = CallbackAsync
};
static async IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
yield return new AgentResponseUpdate(ChatRole.Assistant, "Test");
}
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
await foreach (var update in agent.RunStreamingAsync(messages))
{
// Consume the stream
}
// Assert
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunStreamingAsync invoked")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("RunStreamingAsync completed")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_LogsUpdatesAtTraceLevelAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Trace)).Returns(true);
var innerAgent = new TestAIAgent
{
RunStreamingAsyncFunc = CallbackAsync
};
static async IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
yield return new AgentResponseUpdate(ChatRole.Assistant, "Update 1");
yield return new AgentResponseUpdate(ChatRole.Assistant, "Update 2");
}
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
await foreach (var update in agent.RunStreamingAsync(messages))
{
// Consume the stream
}
// Assert
mockLogger.Verify(
l => l.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("received update")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Exactly(2));
}
[Fact]
public async Task RunStreamingAsync_OnCancellation_LogsCanceledAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
var innerAgent = new TestAIAgent
{
RunStreamingAsyncFunc = CallbackAsync
};
static async IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
throw new OperationCanceledException();
// The following yield statement is required for async iterator methods but is unreachable.
// This pattern is intentional for testing exception scenarios in async iterators.
#pragma warning disable CS0162 // Unreachable code detected
yield break;
#pragma warning restore CS0162 // Unreachable code detected
}
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(messages))
{
// Consume the stream
}
});
mockLogger.Verify(
l => l.Log(
LogLevel.Debug,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("canceled")),
null,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_OnException_LogsFailedAsync()
{
// Arrange
var mockLogger = new Mock<ILogger>();
mockLogger.Setup(l => l.IsEnabled(LogLevel.Debug)).Returns(true);
mockLogger.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
var innerAgent = new TestAIAgent
{
RunStreamingAsyncFunc = CallbackAsync
};
static async IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
throw new InvalidOperationException("Test exception");
// The following yield statement is required for async iterator methods but is unreachable.
// This pattern is intentional for testing exception scenarios in async iterators.
#pragma warning disable CS0162 // Unreachable code detected
yield break;
#pragma warning restore CS0162 // Unreachable code detected
}
var agent = new LoggingAgent(innerAgent, mockLogger.Object);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in agent.RunStreamingAsync(messages))
{
// Consume the stream
}
});
mockLogger.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
}

View File

@@ -0,0 +1,537 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.VectorData;
using Moq;
namespace Microsoft.Agents.AI.Memory.UnitTests;
/// <summary>
/// Contains unit tests for the <see cref="ChatHistoryMemoryProvider"/> class.
/// </summary>
public class ChatHistoryMemoryProviderTests
{
private readonly Mock<ILogger<ChatHistoryMemoryProvider>> _loggerMock;
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
private readonly Mock<VectorStore> _vectorStoreMock;
private readonly Mock<VectorStoreCollection<object, Dictionary<string, object?>>> _vectorStoreCollectionMock;
private const string TestCollectionName = "testcollection";
public ChatHistoryMemoryProviderTests()
{
this._loggerMock = new();
this._loggerFactoryMock = new();
this._loggerFactoryMock
.Setup(f => f.CreateLogger(It.IsAny<string>()))
.Returns(this._loggerMock.Object);
this._loggerFactoryMock
.Setup(f => f.CreateLogger(typeof(ChatHistoryMemoryProvider).FullName!))
.Returns(this._loggerMock.Object);
this._loggerMock
.Setup(f => f.IsEnabled(It.IsAny<LogLevel>()))
.Returns(true);
this._vectorStoreCollectionMock = new(MockBehavior.Strict);
this._vectorStoreMock = new(MockBehavior.Strict);
this._vectorStoreCollectionMock
.Setup(c => c.EnsureCollectionExistsAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
this._vectorStoreMock
.Setup(vs => vs.GetDynamicCollection(
It.IsAny<string>(),
It.IsAny<VectorStoreCollectionDefinition>()))
.Returns(this._vectorStoreCollectionMock.Object);
}
[Fact]
public void Constructor_Throws_ForNullVectorStore()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ChatHistoryMemoryProvider(null!, "testcollection", 1, new ChatHistoryMemoryProviderScope() { UserId = "UID" }));
}
[Fact]
public void Constructor_Throws_ForNullCollectionName()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, null!, 1, new ChatHistoryMemoryProviderScope() { UserId = "UID" }));
}
[Fact]
public void Constructor_Throws_ForNullStorageScope()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, "testcollection", 1, null!));
}
[Fact]
public void Constructor_Throws_ForInvalidVectorDimensions()
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, "testcollection", 0, new ChatHistoryMemoryProviderScope() { UserId = "UID" }));
Assert.Throws<ArgumentOutOfRangeException>(() => new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, "testcollection", -5, new ChatHistoryMemoryProviderScope() { UserId = "UID" }));
}
#region InvokedAsync Tests
[Fact]
public async Task InvokedAsync_UpsertsMessages_ToCollectionAsync()
{
// Arrange
var stored = new List<Dictionary<string, object?>>();
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<Dictionary<string, object?>>, CancellationToken>((items, ct) =>
{
if (items != null)
{
stored.AddRange(items);
}
})
.Returns(Task.CompletedTask);
var storeScope = new ChatHistoryMemoryProviderScope
{
ApplicationId = "app1",
AgentId = "agent1",
ThreadId = "thread1",
UserId = "user1"
};
var provider = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, storeScope);
var requestMsgWithValues = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1", AuthorName = "user1", CreatedAt = new DateTimeOffset(new DateTime(2000, 1, 1), TimeSpan.Zero) };
var requestMsgWithNulls = new ChatMessage(ChatRole.User, "request text nulls");
var responseMsg = new ChatMessage(ChatRole.Assistant, "response text") { MessageId = "resp-1", AuthorName = "assistant" };
var invokedContext = new AIContextProvider.InvokedContext([requestMsgWithValues, requestMsgWithNulls], aiContextProviderMessages: null)
{
ResponseMessages = [responseMsg]
};
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert
this._vectorStoreCollectionMock.Verify(
m => m.EnsureCollectionExistsAsync(It.IsAny<CancellationToken>()),
Times.Once);
Assert.Equal(3, stored.Count);
Assert.Equal("req-1", stored[0]["MessageId"]);
Assert.Equal("request text", stored[0]["Content"]);
Assert.Equal("user1", stored[0]["AuthorName"]);
Assert.Equal(ChatRole.User.ToString(), stored[0]["Role"]);
Assert.Equal("2000-01-01T00:00:00.0000000+00:00", stored[0]["CreatedAt"]);
Assert.Equal("app1", stored[0]["ApplicationId"]);
Assert.Equal("agent1", stored[0]["AgentId"]);
Assert.Equal("thread1", stored[0]["ThreadId"]);
Assert.Equal("user1", stored[0]["UserId"]);
Assert.Null(stored[1]["MessageId"]);
Assert.Equal("request text nulls", stored[1]["Content"]);
Assert.Null(stored[1]["AuthorName"]);
Assert.Equal(ChatRole.User.ToString(), stored[1]["Role"]);
Assert.Equal("app1", stored[1]["ApplicationId"]);
Assert.Equal("agent1", stored[1]["AgentId"]);
Assert.Equal("thread1", stored[1]["ThreadId"]);
Assert.Equal("user1", stored[1]["UserId"]);
Assert.Equal("resp-1", stored[2]["MessageId"]);
Assert.Equal("response text", stored[2]["Content"]);
Assert.Equal("assistant", stored[2]["AuthorName"]);
Assert.Equal(ChatRole.Assistant.ToString(), stored[2]["Role"]);
Assert.Equal("app1", stored[2]["ApplicationId"]);
Assert.Equal("agent1", stored[2]["AgentId"]);
Assert.Equal("thread1", stored[2]["ThreadId"]);
Assert.Equal("user1", stored[2]["UserId"]);
}
[Fact]
public async Task InvokedAsync_DoesNotUpsertMessages_WhenInvokeFailedAsync()
{
// Arrange
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
new ChatHistoryMemoryProviderScope() { UserId = "UID" });
var requestMsg = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1" };
var invokedContext = new AIContextProvider.InvokedContext([requestMsg], aiContextProviderMessages: null)
{
InvokeException = new InvalidOperationException("Invoke failed")
};
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert
this._vectorStoreCollectionMock.Verify(
c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task InvokedAsync_DoesNotThrow_WhenUpsertThrowsAsync()
{
// Arrange
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Upsert failed"));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
new ChatHistoryMemoryProviderScope() { UserId = "UID" },
loggerFactory: this._loggerFactoryMock.Object);
var requestMsg = new ChatMessage(ChatRole.User, "request text") { MessageId = "req-1" };
var invokedContext = new AIContextProvider.InvokedContext([requestMsg], aiContextProviderMessages: null);
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert
this._loggerMock.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("ChatHistoryMemoryProvider: Failed to add messages to chat history vector store due to error")),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Theory]
[InlineData(false, false, 0)]
[InlineData(true, false, 0)]
[InlineData(false, true, 2)]
[InlineData(true, true, 2)]
public async Task InvokedAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations)
{
// Arrange
var options = new ChatHistoryMemoryProviderOptions
{
EnableSensitiveTelemetryData = enableSensitiveTelemetryData
};
if (requestThrows)
{
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Upsert failed"));
}
else
{
this._vectorStoreCollectionMock
.Setup(c => c.UpsertAsync(It.IsAny<IEnumerable<Dictionary<string, object?>>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
}
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
new ChatHistoryMemoryProviderScope { UserId = "user1" },
options: options,
loggerFactory: this._loggerFactoryMock.Object);
var requestMsg = new ChatMessage(ChatRole.User, "request text");
var invokedContext = new AIContextProvider.InvokedContext([requestMsg], aiContextProviderMessages: null);
// Act
await provider.InvokedAsync(invokedContext, CancellationToken.None);
// Assert
Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count);
foreach (var logInvocation in this._loggerMock.Invocations)
{
if (logInvocation.Method.Name == nameof(ILogger.IsEnabled))
{
continue;
}
var state = Assert.IsType<IReadOnlyList<KeyValuePair<string, object?>>>(logInvocation.Arguments[2], exactMatch: false);
var userIdValue = state.First(kvp => kvp.Key == "UserId").Value;
Assert.Equal(enableSensitiveTelemetryData ? "user1" : "<redacted>", userIdValue);
}
}
#endregion
#region InvokingAsync Tests
[Fact]
public async Task InvokedAsync_SearchesVectorStoreAsync()
{
// Arrange
var providerOptions = new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
MaxResults = 2,
ContextPrompt = "Here is the relevant chat history:\n"
};
var storedItems = new List<VectorSearchResult<Dictionary<string, object?>>>
{
new(
new Dictionary<string, object?>
{
["MessageId"] = "msg-1",
["Content"] = "First stored message",
["Role"] = ChatRole.User.ToString(),
["CreatedAt"] = "2023-01-01T00:00:00.0000000+00:00"
},
0.9f),
new(
new Dictionary<string, object?>
{
["MessageId"] = "msg-2",
["Content"] = "Second stored message",
["Role"] = ChatRole.User.ToString(),
["CreatedAt"] = "2023-01-02T00:00:00.0000000+00:00"
},
0.8f)
};
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(storedItems));
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
new ChatHistoryMemoryProviderScope() { UserId = "UID" },
options: providerOptions);
var requestMsg = new ChatMessage(ChatRole.User, "requesting relevant history");
var invokingContext = new AIContextProvider.InvokingContext([requestMsg]);
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
this._vectorStoreCollectionMock.Verify(
c => c.SearchAsync(
It.Is<string>(s => s == "requesting relevant history"),
2,
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task InvokedAsync_CreatesFilter_WhenSearchScopeProvidedAsync()
{
// Arrange
var providerOptions = new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
MaxResults = 2,
ContextPrompt = "Here is the relevant chat history:\n"
};
var searchScope = new ChatHistoryMemoryProviderScope
{
ApplicationId = "app1",
AgentId = "agent1",
ThreadId = "thread1",
UserId = "user1"
};
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Callback((string query, int maxResults, VectorSearchOptions<Dictionary<string, object?>> options, CancellationToken ct) =>
{
// Verify that the filter was created correctly
const string ExpectedFilter = "x => ((((x.ApplicationId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).applicationId) AndAlso (x.AgentId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).agentId)) AndAlso (x.UserId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).userId)) AndAlso (x.ThreadId == value(Microsoft.Agents.AI.VectorDataMemory.ChatHistoryMemoryProvider+<>c__DisplayClass20_0).threadId))";
Assert.Equal(ExpectedFilter, options.Filter!.ToString());
})
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
var provider = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, options: providerOptions, storageScope: searchScope, searchScope: searchScope);
var requestMsg = new ChatMessage(ChatRole.User, "requesting relevant history");
var invokingContext = new AIContextProvider.InvokingContext([requestMsg]);
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
this._vectorStoreCollectionMock.Verify(
c => c.SearchAsync(
It.Is<string>(s => s == "requesting relevant history"),
2,
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Theory]
[InlineData(false, false, 2)]
[InlineData(true, false, 2)]
[InlineData(false, true, 2)]
[InlineData(true, true, 2)]
public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations)
{
// Arrange
var options = new ChatHistoryMemoryProviderOptions
{
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
EnableSensitiveTelemetryData = enableSensitiveTelemetryData
};
var scope = new ChatHistoryMemoryProviderScope
{
UserId = "user1"
};
if (requestThrows)
{
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Search failed"));
}
else
{
this._vectorStoreCollectionMock
.Setup(c => c.SearchAsync(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
}
var provider = new ChatHistoryMemoryProvider(
this._vectorStoreMock.Object,
TestCollectionName,
1,
storageScope: scope,
searchScope: scope,
options: options,
loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "requesting relevant history")]);
// Act
await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count);
foreach (var logInvocation in this._loggerMock.Invocations)
{
if (logInvocation.Method.Name == nameof(ILogger.IsEnabled))
{
continue;
}
var state = Assert.IsType<IReadOnlyList<KeyValuePair<string, object?>>>(logInvocation.Arguments[2], exactMatch: false);
var userIdValue = state.First(kvp => kvp.Key == "UserId").Value;
Assert.Equal(enableSensitiveTelemetryData ? "user1" : "<redacted>", userIdValue);
var inputValue = state.FirstOrDefault(kvp => kvp.Key == "Input").Value;
if (inputValue != null)
{
Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : "<redacted>", inputValue);
}
var messageTextValue = state.FirstOrDefault(kvp => kvp.Key == "MessageText").Value;
if (messageTextValue != null)
{
Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : "<redacted>", messageTextValue);
}
}
}
#endregion
#region Serialization Tests
[Fact]
public void Serialize_Deserialize_RoundtripsScopes()
{
// Arrange
var storageScope = new ChatHistoryMemoryProviderScope
{
ApplicationId = "app",
AgentId = "agent",
ThreadId = "thread",
UserId = "user"
};
var searchScope = new ChatHistoryMemoryProviderScope
{
ApplicationId = "app2",
AgentId = "agent2",
ThreadId = "thread2",
UserId = "user2"
};
var provider = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, storageScope: storageScope, searchScope: searchScope);
// Act
var stateElement = provider.Serialize();
using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText());
var storage = doc.RootElement.GetProperty("storageScope");
Assert.Equal("app", storage.GetProperty("applicationId").GetString());
Assert.Equal("agent", storage.GetProperty("agentId").GetString());
Assert.Equal("thread", storage.GetProperty("threadId").GetString());
Assert.Equal("user", storage.GetProperty("userId").GetString());
var search = doc.RootElement.GetProperty("searchScope");
Assert.Equal("app2", search.GetProperty("applicationId").GetString());
Assert.Equal("agent2", search.GetProperty("agentId").GetString());
Assert.Equal("thread2", search.GetProperty("threadId").GetString());
Assert.Equal("user2", search.GetProperty("userId").GetString());
// Act - deserialize and serialize again
var provider2 = new ChatHistoryMemoryProvider(this._vectorStoreMock.Object, TestCollectionName, 1, serializedState: stateElement);
var stateElement2 = provider2.Serialize();
// Assert - roundtrip the state
Assert.Equal(stateElement.GetRawText(), stateElement2.GetRawText());
}
#endregion
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
{
await Task.Yield();
foreach (var update in values)
{
yield return update;
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.CopilotStudio\Microsoft.Agents.AI.CopilotStudio.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,126 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="OpenTelemetryAgentBuilderExtensions"/> class.
/// </summary>
public class OpenTelemetryAgentBuilderExtensionsTests
{
/// <summary>
/// Verify that UseOpenTelemetry throws ArgumentNullException when builder is null.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithNullBuilder_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>("builder", () => ((AIAgentBuilder)null!).UseOpenTelemetry());
}
/// <summary>
/// Verify that UseOpenTelemetry returns an OpenTelemetryAgent.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithValidBuilder_ReturnsOpenTelemetryAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseOpenTelemetry().Build();
// Assert
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry with source name works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithSourceName_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
const string SourceName = "TestSource";
// Act
var result = builder.UseOpenTelemetry(sourceName: SourceName).Build();
// Assert
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry with configure action works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithConfigureAction_CallsConfigureAction()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
var configureWasCalled = false;
// Act
var result = builder.UseOpenTelemetry(configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
Assert.IsType<OpenTelemetryAgent>(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<OpenTelemetryAgent>(result);
}
/// <summary>
/// Verify that UseOpenTelemetry returns the same builder instance for chaining.
/// </summary>
[Fact]
public void UseOpenTelemetry_ReturnsBuilderForChaining()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
// Act
var result = builder.UseOpenTelemetry();
// Assert
Assert.Same(builder, result);
}
/// <summary>
/// Verify that UseOpenTelemetry with all parameters works correctly.
/// </summary>
[Fact]
public void UseOpenTelemetry_WithAllParameters_WorksCorrectly()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
using var loggerFactory = LoggerFactory.Create(builder => { });
var builder = new AIAgentBuilder(mockAgent.Object);
const string SourceName = "TestSource";
var configureWasCalled = false;
// Act
var result = builder.UseOpenTelemetry(
sourceName: SourceName,
configure: agent =>
{
configureWasCalled = true;
Assert.NotNull(agent);
}).Build();
// Assert
Assert.True(configureWasCalled);
Assert.IsType<OpenTelemetryAgent>(result);
}
}

View File

@@ -0,0 +1,611 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenTelemetry.Trace;
#pragma warning disable CA1861 // Avoid constant arrays as arguments
#pragma warning disable RCS1186 // Use Regex instance instead of static method
namespace Microsoft.Agents.AI.UnitTests;
public class OpenTelemetryAgentTests
{
[Fact]
public void Ctor_InvalidArgs_Throws()
{
Assert.Throws<ArgumentNullException>(() => new OpenTelemetryAgent(null!));
}
[Fact]
public void Ctor_NullSourceName_Valid()
{
using var agent = new OpenTelemetryAgent(new TestAIAgent(), null);
Assert.NotNull(agent);
}
[Fact]
public void Properties_DelegateToInnerAgent()
{
TestAIAgent innerAgent = new()
{
NameFunc = () => "TestAgent",
DescriptionFunc = () => "This is a test agent.",
};
using var agent = new OpenTelemetryAgent(innerAgent, "MySource");
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("This is a test agent.", agent.Description);
Assert.Equal(innerAgent.Id, agent.Id);
}
[Fact]
public void EnableSensitiveData_Roundtrips()
{
using var agent = new OpenTelemetryAgent(new TestAIAgent(), "MySource");
for (int i = 0; i < 2; i++)
{
Assert.False(agent.EnableSensitiveData);
agent.EnableSensitiveData = true;
Assert.True(agent.EnableSensitiveData);
agent.EnableSensitiveData = false;
}
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task WithoutChatOptions_ExpectedInformationLogged_Async(bool enableSensitiveData, bool streaming)
{
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var innerAgent = new TestAIAgent
{
NameFunc = () => "TestAgent",
DescriptionFunc = () => "This is a test agent.",
RunAsyncFunc = async (messages, thread, options, cancellationToken) =>
{
await Task.Yield();
return new AgentResponse(new ChatMessage(ChatRole.Assistant, "The blue whale, I think."))
{
ResponseId = "id123",
Usage = new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 20,
TotalTokenCount = 42,
},
AdditionalProperties = new()
{
["system_fingerprint"] = "abcdefgh",
["AndSomethingElse"] = "value2",
},
};
},
RunStreamingAsyncFunc = CallbackAsync,
GetServiceFunc = (serviceType, serviceKey) =>
serviceType == typeof(AIAgentMetadata) ? new AIAgentMetadata("TestAgentProviderFromAIAgentMetadata") :
serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("TestAgentProviderFromChatClientMetadata", new Uri("http://localhost:12345/something"), "amazingmodel") :
null,
};
async static IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
foreach (string text in new[] { "The ", "blue ", "whale,", " ", "", "I", " think." })
{
await Task.Yield();
yield return new AgentResponseUpdate(ChatRole.Assistant, text)
{
ResponseId = "id123",
};
}
yield return new AgentResponseUpdate
{
Contents = [new UsageContent(new()
{
InputTokenCount = 10,
OutputTokenCount = 20,
TotalTokenCount = 42,
})],
AdditionalProperties = new()
{
["system_fingerprint"] = "abcdefgh",
["AndSomethingElse"] = "value2",
},
};
}
using var agent = new OpenTelemetryAgent(innerAgent, sourceName) { EnableSensitiveData = enableSensitiveData };
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a close friend."),
new(ChatRole.User, "Hey!"),
new(ChatRole.Assistant, [new FunctionCallContent("12345", "GetPersonName")]),
new(ChatRole.Tool, [new FunctionResultContent("12345", "John")]),
new(ChatRole.Assistant, "Hey John, what's up?"),
new(ChatRole.User, "What's the biggest animal?")
];
if (streaming)
{
await foreach (var update in agent.RunStreamingAsync(messages))
{
await Task.Yield();
}
}
else
{
await agent.RunAsync(messages);
}
var activity = Assert.Single(activities);
Assert.NotNull(activity.Id);
Assert.NotEmpty(activity.Id);
Assert.Equal("localhost", activity.GetTagItem("server.address"));
Assert.Equal(12345, (int)activity.GetTagItem("server.port")!);
Assert.Equal($"invoke_agent {agent.Name}({agent.Id})", activity.DisplayName);
Assert.Equal("invoke_agent", activity.GetTagItem("gen_ai.operation.name"));
Assert.Equal("TestAgentProviderFromAIAgentMetadata", activity.GetTagItem("gen_ai.provider.name"));
Assert.Equal(innerAgent.Name, activity.GetTagItem("gen_ai.agent.name"));
Assert.Equal(innerAgent.Id, activity.GetTagItem("gen_ai.agent.id"));
Assert.Equal(innerAgent.Description, activity.GetTagItem("gen_ai.agent.description"));
Assert.Equal("amazingmodel", activity.GetTagItem("gen_ai.request.model"));
Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id"));
Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens"));
Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens"));
Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("system_fingerprint"));
Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("AndSomethingElse"));
Assert.True(activity.Duration.TotalMilliseconds > 0);
var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
if (enableSensitiveData)
{
Assert.Equal(ReplaceWhitespace("""
[
{
"role": "system",
"parts": [
{
"type": "text",
"content": "You are a close friend."
}
]
},
{
"role": "user",
"parts": [
{
"type": "text",
"content": "Hey!"
}
]
},
{
"role": "assistant",
"parts": [
{
"type": "tool_call",
"id": "12345",
"name": "GetPersonName"
}
]
},
{
"role": "tool",
"parts": [
{
"type": "tool_call_response",
"id": "12345",
"response": "John"
}
]
},
{
"role": "assistant",
"parts": [
{
"type": "text",
"content": "Hey John, what's up?"
}
]
},
{
"role": "user",
"parts": [
{
"type": "text",
"content": "What's the biggest animal?"
}
]
}
]
"""), ReplaceWhitespace(tags["gen_ai.input.messages"]));
Assert.Equal(ReplaceWhitespace("""
[
{
"role": "assistant",
"parts": [
{
"type": "text",
"content": "The blue whale, I think."
}
]
}
]
"""), ReplaceWhitespace(tags["gen_ai.output.messages"]));
}
else
{
Assert.False(tags.ContainsKey("gen_ai.input.messages"));
Assert.False(tags.ContainsKey("gen_ai.output.messages"));
}
Assert.False(tags.ContainsKey("gen_ai.system_instructions"));
Assert.False(tags.ContainsKey("gen_ai.tool.definitions"));
}
public static IEnumerable<object[]> WithChatOptions_ExpectedInformationLogged_Async_MemberData() =>
from enableSensitiveData in new[] { false, true }
from streaming in new[] { false, true }
from name in new[] { null, "TestAgent" }
from description in new[] { null, "This is a test agent." }
select new object[] { enableSensitiveData, streaming, name, description, true };
[Theory]
[MemberData(nameof(WithChatOptions_ExpectedInformationLogged_Async_MemberData))]
[InlineData(true, false, "TestAgent", "This is a test agent.", false)]
[InlineData(true, true, "TestAgent", "This is a test agent.", false)]
public async Task WithChatOptions_ExpectedInformationLogged_Async(
bool enableSensitiveData, bool streaming, string name, string description, bool hasListener)
{
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
var builder = OpenTelemetry.Sdk.CreateTracerProviderBuilder();
if (hasListener)
{
builder.AddSource(sourceName);
}
using var tracerProvider = builder
.AddInMemoryExporter(activities)
.Build();
var innerAgent = new TestAIAgent
{
NameFunc = () => name,
DescriptionFunc = () => description,
RunAsyncFunc = async (messages, thread, options, cancellationToken) =>
{
await Task.Yield();
return new AgentResponse(new ChatMessage(ChatRole.Assistant, "The blue whale, I think."))
{
ResponseId = "id123",
Usage = new UsageDetails
{
InputTokenCount = 10,
OutputTokenCount = 20,
TotalTokenCount = 42,
},
AdditionalProperties = new()
{
["system_fingerprint"] = "abcdefgh",
["AndSomethingElse"] = "value2",
},
};
},
RunStreamingAsyncFunc = CallbackAsync,
GetServiceFunc = (serviceType, serviceKey) =>
serviceType == typeof(AIAgentMetadata) ? new AIAgentMetadata("TestAgentProviderFromAIAgentMetadata") :
serviceType == typeof(ChatClientMetadata) ? new ChatClientMetadata("TestAgentProviderFromChatClientMetadata", new Uri("http://localhost:12345/something"), "amazingmodel") :
null,
};
async static IAsyncEnumerable<AgentResponseUpdate> CallbackAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread, AgentRunOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
foreach (string text in new[] { "The ", "blue ", "whale,", " ", "", "I", " think." })
{
await Task.Yield();
yield return new AgentResponseUpdate(ChatRole.Assistant, text)
{
ResponseId = "id123",
};
}
yield return new AgentResponseUpdate
{
Contents = [new UsageContent(new()
{
InputTokenCount = 10,
OutputTokenCount = 20,
TotalTokenCount = 42,
})],
AdditionalProperties = new()
{
["system_fingerprint"] = "abcdefgh",
["AndSomethingElse"] = "value2",
},
};
}
using var agent = new OpenTelemetryAgent(innerAgent, sourceName) { EnableSensitiveData = enableSensitiveData };
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a close friend."),
new(ChatRole.User, "Hey!"),
new(ChatRole.Assistant, [new FunctionCallContent("12345", "GetPersonName")]),
new(ChatRole.Tool, [new FunctionResultContent("12345", "John")]),
new(ChatRole.Assistant, "Hey John, what's up?"),
new(ChatRole.User, "What's the biggest animal?")
];
var options = new ChatClientAgentRunOptions()
{
ChatOptions = new ChatOptions
{
FrequencyPenalty = 3.0f,
MaxOutputTokens = 123,
ModelId = "replacementmodel",
TopP = 4.0f,
TopK = 7,
PresencePenalty = 5.0f,
ResponseFormat = ChatResponseFormat.Json,
Temperature = 6.0f,
Seed = 42,
StopSequences = ["hello", "world"],
AdditionalProperties = new()
{
["service_tier"] = "value1",
["SomethingElse"] = "value2",
},
Instructions = "You are helpful.",
Tools =
[
AIFunctionFactory.Create((string personName) => personName, "GetPersonAge", "Gets the age of a person by name."),
new HostedWebSearchTool(),
AIFunctionFactory.Create((string location) => "", "GetCurrentWeather", "Gets the current weather for a location.").AsDeclarationOnly(),
],
}
};
if (streaming)
{
await foreach (var update in agent.RunStreamingAsync(messages, options: options))
{
await Task.Yield();
}
}
else
{
await agent.RunAsync(messages, options: options);
}
if (!hasListener)
{
Assert.Empty(activities);
return;
}
var activity = Assert.Single(activities);
var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
Assert.NotNull(activity.Id);
Assert.NotEmpty(activity.Id);
Assert.Equal("localhost", activity.GetTagItem("server.address"));
Assert.Equal(12345, (int)activity.GetTagItem("server.port")!);
if (string.IsNullOrWhiteSpace(innerAgent.Name))
{
Assert.Equal($"invoke_agent {innerAgent.Id}", activity.DisplayName);
}
else
{
Assert.Equal($"invoke_agent {innerAgent.Name}({innerAgent.Id})", activity.DisplayName);
}
Assert.Equal("invoke_agent", activity.GetTagItem("gen_ai.operation.name"));
Assert.Equal("TestAgentProviderFromAIAgentMetadata", activity.GetTagItem("gen_ai.provider.name"));
Assert.Equal(innerAgent.Name, activity.GetTagItem("gen_ai.agent.name"));
Assert.Equal(innerAgent.Id, activity.GetTagItem("gen_ai.agent.id"));
if (description is null)
{
Assert.False(tags.ContainsKey("gen_ai.agent.description"));
}
else
{
Assert.Equal(innerAgent.Description, activity.GetTagItem("gen_ai.agent.description"));
}
Assert.Equal("replacementmodel", activity.GetTagItem("gen_ai.request.model"));
Assert.Equal(3.0f, activity.GetTagItem("gen_ai.request.frequency_penalty"));
Assert.Equal(4.0f, activity.GetTagItem("gen_ai.request.top_p"));
Assert.Equal(5.0f, activity.GetTagItem("gen_ai.request.presence_penalty"));
Assert.Equal(6.0f, activity.GetTagItem("gen_ai.request.temperature"));
Assert.Equal(7, activity.GetTagItem("gen_ai.request.top_k"));
Assert.Equal(123, activity.GetTagItem("gen_ai.request.max_tokens"));
Assert.Equal("""["hello", "world"]""", activity.GetTagItem("gen_ai.request.stop_sequences"));
Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier"));
Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse"));
Assert.Equal(42L, activity.GetTagItem("gen_ai.request.seed"));
Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id"));
Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens"));
Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens"));
Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("system_fingerprint"));
Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("AndSomethingElse"));
Assert.True(activity.Duration.TotalMilliseconds > 0);
if (enableSensitiveData)
{
Assert.Equal(ReplaceWhitespace("""
[
{
"role": "system",
"parts": [
{
"type": "text",
"content": "You are a close friend."
}
]
},
{
"role": "user",
"parts": [
{
"type": "text",
"content": "Hey!"
}
]
},
{
"role": "assistant",
"parts": [
{
"type": "tool_call",
"id": "12345",
"name": "GetPersonName"
}
]
},
{
"role": "tool",
"parts": [
{
"type": "tool_call_response",
"id": "12345",
"response": "John"
}
]
},
{
"role": "assistant",
"parts": [
{
"type": "text",
"content": "Hey John, what's up?"
}
]
},
{
"role": "user",
"parts": [
{
"type": "text",
"content": "What's the biggest animal?"
}
]
}
]
"""), ReplaceWhitespace(tags["gen_ai.input.messages"]));
Assert.Equal(ReplaceWhitespace("""
[
{
"role": "assistant",
"parts": [
{
"type": "text",
"content": "The blue whale, I think."
}
]
}
]
"""), ReplaceWhitespace(tags["gen_ai.output.messages"]));
Assert.Equal(ReplaceWhitespace("""
[
{
"type": "text",
"content": "You are helpful."
}
]
"""), ReplaceWhitespace(tags["gen_ai.system_instructions"]));
Assert.Equal(ReplaceWhitespace("""
[
{
"type": "function",
"name": "GetPersonAge",
"description": "Gets the age of a person by name.",
"parameters": {
"type": "object",
"properties": {
"personName": {
"type": "string"
}
},
"required": [
"personName"
]
}
},
{
"type": "web_search"
},
{
"type": "function",
"name": "GetCurrentWeather",
"description": "Gets the current weather for a location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string"
}
},
"required": [
"location"
]
}
}
]
"""), ReplaceWhitespace(tags["gen_ai.tool.definitions"]));
}
else
{
Assert.False(tags.ContainsKey("gen_ai.input.messages"));
Assert.False(tags.ContainsKey("gen_ai.output.messages"));
Assert.False(tags.ContainsKey("gen_ai.system_instructions"));
Assert.False(tags.ContainsKey("gen_ai.tool.definitions"));
}
}
private static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", "").Trim();
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
internal sealed class TestAIAgent : AIAgent
{
public Func<string>? NameFunc;
public Func<string>? DescriptionFunc;
public Func<JsonElement, JsonSerializerOptions?, AgentThread> DeserializeThreadFunc = delegate { throw new NotSupportedException(); };
public Func<AgentThread> GetNewThreadFunc = delegate { throw new NotSupportedException(); };
public Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, Task<AgentResponse>> RunAsyncFunc = delegate { throw new NotSupportedException(); };
public Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, IAsyncEnumerable<AgentResponseUpdate>> RunStreamingAsyncFunc = delegate { throw new NotSupportedException(); };
public Func<Type, object?, object?>? GetServiceFunc;
public override string? Name => this.NameFunc?.Invoke() ?? base.Name;
public override string? Description => this.DescriptionFunc?.Invoke() ?? base.Description;
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
new(this.DeserializeThreadFunc(serializedThread, jsonSerializerOptions));
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default) =>
new(this.GetNewThreadFunc());
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunAsyncFunc(messages, thread, options, cancellationToken);
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunStreamingAsyncFunc(messages, thread, options, cancellationToken);
public override object? GetService(Type serviceType, object? serviceKey = null) =>
this.GetServiceFunc is { } func ? func(serviceType, serviceKey) :
base.GetService(serviceType, serviceKey);
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.UnitTests;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UseStringEnumConverter = true)]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
internal sealed partial class TestJsonSerializerContext : JsonSerializerContext;