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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" } }));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
400
dotnet/tests/Microsoft.Agents.AI.UnitTests/LoggingAgentTests.cs
Normal file
400
dotnet/tests/Microsoft.Agents.AI.UnitTests/LoggingAgentTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
42
dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs
Normal file
42
dotnet/tests/Microsoft.Agents.AI.UnitTests/TestAIAgent.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user