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:
17
dotnet/tests/.editorconfig
Normal file
17
dotnet/tests/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Suppressing errors for Test projects under dotnet/tests folder
|
||||
[*.cs]
|
||||
dotnet_diagnostic.CA1822.severity = none # Member does not access instance data and can be marked as static
|
||||
dotnet_diagnostic.CA1873.severity = none # Evaluation of logging arguments may be expensive
|
||||
dotnet_diagnostic.CA1875.severity = none # Regex.IsMatch/Count instead of Regex.Match(...).Success/Regex.Matches(...).Count
|
||||
dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task
|
||||
dotnet_diagnostic.CA2249.severity = none # Use `string.Contains` instead of `string.IndexOf` to improve readability
|
||||
|
||||
dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member
|
||||
|
||||
dotnet_diagnostic.IDE1006.severity = warning # Naming rule violations
|
||||
|
||||
dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave
|
||||
|
||||
dotnet_diagnostic.MEAI001.severity = none # [Experimental] APIs in Microsoft.Extensions.AI
|
||||
dotnet_diagnostic.OPENAI001.severity = none # [Experimental] APIs in OpenAI
|
||||
dotnet_diagnostic.SKEXP0110.severity = none # [Experimental] APIs in Microsoft.SemanticKernel
|
||||
1
dotnet/tests/.gitignore
vendored
Normal file
1
dotnet/tests/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
launchSettings.json
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsTestProject>false</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
25
dotnet/tests/AgentConformance.IntegrationTests/AgentTests.cs
Normal file
25
dotnet/tests/AgentConformance.IntegrationTests/AgentTests.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AgentConformance.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for all test classes used for testing agents.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAgentFixture">The type of the agent fixture used in these tests.</typeparam>
|
||||
/// <param name="createAgentFixture">Used to create a new fixture for this test suite.</param>
|
||||
public abstract class AgentTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : IAsyncLifetime
|
||||
where TAgentFixture : IAgentFixture
|
||||
{
|
||||
protected TAgentFixture Fixture { get; private set; } = default!;
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
this.Fixture = createAgentFixture();
|
||||
return this.Fixture.InitializeAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => this.Fixture.DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AgentConformance.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests that are specific to the <see cref="ChatClientAgent"/> in addition to those in <see cref="RunStreamingTests{TAgentFixture}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
|
||||
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
|
||||
public abstract class ChatClientAgentRunStreamingTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
|
||||
where TAgentFixture : IChatClientAgentFixture
|
||||
{
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = await this.Fixture.CreateChatClientAgentAsync(instructions: "Always respond with 'Computer says no', even if there was no user input.");
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var agentCleanup = new AgentCleanup(agent, this.Fixture);
|
||||
await using var threadCleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var responseUpdates = await agent.RunStreamingAsync(thread).ToListAsync();
|
||||
|
||||
// Assert
|
||||
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
|
||||
Assert.Contains("Computer says no", chatResponseText, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var questionsAndAnswers = new[]
|
||||
{
|
||||
(Question: "Hello", ExpectedAnswer: string.Empty),
|
||||
(Question: "What is the special soup?", ExpectedAnswer: "Clam Chowder"),
|
||||
(Question: "What is the special drink?", ExpectedAnswer: "Chai Tea"),
|
||||
(Question: "What is the special salad?", ExpectedAnswer: "Cobb Salad"),
|
||||
(Question: "Thank you", ExpectedAnswer: string.Empty)
|
||||
};
|
||||
|
||||
var agent = await this.Fixture.CreateChatClientAgentAsync(
|
||||
aiTools:
|
||||
[
|
||||
AIFunctionFactory.Create(MenuPlugin.GetSpecials),
|
||||
AIFunctionFactory.Create(MenuPlugin.GetItemPrice)
|
||||
]);
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
|
||||
foreach (var questionAndAnswer in questionsAndAnswers)
|
||||
{
|
||||
// Act
|
||||
var responseUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, questionAndAnswer.Question),
|
||||
thread).ToListAsync();
|
||||
|
||||
// Assert
|
||||
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
|
||||
Assert.Contains(questionAndAnswer.ExpectedAnswer, chatResponseText, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AgentConformance.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests that are specific to the <see cref="ChatClientAgent"/> in addition to those in <see cref="RunTests{TAgentFixture}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
|
||||
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
|
||||
public abstract class ChatClientAgentRunTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
|
||||
where TAgentFixture : IChatClientAgentFixture
|
||||
{
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = await this.Fixture.CreateChatClientAgentAsync(instructions: "ALWAYS RESPOND WITH 'Computer says no', even if there was no user input.");
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var agentCleanup = new AgentCleanup(agent, this.Fixture);
|
||||
await using var threadCleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(thread);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Single(response.Messages);
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text), "Agent should return non-empty response even without user input");
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var questionsAndAnswers = new[]
|
||||
{
|
||||
(Question: "Hello", ExpectedAnswer: string.Empty),
|
||||
(Question: "What is the special soup?", ExpectedAnswer: "Clam Chowder"),
|
||||
(Question: "What is the special drink?", ExpectedAnswer: "Chai Tea"),
|
||||
(Question: "What is the special salad?", ExpectedAnswer: "Cobb Salad"),
|
||||
(Question: "Thank you", ExpectedAnswer: string.Empty)
|
||||
};
|
||||
|
||||
var agent = await this.Fixture.CreateChatClientAgentAsync(
|
||||
aiTools:
|
||||
[
|
||||
AIFunctionFactory.Create(MenuPlugin.GetSpecials),
|
||||
AIFunctionFactory.Create(MenuPlugin.GetItemPrice)
|
||||
]);
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
|
||||
foreach (var questionAndAnswer in questionsAndAnswers)
|
||||
{
|
||||
// Act
|
||||
var result = await agent.RunAsync(
|
||||
new ChatMessage(ChatRole.User, questionAndAnswer.Question),
|
||||
thread);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(questionAndAnswer.ExpectedAnswer, result.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AgentConformance.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for setting up and tearing down agents, to be used in tests.
|
||||
/// Each agent type should have its own derived class.
|
||||
/// </summary>
|
||||
public interface IAgentFixture : IAsyncLifetime
|
||||
{
|
||||
AIAgent Agent { get; }
|
||||
|
||||
Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread);
|
||||
|
||||
Task DeleteThreadAsync(AgentThread thread);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AgentConformance.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for setting up and tearing down <see cref="IChatClient"/> based agents, to be used in tests.
|
||||
/// Each agent type should have its own derived class.
|
||||
/// </summary>
|
||||
public interface IChatClientAgentFixture : IAgentFixture
|
||||
{
|
||||
IChatClient ChatClient { get; }
|
||||
|
||||
Task<ChatClientAgent> CreateChatClientAgentAsync(
|
||||
string name = "HelpfulAssistant",
|
||||
string instructions = "You are a helpful assistant.",
|
||||
IList<AITool>? aiTools = null);
|
||||
|
||||
Task DeleteAgentAsync(ChatClientAgent agent);
|
||||
}
|
||||
25
dotnet/tests/AgentConformance.IntegrationTests/MenuPlugin.cs
Normal file
25
dotnet/tests/AgentConformance.IntegrationTests/MenuPlugin.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace AgentConformance.IntegrationTests;
|
||||
|
||||
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
|
||||
|
||||
/// <summary>
|
||||
/// A test plugin used to verify function invocation.
|
||||
/// </summary>
|
||||
internal static class MenuPlugin
|
||||
{
|
||||
[Description("Provides a list of specials from the menu.")]
|
||||
public static string GetSpecials() => """
|
||||
Special Soup: Clam Chowder
|
||||
Special Salad: Cobb Salad
|
||||
Special Drink: Chai Tea
|
||||
""";
|
||||
|
||||
[Description("Provides the price of the requested menu item.")]
|
||||
public static string GetItemPrice(
|
||||
[Description("The name of the menu item.")]
|
||||
string menuItem) => "$9.99";
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AgentConformance.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for run methods on agents.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
|
||||
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
|
||||
public abstract class RunStreamingTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
|
||||
where TAgentFixture : IAgentFixture
|
||||
{
|
||||
public virtual Func<Task<AgentRunOptions?>> AgentRunOptionsFactory { get; set; } = () => Task.FromResult(default(AgentRunOptions));
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithNoMessageDoesNotFailAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var chatResponses = await agent.RunStreamingAsync(thread, await this.AgentRunOptionsFactory.Invoke()).ToListAsync();
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithStringReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var responseUpdates = await agent.RunStreamingAsync("What is the capital of France.", thread, await this.AgentRunOptionsFactory.Invoke()).ToListAsync();
|
||||
|
||||
// Assert
|
||||
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
|
||||
Assert.Contains("Paris", chatResponseText);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithChatMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var responseUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "What is the capital of France."), thread, await this.AgentRunOptionsFactory.Invoke()).ToListAsync();
|
||||
|
||||
// Assert
|
||||
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
|
||||
Assert.Contains("Paris", chatResponseText);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithChatMessagesReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var responseUpdates = await agent.RunStreamingAsync(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello."),
|
||||
new ChatMessage(ChatRole.User, "What is the capital of France.")
|
||||
],
|
||||
thread,
|
||||
await this.AgentRunOptionsFactory.Invoke()).ToListAsync();
|
||||
|
||||
// Assert
|
||||
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
|
||||
Assert.Contains("Paris", chatResponseText);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task ThreadMaintainsHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string Q1 = "What is the capital of France.";
|
||||
const string Q2 = "And Austria?";
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var options = await this.AgentRunOptionsFactory.Invoke();
|
||||
var responseUpdates1 = await agent.RunStreamingAsync(Q1, thread, options).ToListAsync();
|
||||
var responseUpdates2 = await agent.RunStreamingAsync(Q2, thread, options).ToListAsync();
|
||||
|
||||
// Assert
|
||||
var response1Text = string.Concat(responseUpdates1.Select(x => x.Text));
|
||||
var response2Text = string.Concat(responseUpdates2.Select(x => x.Text));
|
||||
Assert.Contains("Paris", response1Text);
|
||||
Assert.Contains("Vienna", response2Text);
|
||||
|
||||
var chatHistory = await this.Fixture.GetChatHistoryAsync(thread);
|
||||
Assert.Equal(4, chatHistory.Count);
|
||||
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.User));
|
||||
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.Assistant));
|
||||
Assert.Equal(Q1, chatHistory[0].Text);
|
||||
Assert.Equal(Q2, chatHistory[2].Text);
|
||||
Assert.Contains("Paris", chatHistory[1].Text);
|
||||
Assert.Contains("Vienna", chatHistory[3].Text);
|
||||
}
|
||||
}
|
||||
123
dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs
Normal file
123
dotnet/tests/AgentConformance.IntegrationTests/RunTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AgentConformance.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for run methods on agents.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
|
||||
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
|
||||
public abstract class RunTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
|
||||
where TAgentFixture : IAgentFixture
|
||||
{
|
||||
public virtual Func<Task<AgentRunOptions?>> AgentRunOptionsFactory { get; set; } = () => Task.FromResult(default(AgentRunOptions));
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithNoMessageDoesNotFailAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var chatResponse = await agent.RunAsync(thread);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(chatResponse);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithStringReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("What is the capital of France.", thread, await this.AgentRunOptionsFactory.Invoke());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Contains("Paris", response.Text);
|
||||
Assert.Equal(agent.Id, response.AgentId);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithChatMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "What is the capital of France."), thread, await this.AgentRunOptionsFactory.Invoke());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Contains("Paris", response.Text);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task RunWithChatMessagesReturnsExpectedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello."),
|
||||
new ChatMessage(ChatRole.User, "What is the capital of France.")
|
||||
],
|
||||
thread,
|
||||
await this.AgentRunOptionsFactory.Invoke());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Contains("Paris", response.Text);
|
||||
}
|
||||
|
||||
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
|
||||
public virtual async Task ThreadMaintainsHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string Q1 = "What is the capital of France.";
|
||||
const string Q2 = "And Austria?";
|
||||
var agent = this.Fixture.Agent;
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await using var cleanup = new ThreadCleanup(thread, this.Fixture);
|
||||
|
||||
// Act
|
||||
var options = await this.AgentRunOptionsFactory.Invoke();
|
||||
var result1 = await agent.RunAsync(Q1, thread, options);
|
||||
var result2 = await agent.RunAsync(Q2, thread, options);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Paris", result1.Text);
|
||||
Assert.Contains("Vienna", result2.Text);
|
||||
|
||||
var chatHistory = await this.Fixture.GetChatHistoryAsync(thread);
|
||||
Assert.Equal(4, chatHistory.Count);
|
||||
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.User));
|
||||
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.Assistant));
|
||||
Assert.Equal(Q1, chatHistory[0].Text);
|
||||
Assert.Contains("Paris", chatHistory[1].Text);
|
||||
Assert.Equal(Q2, chatHistory[2].Text);
|
||||
Assert.Contains("Vienna", chatHistory[3].Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace AgentConformance.IntegrationTests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class to delete agents after tests.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to delete.</param>
|
||||
/// <param name="fixture">The fixture that provides agent specific capabilities.</param>
|
||||
internal sealed class AgentCleanup(ChatClientAgent agent, IChatClientAgentFixture fixture) : IAsyncDisposable
|
||||
{
|
||||
public async ValueTask DisposeAsync() =>
|
||||
await fixture.DeleteAgentAsync(agent);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AgentConformance.IntegrationTests.Support;
|
||||
|
||||
internal static class Constants
|
||||
{
|
||||
public const int RetryCount = 3;
|
||||
public const int RetryDelay = 5000;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace AgentConformance.IntegrationTests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for loading test configuration settings.
|
||||
/// </summary>
|
||||
public sealed class TestConfiguration
|
||||
{
|
||||
private static readonly IConfiguration s_configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile(path: "testsettings.json", optional: true)
|
||||
.AddJsonFile(path: "testsettings.development.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.AddUserSecrets<TestConfiguration>()
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Loads the type of configuration using a section name based on the type name.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of config to load.</typeparam>
|
||||
/// <returns>The loaded configuration section of the specified type.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the configuration section cannot be loaded.</exception>
|
||||
public static T LoadSection<T>()
|
||||
{
|
||||
var configType = typeof(T);
|
||||
var configTypeName = configType.Name;
|
||||
|
||||
const string TrimText = "Configuration";
|
||||
if (configTypeName.EndsWith(TrimText, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
configTypeName = configTypeName.Substring(0, configTypeName.Length - TrimText.Length);
|
||||
}
|
||||
|
||||
return s_configuration.GetRequiredSection(configTypeName).Get<T>() ??
|
||||
throw new InvalidOperationException($"Could not load config for {configTypeName}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace AgentConformance.IntegrationTests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class to delete threads after tests.
|
||||
/// </summary>
|
||||
/// <param name="thread">The thread to delete.</param>
|
||||
/// <param name="fixture">The fixture that provides agent specific capabilities.</param>
|
||||
internal sealed class ThreadCleanup(AgentThread thread, IAgentFixture fixture) : IAsyncDisposable
|
||||
{
|
||||
public async ValueTask DisposeAsync() =>
|
||||
await fixture.DeleteThreadAsync(thread);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Anthropic\Microsoft.Agents.AI.Anthropic.csproj" />
|
||||
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AnthropicChatCompletion.IntegrationTests;
|
||||
|
||||
public abstract class SkipAllChatClientRunStreaming(Func<AnthropicChatCompletionFixture> func) : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(func)
|
||||
{
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync()
|
||||
=> base.RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
=> base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
|
||||
public class AnthropicBetaChatCompletionChatClientAgentRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
|
||||
public class AnthropicChatCompletionChatClientAgentRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
|
||||
public class AnthropicChatCompletionChatClientAgentReasoningRunStreamingTests() : SkipAllChatClientRunStreaming(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AnthropicChatCompletion.IntegrationTests;
|
||||
|
||||
public abstract class SkipAllChatClientAgentRun(Func<AnthropicChatCompletionFixture> func) : ChatClientAgentRunTests<AnthropicChatCompletionFixture>(func)
|
||||
{
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync()
|
||||
=> base.RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
=> base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
|
||||
}
|
||||
|
||||
public class AnthropicBetaChatCompletionChatClientAgentRunTests()
|
||||
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
|
||||
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunTests()
|
||||
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
|
||||
public class AnthropicChatCompletionChatClientAgentRunTests()
|
||||
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
|
||||
public class AnthropicChatCompletionChatClientAgentReasoningRunTests()
|
||||
: SkipAllChatClientAgentRun(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Anthropic;
|
||||
using Anthropic.Models.Beta.Messages;
|
||||
using Anthropic.Models.Messages;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace AnthropicChatCompletion.IntegrationTests;
|
||||
|
||||
public class AnthropicChatCompletionFixture : IChatClientAgentFixture
|
||||
{
|
||||
// All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup.
|
||||
internal const string SkipReason = "Integrations tests for local execution only";
|
||||
|
||||
private static readonly AnthropicConfiguration s_config = TestConfiguration.LoadSection<AnthropicConfiguration>();
|
||||
private readonly bool _useReasoningModel;
|
||||
private readonly bool _useBeta;
|
||||
|
||||
private ChatClientAgent _agent = null!;
|
||||
|
||||
public AnthropicChatCompletionFixture(bool useReasoningChatModel, bool useBeta)
|
||||
{
|
||||
this._useReasoningModel = useReasoningChatModel;
|
||||
this._useBeta = useBeta;
|
||||
}
|
||||
|
||||
public AIAgent Agent => this._agent;
|
||||
|
||||
public IChatClient ChatClient => this._agent.ChatClient;
|
||||
|
||||
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread)
|
||||
{
|
||||
var typedThread = (ChatClientAgentThread)thread;
|
||||
|
||||
if (typedThread.MessageStore is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await typedThread.MessageStore.InvokingAsync(new([]))).ToList();
|
||||
}
|
||||
|
||||
public Task<ChatClientAgent> CreateChatClientAgentAsync(
|
||||
string name = "HelpfulAssistant",
|
||||
string instructions = "You are a helpful assistant.",
|
||||
IList<AITool>? aiTools = null)
|
||||
{
|
||||
var anthropicClient = new AnthropicClient() { APIKey = s_config.ApiKey };
|
||||
|
||||
IChatClient? chatClient = this._useBeta
|
||||
? anthropicClient
|
||||
.Beta
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.ConfigureOptions(options
|
||||
=> options.RawRepresentationFactory = _
|
||||
=> new Anthropic.Models.Beta.Messages.MessageCreateParams()
|
||||
{
|
||||
Model = options.ModelId ?? (this._useReasoningModel ? s_config.ChatReasoningModelId : s_config.ChatModelId),
|
||||
MaxTokens = options.MaxOutputTokens ?? 4096,
|
||||
Messages = [],
|
||||
Thinking = this._useReasoningModel
|
||||
? new BetaThinkingConfigParam(new BetaThinkingConfigEnabled(2048))
|
||||
: new BetaThinkingConfigParam(new BetaThinkingConfigDisabled())
|
||||
}).Build()
|
||||
|
||||
: anthropicClient
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.ConfigureOptions(options
|
||||
=> options.RawRepresentationFactory = _
|
||||
=> new Anthropic.Models.Messages.MessageCreateParams()
|
||||
{
|
||||
Model = options.ModelId ?? (this._useReasoningModel ? s_config.ChatReasoningModelId : s_config.ChatModelId),
|
||||
MaxTokens = options.MaxOutputTokens ?? 4096,
|
||||
Messages = [],
|
||||
Thinking = this._useReasoningModel
|
||||
? new ThinkingConfigParam(new ThinkingConfigEnabled(2048))
|
||||
: new ThinkingConfigParam(new ThinkingConfigDisabled())
|
||||
}).Build();
|
||||
|
||||
return Task.FromResult(new ChatClientAgent(chatClient, options: new()
|
||||
{
|
||||
Name = name,
|
||||
ChatOptions = new() { Instructions = instructions, Tools = aiTools }
|
||||
}));
|
||||
}
|
||||
|
||||
public Task DeleteAgentAsync(ChatClientAgent agent) =>
|
||||
// Chat Completion does not require/support deleting agents, so this is a no-op.
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task DeleteThreadAsync(AgentThread thread) =>
|
||||
// Chat Completion does not require/support deleting threads, so this is a no-op.
|
||||
Task.CompletedTask;
|
||||
|
||||
public async Task InitializeAsync() =>
|
||||
this._agent = await this.CreateChatClientAgentAsync();
|
||||
|
||||
public Task DisposeAsync() =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AnthropicChatCompletion.IntegrationTests;
|
||||
|
||||
public abstract class SkipAllRunStreaming(Func<AnthropicChatCompletionFixture> func) : RunStreamingTests<AnthropicChatCompletionFixture>(func)
|
||||
{
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithChatMessageReturnsExpectedResultAsync() => base.RunWithChatMessageReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync() => base.RunWithNoMessageDoesNotFailAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithChatMessagesReturnsExpectedResultAsync() => base.RunWithChatMessagesReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithStringReturnsExpectedResultAsync() => base.RunWithStringReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task ThreadMaintainsHistoryAsync() => base.ThreadMaintainsHistoryAsync();
|
||||
}
|
||||
|
||||
public class AnthropicBetaChatCompletionRunStreamingTests()
|
||||
: SkipAllRunStreaming(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
|
||||
public class AnthropicBetaChatCompletionReasoningRunStreamingTests()
|
||||
: SkipAllRunStreaming(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
|
||||
public class AnthropicChatCompletionRunStreamingTests()
|
||||
: SkipAllRunStreaming(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
|
||||
public class AnthropicChatCompletionReasoningRunStreamingTests()
|
||||
: SkipAllRunStreaming(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AnthropicChatCompletion.IntegrationTests;
|
||||
|
||||
public abstract class SkipAllRun(Func<AnthropicChatCompletionFixture> func) : RunTests<AnthropicChatCompletionFixture>(func)
|
||||
{
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithChatMessageReturnsExpectedResultAsync() => base.RunWithChatMessageReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync() => base.RunWithNoMessageDoesNotFailAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithChatMessagesReturnsExpectedResultAsync() => base.RunWithChatMessagesReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task RunWithStringReturnsExpectedResultAsync() => base.RunWithStringReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = AnthropicChatCompletionFixture.SkipReason)]
|
||||
public override Task ThreadMaintainsHistoryAsync() => base.ThreadMaintainsHistoryAsync();
|
||||
}
|
||||
|
||||
public class AnthropicBetaChatCompletionRunTests()
|
||||
: SkipAllRun(() => new(useReasoningChatModel: false, useBeta: true));
|
||||
|
||||
public class AnthropicBetaChatCompletionReasoningRunTests()
|
||||
: SkipAllRun(() => new(useReasoningChatModel: true, useBeta: true));
|
||||
|
||||
public class AnthropicChatCompletionRunTests()
|
||||
: SkipAllRun(() => new(useReasoningChatModel: false, useBeta: false));
|
||||
|
||||
public class AnthropicChatCompletionReasoningRunTests()
|
||||
: SkipAllRun(() => new(useReasoningChatModel: true, useBeta: false));
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStreamingTests<AIProjectClientFixture>(() => new())
|
||||
{
|
||||
[Fact(Skip = "No messages is not supported")]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class AIProjectClientAgentRunStreamingConversationTests() : RunTests<AIProjectClientFixture>(() => new())
|
||||
{
|
||||
public override Func<Task<AgentRunOptions?>> AgentRunOptionsFactory => async () =>
|
||||
{
|
||||
var conversationId = await this.Fixture.CreateConversationAsync();
|
||||
return new ChatClientAgentRunOptions(new() { ConversationId = conversationId });
|
||||
};
|
||||
|
||||
[Fact(Skip = "No messages is not supported")]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientAgentRunPreviousResponseTests() : RunTests<AIProjectClientFixture>(() => new())
|
||||
{
|
||||
[Fact(Skip = "No messages is not supported")]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class AIProjectClientAgentRunConversationTests() : RunTests<AIProjectClientFixture>(() => new())
|
||||
{
|
||||
public override Func<Task<AgentRunOptions?>> AgentRunOptionsFactory => async () =>
|
||||
{
|
||||
var conversationId = await this.Fixture.CreateConversationAsync();
|
||||
return new ChatClientAgentRunOptions(new() { ConversationId = conversationId });
|
||||
};
|
||||
|
||||
[Fact(Skip = "No messages is not supported")]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AIProjectClientFixture>(() => new())
|
||||
{
|
||||
[Fact(Skip = "No messages is not supported")]
|
||||
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientChatClientAgentRunTests() : ChatClientAgentRunTests<AIProjectClientFixture>(() => new())
|
||||
{
|
||||
[Fact(Skip = "No messages is not supported")]
|
||||
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Files;
|
||||
using OpenAI.Responses;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientCreateTests
|
||||
{
|
||||
private static readonly AzureAIConfiguration s_config = TestConfiguration.LoadSection<AzureAIConfiguration>();
|
||||
private readonly AIProjectClient _client = new(new Uri(s_config.Endpoint), new AzureCliCredential());
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("IntegrationTestAgent");
|
||||
const string AgentDescription = "An agent created during integration tests";
|
||||
const string AgentInstructions = "You are an integration test agent";
|
||||
|
||||
// Act.
|
||||
var agent = createMechanism switch
|
||||
{
|
||||
"CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
model: s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
Name = AgentName,
|
||||
Description = AgentDescription,
|
||||
ChatOptions = new() { Instructions = AgentInstructions }
|
||||
}),
|
||||
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
name: AgentName,
|
||||
creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(s_config.DeploymentName) { Instructions = AgentInstructions }) { Description = AgentDescription }),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Assert.
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal(AgentName, agent.Name);
|
||||
Assert.Equal(AgentDescription, agent.Description);
|
||||
Assert.Equal(AgentInstructions, agent.Instructions);
|
||||
|
||||
var agentRecord = await this._client.Agents.GetAgentAsync(agent.Name);
|
||||
Assert.NotNull(agentRecord);
|
||||
Assert.Equal(AgentName, agentRecord.Value.Name);
|
||||
var definition = Assert.IsType<PromptAgentDefinition>(agentRecord.Value.Versions.Latest.Definition);
|
||||
Assert.Equal(AgentDescription, agentRecord.Value.Versions.Latest.Description);
|
||||
Assert.Equal(AgentInstructions, definition.Instructions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup.
|
||||
await this._client.Agents.DeleteAgentAsync(agent.Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory(Skip = "For manual testing only")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("VectorStoreAgent");
|
||||
const string AgentInstructions = """
|
||||
You are a helpful agent that can help fetch data from files you know about.
|
||||
Use the File Search Tool to look up codes for words.
|
||||
Do not answer a question unless you can find the answer using the File Search Tool.
|
||||
""";
|
||||
|
||||
// Get the project OpenAI client.
|
||||
var projectOpenAIClient = this._client.GetProjectOpenAIClient();
|
||||
|
||||
// Create a vector store.
|
||||
var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt";
|
||||
File.WriteAllText(
|
||||
path: searchFilePath,
|
||||
contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457."
|
||||
);
|
||||
OpenAIFile uploadedAgentFile = projectOpenAIClient.GetProjectFilesClient().UploadFile(
|
||||
filePath: searchFilePath,
|
||||
purpose: FileUploadPurpose.Assistants
|
||||
);
|
||||
var vectorStoreMetadata = await projectOpenAIClient.GetProjectVectorStoresClient().CreateVectorStoreAsync(options: new() { FileIds = { uploadedAgentFile.Id }, Name = "WordCodeLookup_VectorStore" });
|
||||
|
||||
// Act.
|
||||
var agent = createMechanism switch
|
||||
{
|
||||
"CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]),
|
||||
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Assert.
|
||||
// Verify that the agent can use the vector store to answer a question.
|
||||
var result = await agent.RunAsync("Can you give me the documented code for 'banana'?");
|
||||
Assert.Contains("673457", result.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup.
|
||||
await this._client.Agents.DeleteAgentAsync(agent.Name);
|
||||
await projectOpenAIClient.GetProjectVectorStoresClient().DeleteVectorStoreAsync(vectorStoreMetadata.Value.Id);
|
||||
await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedAgentFile.Id);
|
||||
File.Delete(searchFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("CodeInterpreterAgent");
|
||||
const string AgentInstructions = """
|
||||
You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file
|
||||
and report the SECRET_NUMBER value it prints. Respond only with the number.
|
||||
""";
|
||||
|
||||
// Get the project OpenAI client.
|
||||
var projectOpenAIClient = this._client.GetProjectOpenAIClient();
|
||||
|
||||
// Create a python file that prints a known value.
|
||||
var codeFilePath = Path.GetTempFileName() + "secret_number.py";
|
||||
File.WriteAllText(
|
||||
path: codeFilePath,
|
||||
contents: "print(\"SECRET_NUMBER=24601\")" // Deterministic output we will look for.
|
||||
);
|
||||
OpenAIFile uploadedCodeFile = projectOpenAIClient.GetProjectFilesClient().UploadFile(
|
||||
filePath: codeFilePath,
|
||||
purpose: FileUploadPurpose.Assistants
|
||||
);
|
||||
|
||||
// Act.
|
||||
var agent = createMechanism switch
|
||||
{
|
||||
// Hosted tool path (tools supplied via ChatClientAgentOptions)
|
||||
"CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]),
|
||||
// Foundry (definitions + resources provided directly)
|
||||
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Assert.
|
||||
var result = await agent.RunAsync("What is the SECRET_NUMBER?");
|
||||
// We expect the model to run the code and surface the number.
|
||||
Assert.Contains("24601", result.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup.
|
||||
await this._client.Agents.DeleteAgentAsync(agent.Name);
|
||||
await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedCodeFile.Id);
|
||||
File.Delete(codeFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("WeatherAgent");
|
||||
const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather.";
|
||||
|
||||
static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C.";
|
||||
var weatherFunction = AIFunctionFactory.Create(GetWeather);
|
||||
|
||||
ChatClientAgent agent = createMechanism switch
|
||||
{
|
||||
"CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
model: s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
Name = AgentName,
|
||||
ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] }
|
||||
}),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Act.
|
||||
var response = await agent.RunAsync("What is the weather like in Amsterdam?");
|
||||
|
||||
// Assert - ensure function was invoked and its output surfaced.
|
||||
var text = response.Text;
|
||||
Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._client.Agents.DeleteAgentAsync(agent.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
167
dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs
Normal file
167
dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace AzureAI.IntegrationTests;
|
||||
|
||||
public class AIProjectClientFixture : IChatClientAgentFixture
|
||||
{
|
||||
private static readonly AzureAIConfiguration s_config = TestConfiguration.LoadSection<AzureAIConfiguration>();
|
||||
|
||||
private ChatClientAgent _agent = null!;
|
||||
private AIProjectClient _client = null!;
|
||||
|
||||
public IChatClient ChatClient => this._agent.ChatClient;
|
||||
|
||||
public AIAgent Agent => this._agent;
|
||||
|
||||
public async Task<string> CreateConversationAsync()
|
||||
{
|
||||
var response = await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync();
|
||||
return response.Value.Id;
|
||||
}
|
||||
|
||||
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread)
|
||||
{
|
||||
var chatClientThread = (ChatClientAgentThread)thread;
|
||||
|
||||
if (chatClientThread.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
// Conversation threads do not persist message history.
|
||||
return await this.GetChatHistoryFromConversationAsync(chatClientThread.ConversationId);
|
||||
}
|
||||
|
||||
if (chatClientThread.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
return await this.GetChatHistoryFromResponsesChainAsync(chatClientThread.ConversationId);
|
||||
}
|
||||
|
||||
if (chatClientThread.MessageStore is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await chatClientThread.MessageStore.InvokingAsync(new([]))).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<ChatMessage>> GetChatHistoryFromResponsesChainAsync(string conversationId)
|
||||
{
|
||||
var openAIResponseClient = this._client.GetProjectOpenAIClient().GetProjectResponsesClient();
|
||||
var inputItems = await openAIResponseClient.GetResponseInputItemsAsync(conversationId).ToListAsync();
|
||||
var response = await openAIResponseClient.GetResponseAsync(conversationId);
|
||||
var responseItem = response.Value.OutputItems.FirstOrDefault()!;
|
||||
|
||||
// Take the messages that were the chat history leading up to the current response
|
||||
// remove the instruction messages, and reverse the order so that the most recent message is last.
|
||||
var previousMessages = inputItems
|
||||
.Select(ConvertToChatMessage)
|
||||
.Where(x => x.Text != "You are a helpful assistant.")
|
||||
.Reverse();
|
||||
|
||||
// Convert the response item to a chat message.
|
||||
var responseMessage = ConvertToChatMessage(responseItem);
|
||||
|
||||
// Concatenate the previous messages with the response message to get a full chat history
|
||||
// that includes the current response.
|
||||
return [.. previousMessages, responseMessage];
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertToChatMessage(ResponseItem item)
|
||||
{
|
||||
if (item is MessageResponseItem messageResponseItem)
|
||||
{
|
||||
var role = messageResponseItem.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant;
|
||||
return new ChatMessage(role, messageResponseItem.Content.FirstOrDefault()?.Text);
|
||||
}
|
||||
|
||||
throw new NotSupportedException("This test currently only supports text messages");
|
||||
}
|
||||
|
||||
private async Task<List<ChatMessage>> GetChatHistoryFromConversationAsync(string conversationId)
|
||||
{
|
||||
List<ChatMessage> messages = [];
|
||||
await foreach (AgentResponseItem item in this._client.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc"))
|
||||
{
|
||||
var openAIItem = item.AsResponseResultItem();
|
||||
if (openAIItem is MessageResponseItem messageItem)
|
||||
{
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = new ChatRole(messageItem.Role.ToString()),
|
||||
Contents = messageItem.Content
|
||||
.Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText)
|
||||
.Select(c => new TextContent(c.Text))
|
||||
.ToList<AIContent>()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
public async Task<ChatClientAgent> CreateChatClientAgentAsync(
|
||||
string name = "HelpfulAssistant",
|
||||
string instructions = "You are a helpful assistant.",
|
||||
IList<AITool>? aiTools = null)
|
||||
{
|
||||
return await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: s_config.DeploymentName, instructions: instructions, tools: aiTools);
|
||||
}
|
||||
|
||||
public static string GenerateUniqueAgentName(string baseName) =>
|
||||
$"{baseName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
|
||||
|
||||
public Task DeleteAgentAsync(ChatClientAgent agent) =>
|
||||
this._client.Agents.DeleteAgentAsync(agent.Name);
|
||||
|
||||
public async Task DeleteThreadAsync(AgentThread thread)
|
||||
{
|
||||
var typedThread = (ChatClientAgentThread)thread;
|
||||
if (typedThread.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(typedThread.ConversationId);
|
||||
}
|
||||
else if (typedThread.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
await this.DeleteResponseChainAsync(typedThread.ConversationId!);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteResponseChainAsync(string lastResponseId)
|
||||
{
|
||||
var response = await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().GetResponseAsync(lastResponseId);
|
||||
await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().DeleteResponseAsync(lastResponseId);
|
||||
|
||||
if (response.Value.PreviousResponseId is not null)
|
||||
{
|
||||
await this.DeleteResponseChainAsync(response.Value.PreviousResponseId);
|
||||
}
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
if (this._client is not null && this._agent is not null)
|
||||
{
|
||||
return this._client.Agents.DeleteAgentAsync(this._agent.Name);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
this._client = new(new Uri(s_config.Endpoint), new AzureCliCredential());
|
||||
this._agent = await this.CreateChatClientAgentAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AzureAIAgentsPersistentFixture>(() => new())
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsChatClientAgentRunTests() : ChatClientAgentRunTests<AzureAIAgentsPersistentFixture>(() => new())
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Agents.Persistent" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,277 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Agents.Persistent;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsPersistentCreateTests
|
||||
{
|
||||
private static readonly AzureAIConfiguration s_config = TestConfiguration.LoadSection<AzureAIConfiguration>();
|
||||
private readonly PersistentAgentsClient _persistentAgentsClient = new(s_config.Endpoint, new AzureCliCredential());
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
const string AgentName = "IntegrationTestAgent";
|
||||
const string AgentDescription = "An agent created during integration tests";
|
||||
const string AgentInstructions = "You are an integration test agent";
|
||||
|
||||
// Act.
|
||||
var agent = createMechanism switch
|
||||
{
|
||||
"CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new() { Instructions = AgentInstructions },
|
||||
Name = AgentName,
|
||||
Description = AgentDescription
|
||||
}),
|
||||
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
instructions: AgentInstructions,
|
||||
name: AgentName,
|
||||
description: AgentDescription),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Assert.
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal(AgentName, agent.Name);
|
||||
Assert.Equal(AgentDescription, agent.Description);
|
||||
Assert.Equal(AgentInstructions, agent.Instructions);
|
||||
|
||||
var retrievedAgentMetadata = await this._persistentAgentsClient.Administration.GetAgentAsync(agent.Id);
|
||||
Assert.NotNull(retrievedAgentMetadata);
|
||||
Assert.Equal(AgentName, retrievedAgentMetadata.Value.Name);
|
||||
Assert.Equal(AgentDescription, retrievedAgentMetadata.Value.Description);
|
||||
Assert.Equal(AgentInstructions, retrievedAgentMetadata.Value.Instructions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup.
|
||||
await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory(Skip = "For manual testing only")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
const string AgentInstructions = """
|
||||
You are a helpful agent that can help fetch data from files you know about.
|
||||
Use the File Search Tool to look up codes for words.
|
||||
Do not answer a question unless you can find the answer using the File Search Tool.
|
||||
""";
|
||||
|
||||
// Create a vector store.
|
||||
var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt";
|
||||
File.WriteAllText(
|
||||
path: searchFilePath,
|
||||
contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457."
|
||||
);
|
||||
PersistentAgentFileInfo uploadedAgentFile = this._persistentAgentsClient.Files.UploadFile(
|
||||
filePath: searchFilePath,
|
||||
purpose: PersistentAgentFilePurpose.Agents
|
||||
);
|
||||
var vectorStoreMetadata = await this._persistentAgentsClient.VectorStores.CreateVectorStoreAsync([uploadedAgentFile.Id], name: "WordCodeLookup_VectorStore");
|
||||
|
||||
// Wait for vector store indexing to complete before using it
|
||||
await this.WaitForVectorStoreReadyAsync(this._persistentAgentsClient, vectorStoreMetadata.Value.Id);
|
||||
|
||||
// Act.
|
||||
var agent = createMechanism switch
|
||||
{
|
||||
"CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]
|
||||
}
|
||||
}),
|
||||
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new FileSearchToolDefinition()],
|
||||
toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Assert.
|
||||
// Verify that the agent can use the vector store to answer a question.
|
||||
var result = await agent.RunAsync("Can you give me the documented code for 'banana'?");
|
||||
Assert.Contains("673457", result.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup.
|
||||
await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
|
||||
await this._persistentAgentsClient.VectorStores.DeleteVectorStoreAsync(vectorStoreMetadata.Value.Id);
|
||||
await this._persistentAgentsClient.Files.DeleteFileAsync(uploadedAgentFile.Id);
|
||||
File.Delete(searchFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
const string AgentInstructions = """
|
||||
You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file
|
||||
and report the SECRET_NUMBER value it prints. Respond only with the number.
|
||||
""";
|
||||
|
||||
// Create a python file that prints a known value.
|
||||
var codeFilePath = Path.GetTempFileName() + "secret_number.py";
|
||||
File.WriteAllText(
|
||||
path: codeFilePath,
|
||||
contents: "print(\"SECRET_NUMBER=24601\")" // Deterministic output we will look for.
|
||||
);
|
||||
PersistentAgentFileInfo uploadedCodeFile = this._persistentAgentsClient.Files.UploadFile(
|
||||
filePath: codeFilePath,
|
||||
purpose: PersistentAgentFilePurpose.Agents
|
||||
);
|
||||
CodeInterpreterToolResource toolResource = new();
|
||||
toolResource.FileIds.Add(uploadedCodeFile.Id);
|
||||
|
||||
// Act.
|
||||
var agent = createMechanism switch
|
||||
{
|
||||
// Hosted tool path (tools supplied via ChatClientAgentOptions)
|
||||
"CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]
|
||||
}
|
||||
}),
|
||||
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new CodeInterpreterToolDefinition()],
|
||||
toolResources: new ToolResources() { CodeInterpreter = toolResource }),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Assert.
|
||||
var result = await agent.RunAsync("What is the SECRET_NUMBER?");
|
||||
// We expect the model to run the code and surface the number.
|
||||
Assert.Contains("24601", result.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup.
|
||||
await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
|
||||
await this._persistentAgentsClient.Files.DeleteFileAsync(uploadedCodeFile.Id);
|
||||
File.Delete(codeFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather.";
|
||||
|
||||
static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C.";
|
||||
var weatherFunction = AIFunctionFactory.Create(GetWeather);
|
||||
|
||||
ChatClientAgent agent = createMechanism switch
|
||||
{
|
||||
"CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = [weatherFunction]
|
||||
}
|
||||
}),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Act.
|
||||
var response = await agent.RunAsync("What is the weather like in Amsterdam?");
|
||||
|
||||
// Assert - ensure function was invoked and its output surfaced.
|
||||
var text = response.Text;
|
||||
Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a vector store to complete indexing by polling its status.
|
||||
/// </summary>
|
||||
/// <param name="client">The persistent agents client.</param>
|
||||
/// <param name="vectorStoreId">The ID of the vector store.</param>
|
||||
/// <param name="maxWaitSeconds">Maximum time to wait in seconds (default: 30).</param>
|
||||
/// <returns>A task that completes when the vector store is ready or throws on timeout/failure.</returns>
|
||||
private async Task WaitForVectorStoreReadyAsync(
|
||||
PersistentAgentsClient client,
|
||||
string vectorStoreId,
|
||||
int maxWaitSeconds = 30)
|
||||
{
|
||||
Stopwatch sw = Stopwatch.StartNew();
|
||||
while (sw.Elapsed.TotalSeconds < maxWaitSeconds)
|
||||
{
|
||||
PersistentAgentsVectorStore vectorStore = await client.VectorStores.GetVectorStoreAsync(vectorStoreId);
|
||||
|
||||
if (vectorStore.Status == VectorStoreStatus.Completed)
|
||||
{
|
||||
if (vectorStore.FileCounts.Failed > 0)
|
||||
{
|
||||
throw new InvalidOperationException("Vector store indexing failed for some files");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (vectorStore.Status == VectorStoreStatus.Expired)
|
||||
{
|
||||
throw new InvalidOperationException("Vector store has expired");
|
||||
}
|
||||
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Vector store did not complete indexing within {maxWaitSeconds}s");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure;
|
||||
using Azure.AI.Agents.Persistent;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsPersistentFixture : IChatClientAgentFixture
|
||||
{
|
||||
private static readonly AzureAIConfiguration s_config = TestConfiguration.LoadSection<AzureAIConfiguration>();
|
||||
|
||||
private ChatClientAgent _agent = null!;
|
||||
private PersistentAgentsClient _persistentAgentsClient = null!;
|
||||
|
||||
public IChatClient ChatClient => this._agent.ChatClient;
|
||||
|
||||
public AIAgent Agent => this._agent;
|
||||
|
||||
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread)
|
||||
{
|
||||
List<ChatMessage> messages = [];
|
||||
var typedThread = (ChatClientAgentThread)thread;
|
||||
|
||||
await foreach (var threadMessage in (AsyncPageable<PersistentThreadMessage>)this._persistentAgentsClient.Messages.GetMessagesAsync(
|
||||
threadId: typedThread.ConversationId, order: ListSortOrder.Ascending))
|
||||
{
|
||||
var message = new ChatMessage
|
||||
{
|
||||
Role = threadMessage.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant
|
||||
};
|
||||
|
||||
foreach (var content in threadMessage.ContentItems)
|
||||
{
|
||||
if (content is MessageTextContent textContent)
|
||||
{
|
||||
message.Contents.Add(new TextContent(textContent.Text));
|
||||
}
|
||||
}
|
||||
|
||||
messages.Add(message);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
public async Task<ChatClientAgent> CreateChatClientAgentAsync(
|
||||
string name = "HelpfulAssistant",
|
||||
string instructions = "You are a helpful assistant.",
|
||||
IList<AITool>? aiTools = null)
|
||||
{
|
||||
var persistentAgentResponse = await this._persistentAgentsClient.Administration.CreateAgentAsync(
|
||||
model: s_config.DeploymentName,
|
||||
name: name,
|
||||
instructions: instructions);
|
||||
|
||||
var persistentAgent = persistentAgentResponse.Value;
|
||||
|
||||
return new ChatClientAgent(
|
||||
this._persistentAgentsClient.AsIChatClient(persistentAgent.Id),
|
||||
options: new()
|
||||
{
|
||||
Id = persistentAgent.Id,
|
||||
ChatOptions = new() { Tools = aiTools }
|
||||
});
|
||||
}
|
||||
|
||||
public Task DeleteAgentAsync(ChatClientAgent agent) =>
|
||||
this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
|
||||
|
||||
public Task DeleteThreadAsync(AgentThread thread)
|
||||
{
|
||||
var typedThread = (ChatClientAgentThread)thread;
|
||||
if (typedThread?.ConversationId is not null)
|
||||
{
|
||||
return this._persistentAgentsClient.Threads.DeleteThreadAsync(typedThread.ConversationId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
if (this._persistentAgentsClient is not null && this._agent is not null)
|
||||
{
|
||||
return this._persistentAgentsClient.Administration.DeleteAgentAsync(this._agent.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
this._persistentAgentsClient = new(s_config.Endpoint, new AzureCliCredential());
|
||||
this._agent = await this.CreateChatClientAgentAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsPersistentRunStreamingTests() : RunStreamingTests<AzureAIAgentsPersistentFixture>(() => new())
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAIAgentsPersistent.IntegrationTests;
|
||||
|
||||
public class AzureAIAgentsPersistentRunTests() : RunTests<AzureAIAgentsPersistentFixture>(() => new())
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.CopilotStudio\Microsoft.Agents.AI.CopilotStudio.csproj" />
|
||||
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using CopilotStudio.IntegrationTests.Support;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.CopilotStudio;
|
||||
using Microsoft.Agents.CopilotStudio.Client;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace CopilotStudio.IntegrationTests;
|
||||
|
||||
public class CopilotStudioFixture : IAgentFixture
|
||||
{
|
||||
public AIAgent Agent { get; private set; } = null!;
|
||||
|
||||
public Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread) =>
|
||||
throw new NotSupportedException("CopilotStudio doesn't allow retrieval of chat history.");
|
||||
|
||||
public Task DeleteThreadAsync(AgentThread thread) =>
|
||||
// Chat Completion does not require/support deleting threads, so this is a no-op.
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
const string CopilotStudioHttpClientName = nameof(CopilotStudioAgent);
|
||||
|
||||
var config = TestConfiguration.LoadSection<CopilotStudioAgentConfiguration>();
|
||||
var settings = new CopilotStudioConnectionSettings(config.TenantId, config.AppClientId)
|
||||
{
|
||||
DirectConnectUrl = config.DirectConnectUrl,
|
||||
};
|
||||
|
||||
ServiceCollection services = new();
|
||||
|
||||
services
|
||||
.AddSingleton(settings)
|
||||
.AddSingleton<CopilotStudioTokenHandler>()
|
||||
.AddHttpClient(CopilotStudioHttpClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler<CopilotStudioTokenHandler>();
|
||||
|
||||
IHttpClientFactory httpClientFactory =
|
||||
services
|
||||
.BuildServiceProvider()
|
||||
.GetRequiredService<IHttpClientFactory>();
|
||||
|
||||
CopilotClient client = new(settings, httpClientFactory, NullLogger.Instance, CopilotStudioHttpClientName);
|
||||
|
||||
this.Agent = new CopilotStudioAgent(client);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace CopilotStudio.IntegrationTests;
|
||||
|
||||
public class CopilotStudioRunStreamingTests() : RunStreamingTests<CopilotStudioFixture>(() => new())
|
||||
{
|
||||
// Set to null to run the tests.
|
||||
private const string ManualVerification = "For manual verification";
|
||||
|
||||
[Fact(Skip = "Copilot Studio does not support thread history retrieval, so this test is not applicable.")]
|
||||
public override Task ThreadMaintainsHistoryAsync() =>
|
||||
Task.CompletedTask;
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithChatMessageReturnsExpectedResultAsync() =>
|
||||
base.RunWithChatMessageReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithChatMessagesReturnsExpectedResultAsync() =>
|
||||
base.RunWithChatMessagesReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync() =>
|
||||
base.RunWithNoMessageDoesNotFailAsync();
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithStringReturnsExpectedResultAsync() =>
|
||||
base.RunWithStringReturnsExpectedResultAsync();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace CopilotStudio.IntegrationTests;
|
||||
|
||||
public class CopilotStudioRunTests() : RunTests<CopilotStudioFixture>(() => new())
|
||||
{
|
||||
// Set to null to run the tests.
|
||||
private const string ManualVerification = "For manual verification";
|
||||
|
||||
[Fact(Skip = "Copilot Studio does not support thread history retrieval, so this test is not applicable.")]
|
||||
public override Task ThreadMaintainsHistoryAsync() =>
|
||||
Task.CompletedTask;
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithChatMessageReturnsExpectedResultAsync() => base.RunWithChatMessageReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithChatMessagesReturnsExpectedResultAsync() =>
|
||||
|
||||
base.RunWithChatMessagesReturnsExpectedResultAsync();
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithNoMessageDoesNotFailAsync() =>
|
||||
base.RunWithNoMessageDoesNotFailAsync();
|
||||
|
||||
[Fact(Skip = ManualVerification)]
|
||||
public override Task RunWithStringReturnsExpectedResultAsync() =>
|
||||
base.RunWithStringReturnsExpectedResultAsync();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace CopilotStudio.IntegrationTests.Support;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
|
||||
|
||||
internal sealed class CopilotStudioAgentConfiguration
|
||||
{
|
||||
public string DirectConnectUrl { get; set; }
|
||||
|
||||
public string TenantId { get; set; }
|
||||
|
||||
public string AppClientId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Agents.CopilotStudio.Client;
|
||||
using Microsoft.Agents.CopilotStudio.Client.Discovery;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace CopilotStudio.IntegrationTests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ConnectionSettings"/> with additional properties to specify Application (Client) Id,
|
||||
/// Tenant Id, and optionally the Application Client secret.
|
||||
/// </summary>
|
||||
internal sealed class CopilotStudioConnectionSettings : ConnectionSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Application ID for creating the authentication for the connection
|
||||
/// </summary>
|
||||
public string AppClientId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Application secret for creating the authentication for the connection
|
||||
/// </summary>
|
||||
public string? AppClientSecret { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for creating the authentication for the connection
|
||||
/// </summary>
|
||||
public string TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Use interactive or service connection for authentication.
|
||||
/// Defaults to true, meaning interactive authentication will be used.
|
||||
/// </summary>
|
||||
public bool UseInteractiveAuthentication { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Instantiate a new instance of the <see cref="CopilotStudioConnectionSettings"/> from provided settings.
|
||||
/// </summary>
|
||||
public CopilotStudioConnectionSettings(string tenantId, string appClientId, string? appClientSecret = null)
|
||||
{
|
||||
this.TenantId = tenantId;
|
||||
this.AppClientId = appClientId;
|
||||
this.AppClientSecret = appClientSecret;
|
||||
this.Cloud = PowerPlatformCloud.Prod;
|
||||
this.CopilotAgentType = AgentType.Published;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instantiate a new instance of the <see cref="CopilotStudioConnectionSettings"/> from a configuration section.
|
||||
/// </summary>
|
||||
/// <param name="config"></param>
|
||||
/// <exception cref="ArgumentException"></exception>
|
||||
public CopilotStudioConnectionSettings(IConfigurationSection config)
|
||||
: base(config)
|
||||
{
|
||||
this.AppClientId = config[nameof(this.AppClientId)] ?? throw new ArgumentException($"{nameof(this.AppClientId)} not found in config");
|
||||
this.TenantId = config[nameof(this.TenantId)] ?? throw new ArgumentException($"{nameof(this.TenantId)} not found in config");
|
||||
this.AppClientSecret = config[nameof(this.AppClientSecret)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.CopilotStudio.Client;
|
||||
using Microsoft.Identity.Client;
|
||||
using Microsoft.Identity.Client.Extensions.Msal;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace CopilotStudio.IntegrationTests.Support;
|
||||
|
||||
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="HttpClientHandler"/> that adds an authentication token to the request headers for Copilot Studio API calls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For more information on how to setup various authentication flows, see the Microsoft Identity documentation at https://aka.ms/msal.
|
||||
/// </remarks>
|
||||
internal sealed class CopilotStudioTokenHandler : HttpClientHandler
|
||||
{
|
||||
private const string AuthenticationHeader = "Bearer";
|
||||
private const string CacheFolderName = "mcs_client_console";
|
||||
private const string KeyChainServiceName = "copilot_studio_client_app";
|
||||
private const string KeyChainAccountName = "copilot_studio_client";
|
||||
|
||||
private readonly CopilotStudioConnectionSettings _settings;
|
||||
private readonly string[] _scopes;
|
||||
|
||||
private IConfidentialClientApplication? _clientApplication;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CopilotStudioTokenHandler"/> class with the specified connection settings.
|
||||
/// </summary>
|
||||
/// <param name="settings">The connection settings for Copilot Studio.</param>
|
||||
public CopilotStudioTokenHandler(CopilotStudioConnectionSettings settings)
|
||||
{
|
||||
Throw.IfNull(settings);
|
||||
|
||||
this._settings = settings;
|
||||
this._scopes = [CopilotClient.ScopeFromSettings(this._settings)];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Headers.Authorization is null)
|
||||
{
|
||||
AuthenticationResult authResponse = await this.AuthenticateAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(AuthenticationHeader, authResponse.AccessToken);
|
||||
}
|
||||
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task<AuthenticationResult> AuthenticateAsync(CancellationToken cancellationToken) =>
|
||||
this._settings.UseInteractiveAuthentication ?
|
||||
this.AuthenticateInteractiveAsync(cancellationToken) :
|
||||
this.AuthenticateServiceAsync(cancellationToken);
|
||||
|
||||
private async Task<AuthenticationResult> AuthenticateServiceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._clientApplication is null)
|
||||
{
|
||||
this._clientApplication = ConfidentialClientApplicationBuilder.Create(this._settings.AppClientId)
|
||||
.WithAuthority(AzureCloudInstance.AzurePublic, this._settings.TenantId)
|
||||
.WithClientSecret(this._settings.AppClientSecret)
|
||||
.Build();
|
||||
|
||||
MsalCacheHelper tokenCacheHelper = await CreateCacheHelperAsync("AppTokenCache").ConfigureAwait(false);
|
||||
tokenCacheHelper.RegisterCache(this._clientApplication.AppTokenCache);
|
||||
}
|
||||
|
||||
AuthenticationResult authResponse;
|
||||
|
||||
authResponse = await this._clientApplication.AcquireTokenForClient(this._scopes).ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return authResponse;
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> AuthenticateInteractiveAsync(CancellationToken cancellationToken = default!)
|
||||
{
|
||||
IPublicClientApplication app =
|
||||
PublicClientApplicationBuilder.Create(this._settings.AppClientId)
|
||||
.WithAuthority(AadAuthorityAudience.AzureAdMyOrg)
|
||||
.WithTenantId(this._settings.TenantId)
|
||||
.WithRedirectUri("http://localhost")
|
||||
.Build();
|
||||
|
||||
MsalCacheHelper tokenCacheHelper = await CreateCacheHelperAsync("TokenCache").ConfigureAwait(false);
|
||||
tokenCacheHelper.RegisterCache(app.UserTokenCache);
|
||||
|
||||
IEnumerable<IAccount> accounts = await app.GetAccountsAsync().ConfigureAwait(false);
|
||||
IAccount? account = accounts.FirstOrDefault();
|
||||
|
||||
AuthenticationResult authResponse;
|
||||
|
||||
try
|
||||
{
|
||||
authResponse = await app.AcquireTokenSilent(this._scopes, account).ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (MsalUiRequiredException)
|
||||
{
|
||||
authResponse = await app.AcquireTokenInteractive(this._scopes).ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return authResponse;
|
||||
}
|
||||
|
||||
private static async Task<MsalCacheHelper> CreateCacheHelperAsync(string cacheFileName)
|
||||
{
|
||||
string currentDir = Path.Combine(AppContext.BaseDirectory, CacheFolderName);
|
||||
|
||||
if (!Directory.Exists(currentDir))
|
||||
{
|
||||
Directory.CreateDirectory(currentDir);
|
||||
}
|
||||
|
||||
StorageCreationPropertiesBuilder storageProperties = new(cacheFileName, currentDir);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
storageProperties.WithLinuxUnprotectedFile();
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
storageProperties.WithMacKeyChain(KeyChainServiceName, KeyChainAccountName);
|
||||
}
|
||||
|
||||
return await MsalCacheHelper.CreateAsync(storageProperties.Build()).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
28
dotnet/tests/Directory.Build.props
Normal file
28
dotnet/tests/Directory.Build.props
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project>
|
||||
|
||||
<Import Project="../Directory.Build.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<IsAotCompatible>false</IsAotCompatible>
|
||||
<TargetFrameworks>net10.0;net472</TargetFrameworks>
|
||||
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
|
||||
<NoWarn>$(NoWarn);Moq1410;xUnit2023</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xRetry" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="xRetry" />
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
1124
dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
Normal file
1124
dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AAgentThread"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AAgentThreadTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_RoundTrip_SerializationPreservesState()
|
||||
{
|
||||
// Arrange
|
||||
const string ContextId = "context-rt-001";
|
||||
const string TaskId = "task-rt-002";
|
||||
|
||||
A2AAgentThread originalThread = new() { ContextId = ContextId, TaskId = TaskId };
|
||||
|
||||
// Act
|
||||
JsonElement serialized = originalThread.Serialize();
|
||||
|
||||
A2AAgentThread deserializedThread = new(serialized);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(originalThread.ContextId, deserializedThread.ContextId);
|
||||
Assert.Equal(originalThread.TaskId, deserializedThread.TaskId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AContinuationToken"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AContinuationTokenTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithValidTaskId_InitializesTaskIdProperty()
|
||||
{
|
||||
// Arrange
|
||||
const string TaskId = "task-123";
|
||||
|
||||
// Act
|
||||
var token = new A2AContinuationToken(TaskId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TaskId, token.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToBytes_WithValidToken_SerializesToJsonBytes()
|
||||
{
|
||||
// Arrange
|
||||
const string TaskId = "task-456";
|
||||
var token = new A2AContinuationToken(TaskId);
|
||||
|
||||
// Act
|
||||
var bytes = token.ToBytes();
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(0, bytes.Length);
|
||||
var jsonString = System.Text.Encoding.UTF8.GetString(bytes.ToArray());
|
||||
using var jsonDoc = JsonDocument.Parse(jsonString);
|
||||
var root = jsonDoc.RootElement;
|
||||
Assert.True(root.TryGetProperty("taskId", out var taskIdElement));
|
||||
Assert.Equal(TaskId, taskIdElement.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithA2AContinuationToken_ReturnsSameInstance()
|
||||
{
|
||||
// Arrange
|
||||
const string TaskId = "task-direct";
|
||||
var originalToken = new A2AContinuationToken(TaskId);
|
||||
|
||||
// Act
|
||||
var resultToken = A2AContinuationToken.FromToken(originalToken);
|
||||
|
||||
// Assert
|
||||
Assert.Same(originalToken, resultToken);
|
||||
Assert.Equal(TaskId, resultToken.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithSerializedToken_DeserializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
const string TaskId = "task-deserialized";
|
||||
var originalToken = new A2AContinuationToken(TaskId);
|
||||
var serialized = originalToken.ToBytes();
|
||||
|
||||
// Create a mock token wrapper to pass to FromToken
|
||||
var mockToken = new MockResponseContinuationToken(serialized);
|
||||
|
||||
// Act
|
||||
var resultToken = A2AContinuationToken.FromToken(mockToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TaskId, resultToken.TaskId);
|
||||
Assert.IsType<A2AContinuationToken>(resultToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_RoundTrip_PreservesTaskId()
|
||||
{
|
||||
// Arrange
|
||||
const string TaskId = "task-roundtrip-123";
|
||||
var originalToken = new A2AContinuationToken(TaskId);
|
||||
var serialized = originalToken.ToBytes();
|
||||
var mockToken = new MockResponseContinuationToken(serialized);
|
||||
|
||||
// Act
|
||||
var deserializedToken = A2AContinuationToken.FromToken(mockToken);
|
||||
var reserialized = deserializedToken.ToBytes();
|
||||
var mockToken2 = new MockResponseContinuationToken(reserialized);
|
||||
var deserializedAgain = A2AContinuationToken.FromToken(mockToken2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TaskId, deserializedAgain.TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithEmptyData_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var emptyToken = new MockResponseContinuationToken(ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => A2AContinuationToken.FromToken(emptyToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithMissingTaskIdProperty_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var jsonWithoutTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"someOtherProperty\": \"value\" }").AsMemory();
|
||||
var mockToken = new MockResponseContinuationToken(jsonWithoutTaskId);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<JsonException>(() => A2AContinuationToken.FromToken(mockToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithValidTaskId_ParsesTaskIdCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
const string TaskId = "task-multi-prop";
|
||||
var json = System.Text.Encoding.UTF8.GetBytes($"{{ \"taskId\": \"{TaskId}\" }}").AsMemory();
|
||||
var mockToken = new MockResponseContinuationToken(json);
|
||||
|
||||
// Act
|
||||
var resultToken = A2AContinuationToken.FromToken(mockToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TaskId, resultToken.TaskId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of ResponseContinuationToken for testing.
|
||||
/// </summary>
|
||||
private sealed class MockResponseContinuationToken : ResponseContinuationToken
|
||||
{
|
||||
private readonly ReadOnlyMemory<byte> _data;
|
||||
|
||||
public MockResponseContinuationToken(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
this._data = data;
|
||||
}
|
||||
|
||||
public override ReadOnlyMemory<byte> ToBytes()
|
||||
{
|
||||
return this._data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AAIContentExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AAIContentExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToA2AParts_WithEmptyCollection_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var emptyContents = new List<AIContent>();
|
||||
|
||||
// Act
|
||||
var result = emptyContents.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AParts_WithMultipleContents_ReturnsListWithAllParts()
|
||||
{
|
||||
// Arrange
|
||||
var contents = new List<AIContent>
|
||||
{
|
||||
new TextContent("First text"),
|
||||
new UriContent("https://example.com/file1.txt", "file/txt"),
|
||||
new TextContent("Second text"),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = contents.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
var firstTextPart = Assert.IsType<TextPart>(result[0]);
|
||||
Assert.Equal("First text", firstTextPart.Text);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(result[1]);
|
||||
Assert.Equal("https://example.com/file1.txt", filePart.File.Uri?.ToString());
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(result[2]);
|
||||
Assert.Equal("Second text", secondTextPart.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AParts_WithMixedSupportedAndUnsupportedContent_IgnoresUnsupportedContent()
|
||||
{
|
||||
// Arrange
|
||||
var contents = new List<AIContent>
|
||||
{
|
||||
new TextContent("First text"),
|
||||
new MockAIContent(), // Unsupported - should be ignored
|
||||
new UriContent("https://example.com/file.txt", "file/txt"),
|
||||
new MockAIContent(), // Unsupported - should be ignored
|
||||
new TextContent("Second text")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = contents.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
var firstTextPart = Assert.IsType<TextPart>(result[0]);
|
||||
Assert.Equal("First text", firstTextPart.Text);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(result[1]);
|
||||
Assert.Equal("https://example.com/file.txt", filePart.File.Uri?.ToString());
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(result[2]);
|
||||
Assert.Equal("Second text", secondTextPart.Text);
|
||||
}
|
||||
|
||||
// Mock class for testing unsupported scenarios
|
||||
private sealed class MockAIContent : AIContent;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AAgentCardExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AAgentCardExtensionsTests
|
||||
{
|
||||
private readonly AgentCard _agentCard;
|
||||
|
||||
public A2AAgentCardExtensionsTests()
|
||||
{
|
||||
this._agentCard = new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for unit testing",
|
||||
Url = "http://test-endpoint/agent"
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_ReturnsAIAgent()
|
||||
{
|
||||
// Act
|
||||
var agent = this._agentCard.AsAIAgent();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
Assert.Equal("A test agent for unit testing", agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new HttpMessageHandlerStub();
|
||||
using var httpClient = new HttpClient(handler, false);
|
||||
|
||||
handler.ResponsesToReturn.Enqueue(new AgentMessage
|
||||
{
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Response" }],
|
||||
});
|
||||
|
||||
var agent = this._agentCard.AsAIAgent(httpClient);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
|
||||
// Assert
|
||||
Assert.Single(handler.CapturedUris);
|
||||
Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]);
|
||||
}
|
||||
|
||||
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
public Queue ResponsesToReturn { get; } = new();
|
||||
|
||||
public List<Uri> CapturedUris { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.CapturedUris.Add(request.RequestUri!);
|
||||
|
||||
var response = this.ResponsesToReturn.Dequeue();
|
||||
|
||||
if (response is AgentCard agentCard)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(agentCard);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
else if (response is AgentMessage message)
|
||||
{
|
||||
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
// Return empty agent card if none specified
|
||||
var emptyCard = new AgentCard();
|
||||
var emptyJson = JsonSerializer.Serialize(emptyCard);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(emptyJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AAgentTaskExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToChatMessages_WithNullAgentTask_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AgentTask agentTask = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => agentTask.ToChatMessages());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContents_WithNullAgentTask_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AgentTask agentTask = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => agentTask.ToAIContents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var agentTask = new AgentTask
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
IList<ChatMessage>? result = agentTask.ToChatMessages();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_WithNullArtifactsAndNoUserInputRequests_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var agentTask = new AgentTask
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = null,
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
IList<ChatMessage>? result = agentTask.ToChatMessages();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContents_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var agentTask = new AgentTask
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
IList<AIContent>? result = agentTask.ToAIContents();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContents_WithNullArtifactsAndNoUserInputRequests_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var agentTask = new AgentTask
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = null,
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
IList<AIContent>? result = agentTask.ToAIContents();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_WithValidArtifact_ReturnsChatMessages()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = new Artifact
|
||||
{
|
||||
Parts = [new TextPart { Text = "response" }],
|
||||
};
|
||||
|
||||
var agentTask = new AgentTask
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [artifact],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
IList<ChatMessage>? result = agentTask.ToChatMessages();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result);
|
||||
Assert.All(result, msg => Assert.Equal(ChatRole.Assistant, msg.Role));
|
||||
Assert.Equal("response", result[0].Contents[0].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContents_WithMultipleArtifacts_FlattenAllContents()
|
||||
{
|
||||
// Arrange
|
||||
var artifact1 = new Artifact
|
||||
{
|
||||
Parts = [new TextPart { Text = "content1" }],
|
||||
};
|
||||
|
||||
var artifact2 = new Artifact
|
||||
{
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "content2" },
|
||||
new TextPart { Text = "content3" }
|
||||
],
|
||||
};
|
||||
|
||||
var agentTask = new AgentTask
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [artifact1, artifact2],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
IList<AIContent>? result = agentTask.ToAIContents();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("content1", result[0].ToString());
|
||||
Assert.Equal("content2", result[1].ToString());
|
||||
Assert.Equal("content3", result[2].ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AArtifactExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AArtifactExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToChatMessage_WithMultiplePartsMetadataAndRawRepresentation_ReturnsCorrectChatMessage()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = new Artifact
|
||||
{
|
||||
ArtifactId = "artifact-comprehensive",
|
||||
Name = "comprehensive-artifact",
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "First part" },
|
||||
new TextPart { Text = "Second part" },
|
||||
new TextPart { Text = "Third part" }
|
||||
],
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
{ "key1", JsonSerializer.SerializeToElement("value1") },
|
||||
{ "key2", JsonSerializer.SerializeToElement(42) }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = artifact.ToChatMessage();
|
||||
|
||||
// Assert - Verify multiple parts
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ChatRole.Assistant, result.Role);
|
||||
Assert.Equal(3, result.Contents.Count);
|
||||
Assert.All(result.Contents, content => Assert.IsType<TextContent>(content));
|
||||
Assert.Equal("First part", ((TextContent)result.Contents[0]).Text);
|
||||
Assert.Equal("Second part", ((TextContent)result.Contents[1]).Text);
|
||||
Assert.Equal("Third part", ((TextContent)result.Contents[2]).Text);
|
||||
|
||||
// Assert - Verify metadata conversion to AdditionalProperties
|
||||
Assert.NotNull(result.AdditionalProperties);
|
||||
Assert.Equal(2, result.AdditionalProperties.Count);
|
||||
Assert.True(result.AdditionalProperties.ContainsKey("key1"));
|
||||
Assert.True(result.AdditionalProperties.ContainsKey("key2"));
|
||||
|
||||
// Assert - Verify RawRepresentation is set to artifact
|
||||
Assert.NotNull(result.RawRepresentation);
|
||||
Assert.Same(artifact, result.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContents_WithMultipleParts_ReturnsCorrectList()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = new Artifact
|
||||
{
|
||||
ArtifactId = "artifact-ai-multi",
|
||||
Name = "test",
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "Part 1" },
|
||||
new TextPart { Text = "Part 2" },
|
||||
new TextPart { Text = "Part 3" }
|
||||
],
|
||||
Metadata = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = artifact.ToAIContents();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.All(result, content => Assert.IsType<TextContent>(content));
|
||||
Assert.Equal("Part 1", ((TextContent)result[0]).Text);
|
||||
Assert.Equal("Part 2", ((TextContent)result[1]).Text);
|
||||
Assert.Equal("Part 3", ((TextContent)result[2]).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContents_WithEmptyParts_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var artifact = new Artifact
|
||||
{
|
||||
ArtifactId = "artifact-empty",
|
||||
Name = "test",
|
||||
Parts = [],
|
||||
Metadata = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = artifact.ToAIContents();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2ACardResolverExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HttpMessageHandlerStub _handler;
|
||||
private readonly A2ACardResolver _resolver;
|
||||
|
||||
public A2ACardResolverExtensionsTests()
|
||||
{
|
||||
this._handler = new HttpMessageHandlerStub();
|
||||
this._httpClient = new HttpClient(this._handler, false);
|
||||
this._resolver = new A2ACardResolver(new Uri("http://test-host"), httpClient: this._httpClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithValidAgentCard_ReturnsAIAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for unit testing",
|
||||
Url = "http://test-endpoint/agent"
|
||||
});
|
||||
|
||||
// Act
|
||||
var agent = await this._resolver.GetAIAgentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
Assert.Equal("A test agent for unit testing", agent.Description);
|
||||
|
||||
// Verify that there was only one request made to retrieve the agent card
|
||||
Assert.Single(this._handler.CapturedUris);
|
||||
Assert.StartsWith("http://test-host/", this._handler.CapturedUris[0].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Url = "http://test-endpoint/agent"
|
||||
});
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentMessage
|
||||
{
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Response" }],
|
||||
});
|
||||
|
||||
var agent = await this._resolver.GetAIAgentAsync(this._httpClient);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, this._handler.CapturedUris.Count); // One for getting the card, one for sending the message to the agent
|
||||
Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._handler.Dispose();
|
||||
this._httpClient.Dispose();
|
||||
}
|
||||
|
||||
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
public Queue ResponsesToReturn { get; } = new();
|
||||
|
||||
public List<Uri> CapturedUris { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.CapturedUris.Add(request.RequestUri!);
|
||||
|
||||
var response = this.ResponsesToReturn.Dequeue();
|
||||
|
||||
if (response is AgentCard agentCard)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(agentCard);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
else if (response is AgentMessage message)
|
||||
{
|
||||
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
// Return empty agent card if none specified
|
||||
var emptyCard = new AgentCard();
|
||||
var emptyJson = JsonSerializer.Serialize(emptyCard);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(emptyJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the A2AClientExtensions class.
|
||||
/// </summary>
|
||||
public sealed class A2AClientExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange
|
||||
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
|
||||
const string TestId = "test-agent-id";
|
||||
const string TestName = "Test Agent";
|
||||
const string TestDescription = "This is a test agent description";
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.AsAIAgent(TestId, TestName, TestDescription);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal(TestId, agent.Id);
|
||||
Assert.Equal(TestName, agent.Name);
|
||||
Assert.Equal(TestDescription, agent.Description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AMetadataExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AMetadataExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToAdditionalProperties_WithNullMetadata_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, JsonElement>? metadata = null;
|
||||
|
||||
// Act
|
||||
var result = metadata.ToAdditionalProperties();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAdditionalProperties_WithEmptyMetadata_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new Dictionary<string, JsonElement>();
|
||||
|
||||
// Act
|
||||
var result = metadata.ToAdditionalProperties();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAdditionalProperties_WithMultipleProperties_ReturnsAdditionalPropertiesDictionaryWithAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
{ "stringKey", JsonSerializer.SerializeToElement("stringValue") },
|
||||
{ "numberKey", JsonSerializer.SerializeToElement(42) },
|
||||
{ "booleanKey", JsonSerializer.SerializeToElement(true) }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = metadata.ToAdditionalProperties();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
Assert.True(result.ContainsKey("stringKey"));
|
||||
Assert.Equal("stringValue", ((JsonElement)result["stringKey"]!).GetString());
|
||||
|
||||
Assert.True(result.ContainsKey("numberKey"));
|
||||
Assert.Equal(42, ((JsonElement)result["numberKey"]!).GetInt32());
|
||||
|
||||
Assert.True(result.ContainsKey("booleanKey"));
|
||||
Assert.True(((JsonElement)result["booleanKey"]!).GetBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AdditionalPropertiesDictionaryExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AdditionalPropertiesDictionaryExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary? additionalProperties = null;
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = [];
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "stringKey", "stringValue" }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("stringKey"));
|
||||
Assert.Equal("stringValue", result["stringKey"].GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "numberKey", 42 }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("numberKey"));
|
||||
Assert.Equal(42, result["numberKey"].GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "booleanKey", true }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("booleanKey"));
|
||||
Assert.True(result["booleanKey"].GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "stringKey", "stringValue" },
|
||||
{ "numberKey", 42 },
|
||||
{ "booleanKey", true }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
Assert.True(result.ContainsKey("stringKey"));
|
||||
Assert.Equal("stringValue", result["stringKey"].GetString());
|
||||
|
||||
Assert.True(result.ContainsKey("numberKey"));
|
||||
Assert.Equal(42, result["numberKey"].GetInt32());
|
||||
|
||||
Assert.True(result.ContainsKey("booleanKey"));
|
||||
Assert.True(result["booleanKey"].GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
int[] arrayValue = [1, 2, 3];
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "arrayKey", arrayValue }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("arrayKey"));
|
||||
Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind);
|
||||
Assert.Equal(3, result["arrayKey"].GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "nullKey", null! }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("nullKey"));
|
||||
Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 });
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "jsonElementKey", jsonElement }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("jsonElementKey"));
|
||||
Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind);
|
||||
Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString());
|
||||
Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="ChatMessageExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class ChatMessageExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToA2AMessage_WithMessageContainingMultipleContents_AddsAllContentsAsParts()
|
||||
{
|
||||
// Arrange
|
||||
var contents = new List<AIContent>
|
||||
{
|
||||
new UriContent("https://example.com/report.pdf", "file/pdf"),
|
||||
new TextContent("please summarize the file content"),
|
||||
new TextContent("and send it to me over email")
|
||||
};
|
||||
var chatMessage = new ChatMessage(ChatRole.User, contents);
|
||||
var messages = new List<ChatMessage> { chatMessage };
|
||||
|
||||
// Act
|
||||
var a2aMessage = messages.ToA2AMessage();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(a2aMessage);
|
||||
Assert.NotNull(a2aMessage.MessageId);
|
||||
Assert.NotEmpty(a2aMessage.MessageId);
|
||||
|
||||
Assert.Equal(MessageRole.User, a2aMessage.Role);
|
||||
|
||||
Assert.NotNull(a2aMessage.Parts);
|
||||
Assert.Equal(3, a2aMessage.Parts.Count);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
|
||||
Assert.NotNull(filePart.File);
|
||||
Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
|
||||
Assert.Equal("please summarize the file content", secondTextPart.Text);
|
||||
|
||||
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
|
||||
Assert.Equal("and send it to me over email", thirdTextPart.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMessage_WithMixedMessages_AddsAllContentsAsParts()
|
||||
{
|
||||
// Arrange
|
||||
var firstMessage = new ChatMessage(ChatRole.User, [
|
||||
new UriContent("https://example.com/report.pdf", "file/pdf"),
|
||||
]);
|
||||
var secondMessage = new ChatMessage(ChatRole.User, [
|
||||
new TextContent("please summarize the file content")
|
||||
]);
|
||||
var thirdMessage = new ChatMessage(ChatRole.User, [
|
||||
new TextContent("and send it to me over email")
|
||||
]);
|
||||
var messages = new List<ChatMessage> { firstMessage, secondMessage, thirdMessage };
|
||||
|
||||
// Act
|
||||
var a2aMessage = messages.ToA2AMessage();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(a2aMessage);
|
||||
Assert.NotNull(a2aMessage.MessageId);
|
||||
Assert.NotEmpty(a2aMessage.MessageId);
|
||||
|
||||
Assert.Equal(MessageRole.User, a2aMessage.Role);
|
||||
|
||||
Assert.NotNull(a2aMessage.Parts);
|
||||
Assert.Equal(3, a2aMessage.Parts.Count);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
|
||||
Assert.NotNull(filePart.File);
|
||||
Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
|
||||
Assert.Equal("please summarize the file content", secondTextPart.Text);
|
||||
|
||||
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
|
||||
Assert.Equal("and send it to me over email", thirdTextPart.Text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,644 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.AGUI.Shared;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.AGUI.UnitTests;
|
||||
|
||||
// Custom complex type for testing tool call parameters
|
||||
public sealed class WeatherRequest
|
||||
{
|
||||
public string Location { get; set; } = string.Empty;
|
||||
public string Units { get; set; } = "celsius";
|
||||
public bool IncludeForecast { get; set; }
|
||||
}
|
||||
|
||||
// Custom complex type for testing tool call results
|
||||
public sealed class WeatherResponse
|
||||
{
|
||||
public double Temperature { get; set; }
|
||||
public string Conditions { get; set; } = string.Empty;
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
// Custom JsonSerializerContext for the custom types
|
||||
[JsonSerializable(typeof(WeatherRequest))]
|
||||
[JsonSerializable(typeof(WeatherResponse))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
internal sealed partial class CustomTypesContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AGUIChatMessageExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AGUIChatMessageExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AsChatMessages_WithEmptyCollection_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUIMessage> aguiMessages = [];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(chatMessages);
|
||||
Assert.Empty(chatMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatMessages_WithSingleMessage_ConvertsToChatMessageCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIUserMessage
|
||||
{
|
||||
Id = "msg1",
|
||||
Content = "Hello"
|
||||
}
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
ChatMessage message = Assert.Single(chatMessages);
|
||||
Assert.Equal(ChatRole.User, message.Role);
|
||||
Assert.Equal("Hello", message.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatMessages_WithMultipleMessages_PreservesOrder()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIUserMessage { Id = "msg1", Content = "First" },
|
||||
new AGUIAssistantMessage { Id = "msg2", Content = "Second" },
|
||||
new AGUIUserMessage { Id = "msg3", Content = "Third" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, chatMessages.Count);
|
||||
Assert.Equal("First", chatMessages[0].Text);
|
||||
Assert.Equal("Second", chatMessages[1].Text);
|
||||
Assert.Equal("Third", chatMessages[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatMessages_MapsAllSupportedRoleTypes_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUISystemMessage { Id = "msg1", Content = "System message" },
|
||||
new AGUIUserMessage { Id = "msg2", Content = "User message" },
|
||||
new AGUIAssistantMessage { Id = "msg3", Content = "Assistant message" },
|
||||
new AGUIDeveloperMessage { Id = "msg4", Content = "Developer message" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, chatMessages.Count);
|
||||
Assert.Equal(ChatRole.System, chatMessages[0].Role);
|
||||
Assert.Equal(ChatRole.User, chatMessages[1].Role);
|
||||
Assert.Equal(ChatRole.Assistant, chatMessages[2].Role);
|
||||
Assert.Equal("developer", chatMessages[3].Role.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_WithEmptyCollection_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> chatMessages = [];
|
||||
|
||||
// Act
|
||||
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(aguiMessages);
|
||||
Assert.Empty(aguiMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_WithSingleMessage_ConvertsToAGUIMessageCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> chatMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello") { MessageId = "msg1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
AGUIMessage message = Assert.Single(aguiMessages);
|
||||
Assert.Equal("msg1", message.Id);
|
||||
Assert.Equal(AGUIRoles.User, message.Role);
|
||||
Assert.Equal("Hello", ((AGUIUserMessage)message).Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_WithMultipleMessages_PreservesOrder()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> chatMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Second"),
|
||||
new ChatMessage(ChatRole.User, "Third")
|
||||
];
|
||||
|
||||
// Act
|
||||
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, aguiMessages.Count);
|
||||
Assert.Equal("First", ((AGUIUserMessage)aguiMessages[0]).Content);
|
||||
Assert.Equal("Second", ((AGUIAssistantMessage)aguiMessages[1]).Content);
|
||||
Assert.Equal("Third", ((AGUIUserMessage)aguiMessages[2]).Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_PreservesMessageId_WhenPresent()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> chatMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello") { MessageId = "msg123" }
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert
|
||||
AGUIMessage message = Assert.Single(aguiMessages);
|
||||
Assert.Equal("msg123", message.Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AGUIRoles.System, "system")]
|
||||
[InlineData(AGUIRoles.User, "user")]
|
||||
[InlineData(AGUIRoles.Assistant, "assistant")]
|
||||
[InlineData(AGUIRoles.Developer, "developer")]
|
||||
public void MapChatRole_WithValidRole_ReturnsCorrectChatRole(string aguiRole, string expectedRoleValue)
|
||||
{
|
||||
// Arrange & Act
|
||||
ChatRole role = AGUIChatMessageExtensions.MapChatRole(aguiRole);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedRoleValue, role.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapChatRole_WithUnknownRole_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => AGUIChatMessageExtensions.MapChatRole("unknown"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_WithToolResultMessage_SerializesResultCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var result = new Dictionary<string, object?> { ["temperature"] = 72, ["condition"] = "Sunny" };
|
||||
FunctionResultContent toolResult = new("call_123", result);
|
||||
ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]);
|
||||
List<ChatMessage> messages = [toolMessage];
|
||||
|
||||
// Act
|
||||
List<AGUIMessage> aguiMessages = messages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert
|
||||
AGUIMessage aguiMessage = Assert.Single(aguiMessages);
|
||||
Assert.Equal(AGUIRoles.Tool, aguiMessage.Role);
|
||||
Assert.Equal("call_123", ((AGUIToolMessage)aguiMessage).ToolCallId);
|
||||
Assert.NotEmpty(((AGUIToolMessage)aguiMessage).Content);
|
||||
// Content should be serialized JSON
|
||||
Assert.Contains("temperature", ((AGUIToolMessage)aguiMessage).Content);
|
||||
Assert.Contains("72", ((AGUIToolMessage)aguiMessage).Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_WithNullToolResult_HandlesGracefully()
|
||||
{
|
||||
// Arrange
|
||||
FunctionResultContent toolResult = new("call_456", null);
|
||||
ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]);
|
||||
List<ChatMessage> messages = [toolMessage];
|
||||
|
||||
// Act
|
||||
List<AGUIMessage> aguiMessages = messages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert
|
||||
AGUIMessage aguiMessage = Assert.Single(aguiMessages);
|
||||
Assert.Equal(AGUIRoles.Tool, aguiMessage.Role);
|
||||
Assert.Equal("call_456", ((AGUIToolMessage)aguiMessage).ToolCallId);
|
||||
Assert.Equal(string.Empty, ((AGUIToolMessage)aguiMessage).Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_WithoutTypeInfoResolver_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
FunctionResultContent toolResult = new("call_789", "Result");
|
||||
ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]);
|
||||
List<ChatMessage> messages = [toolMessage];
|
||||
System.Text.Json.JsonSerializerOptions optionsWithoutResolver = new();
|
||||
|
||||
// Act & Assert
|
||||
NotSupportedException ex = Assert.Throws<NotSupportedException>(() => messages.AsAGUIMessages(optionsWithoutResolver).ToList());
|
||||
Assert.Contains("JsonTypeInfo", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatMessages_WithToolMessage_DeserializesResultCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
const string JsonContent = "{\"status\":\"success\",\"value\":42}";
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIToolMessage
|
||||
{
|
||||
Id = "msg1",
|
||||
Content = JsonContent,
|
||||
ToolCallId = "call_abc"
|
||||
}
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert
|
||||
ChatMessage message = Assert.Single(chatMessages);
|
||||
Assert.Equal(ChatRole.Tool, message.Role);
|
||||
FunctionResultContent result = Assert.IsType<FunctionResultContent>(message.Contents[0]);
|
||||
Assert.Equal("call_abc", result.CallId);
|
||||
Assert.NotNull(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatMessages_WithEmptyToolContent_CreatesNullResult()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIToolMessage
|
||||
{
|
||||
Id = "msg1",
|
||||
Content = string.Empty,
|
||||
ToolCallId = "call_def"
|
||||
}
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert
|
||||
ChatMessage message = Assert.Single(chatMessages);
|
||||
FunctionResultContent result = Assert.IsType<FunctionResultContent>(message.Contents[0]);
|
||||
Assert.Equal("call_def", result.CallId);
|
||||
Assert.Equal(string.Empty, result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatMessages_WithToolMessageWithoutCallId_TreatsAsRegularMessage()
|
||||
{
|
||||
// Arrange - use valid JSON for Content
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIToolMessage
|
||||
{
|
||||
Id = "msg1",
|
||||
Content = "{\"result\":\"Some content\"}",
|
||||
ToolCallId = string.Empty
|
||||
}
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
||||
|
||||
// Assert
|
||||
ChatMessage message = Assert.Single(chatMessages);
|
||||
Assert.Equal(ChatRole.Tool, message.Role);
|
||||
var resultContent = Assert.IsType<FunctionResultContent>(message.Contents.First());
|
||||
Assert.Equal(string.Empty, resultContent.CallId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_ToolResultMessage_PreservesData()
|
||||
{
|
||||
// Arrange
|
||||
var resultData = new Dictionary<string, object?> { ["location"] = "Seattle", ["temperature"] = 68, ["forecast"] = "Partly cloudy" };
|
||||
FunctionResultContent originalResult = new("call_roundtrip", resultData);
|
||||
ChatMessage originalMessage = new(ChatRole.Tool, [originalResult]);
|
||||
|
||||
// Act - Convert to AGUI and back
|
||||
List<ChatMessage> originalList = [originalMessage];
|
||||
AGUIMessage aguiMessage = originalList.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).Single();
|
||||
List<AGUIMessage> aguiList = [aguiMessage];
|
||||
ChatMessage reconstructedMessage = aguiList.AsChatMessages(AGUIJsonSerializerContext.Default.Options).Single();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.Tool, reconstructedMessage.Role);
|
||||
FunctionResultContent reconstructedResult = Assert.IsType<FunctionResultContent>(reconstructedMessage.Contents[0]);
|
||||
Assert.Equal("call_roundtrip", reconstructedResult.CallId);
|
||||
Assert.NotNull(reconstructedResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapChatRole_WithToolRole_ReturnsToolChatRole()
|
||||
{
|
||||
// Arrange & Act
|
||||
ChatRole role = AGUIChatMessageExtensions.MapChatRole(AGUIRoles.Tool);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.Tool, role);
|
||||
}
|
||||
|
||||
#region Custom Type Serialization Tests
|
||||
|
||||
[Fact]
|
||||
public void AsChatMessages_WithFunctionCallContainingCustomType_SerializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var customRequest = new WeatherRequest { Location = "Seattle", Units = "fahrenheit", IncludeForecast = true };
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["location"] = customRequest.Location,
|
||||
["units"] = customRequest.Units,
|
||||
["includeForecast"] = customRequest.IncludeForecast
|
||||
};
|
||||
|
||||
List<AGUIMessage> aguiMessages =
|
||||
[
|
||||
new AGUIAssistantMessage
|
||||
{
|
||||
Id = "msg1",
|
||||
ToolCalls =
|
||||
[
|
||||
new AGUIToolCall
|
||||
{
|
||||
Id = "call_1",
|
||||
Function = new AGUIFunctionCall
|
||||
{
|
||||
Name = "GetWeather",
|
||||
Arguments = System.Text.Json.JsonSerializer.Serialize(parameters, AGUIJsonSerializerContext.Default.Options)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Combine contexts for serialization
|
||||
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
||||
AGUIJsonSerializerContext.Default,
|
||||
CustomTypesContext.Default)
|
||||
};
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> chatMessages = aguiMessages.AsChatMessages(combinedOptions);
|
||||
|
||||
// Assert
|
||||
ChatMessage message = Assert.Single(chatMessages);
|
||||
Assert.Equal(ChatRole.Assistant, message.Role);
|
||||
var toolCallContent = Assert.IsType<FunctionCallContent>(message.Contents.First());
|
||||
Assert.Equal("call_1", toolCallContent.CallId);
|
||||
Assert.Equal("GetWeather", toolCallContent.Name);
|
||||
Assert.NotNull(toolCallContent.Arguments);
|
||||
// Compare as strings since deserialization produces JsonElement objects
|
||||
Assert.Equal("Seattle", ((System.Text.Json.JsonElement)toolCallContent.Arguments["location"]!).GetString());
|
||||
Assert.Equal("fahrenheit", ((System.Text.Json.JsonElement)toolCallContent.Arguments["units"]!).GetString());
|
||||
Assert.True(toolCallContent.Arguments["includeForecast"] is System.Text.Json.JsonElement j && j.GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_WithFunctionResultContainingCustomType_SerializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var customResponse = new WeatherResponse { Temperature = 72.5, Conditions = "Sunny", Timestamp = DateTime.UtcNow };
|
||||
var resultObject = new Dictionary<string, object?>
|
||||
{
|
||||
["temperature"] = customResponse.Temperature,
|
||||
["conditions"] = customResponse.Conditions,
|
||||
["timestamp"] = customResponse.Timestamp.ToString("O")
|
||||
};
|
||||
|
||||
var resultJson = System.Text.Json.JsonSerializer.Serialize(resultObject, AGUIJsonSerializerContext.Default.Options);
|
||||
var functionResult = new FunctionResultContent("call_1", System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(resultJson, AGUIJsonSerializerContext.Default.Options));
|
||||
List<ChatMessage> chatMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Tool, [functionResult])
|
||||
];
|
||||
|
||||
// Combine contexts for serialization
|
||||
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
||||
AGUIJsonSerializerContext.Default,
|
||||
CustomTypesContext.Default)
|
||||
};
|
||||
|
||||
// Act
|
||||
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(combinedOptions);
|
||||
|
||||
// Assert
|
||||
AGUIMessage message = Assert.Single(aguiMessages);
|
||||
var toolMessage = Assert.IsType<AGUIToolMessage>(message);
|
||||
Assert.Equal("call_1", toolMessage.ToolCallId);
|
||||
Assert.NotNull(toolMessage.Content);
|
||||
|
||||
// Verify the content can be deserialized back
|
||||
var deserializedResult = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(
|
||||
toolMessage.Content,
|
||||
combinedOptions);
|
||||
Assert.NotNull(deserializedResult);
|
||||
Assert.Equal(72.5, deserializedResult["temperature"].GetDouble());
|
||||
Assert.Equal("Sunny", deserializedResult["conditions"].GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_WithCustomTypesInFunctionCallAndResult_PreservesData()
|
||||
{
|
||||
// Arrange
|
||||
var customRequest = new WeatherRequest { Location = "New York", Units = "celsius", IncludeForecast = false };
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["location"] = customRequest.Location,
|
||||
["units"] = customRequest.Units,
|
||||
["includeForecast"] = customRequest.IncludeForecast
|
||||
};
|
||||
|
||||
var customResponse = new WeatherResponse { Temperature = 22.3, Conditions = "Cloudy", Timestamp = DateTime.UtcNow };
|
||||
var resultObject = new Dictionary<string, object?>
|
||||
{
|
||||
["temperature"] = customResponse.Temperature,
|
||||
["conditions"] = customResponse.Conditions,
|
||||
["timestamp"] = customResponse.Timestamp.ToString("O")
|
||||
};
|
||||
|
||||
var resultJson = System.Text.Json.JsonSerializer.Serialize(resultObject, AGUIJsonSerializerContext.Default.Options);
|
||||
var resultElement = System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(resultJson, AGUIJsonSerializerContext.Default.Options);
|
||||
|
||||
List<ChatMessage> originalChatMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_1", "GetWeather", parameters)]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call_1", resultElement)])
|
||||
];
|
||||
|
||||
// Combine contexts for serialization
|
||||
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
||||
AGUIJsonSerializerContext.Default,
|
||||
CustomTypesContext.Default)
|
||||
};
|
||||
|
||||
// Act - Convert to AGUI messages and back
|
||||
IEnumerable<AGUIMessage> aguiMessages = originalChatMessages.AsAGUIMessages(combinedOptions);
|
||||
List<ChatMessage> roundTrippedChatMessages = aguiMessages.AsChatMessages(combinedOptions).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, roundTrippedChatMessages.Count);
|
||||
|
||||
// Verify function call
|
||||
ChatMessage callMessage = roundTrippedChatMessages[0];
|
||||
Assert.Equal(ChatRole.Assistant, callMessage.Role);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(callMessage.Contents.First());
|
||||
Assert.Equal("call_1", functionCall.CallId);
|
||||
Assert.Equal("GetWeather", functionCall.Name);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
// Compare string values from JsonElement
|
||||
Assert.Equal(customRequest.Location, functionCall.Arguments["location"]?.ToString());
|
||||
Assert.Equal(customRequest.Units, functionCall.Arguments["units"]?.ToString());
|
||||
|
||||
// Verify function result
|
||||
ChatMessage resultMessage = roundTrippedChatMessages[1];
|
||||
Assert.Equal(ChatRole.Tool, resultMessage.Role);
|
||||
var functionResultContent = Assert.IsType<FunctionResultContent>(resultMessage.Contents.First());
|
||||
Assert.Equal("call_1", functionResultContent.CallId);
|
||||
Assert.NotNull(functionResultContent.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_WithNestedCustomObjects_HandlesComplexSerialization()
|
||||
{
|
||||
// Arrange - nested custom types
|
||||
var nestedParameters = new Dictionary<string, object?>
|
||||
{
|
||||
["request"] = new Dictionary<string, object?>
|
||||
{
|
||||
["location"] = "Boston",
|
||||
["options"] = new Dictionary<string, object?>
|
||||
{
|
||||
["units"] = "fahrenheit",
|
||||
["includeHumidity"] = true,
|
||||
["daysAhead"] = 5
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var functionCall = new FunctionCallContent("call_nested", "GetDetailedWeather", nestedParameters);
|
||||
List<ChatMessage> chatMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [functionCall])
|
||||
];
|
||||
|
||||
// Combine contexts for serialization
|
||||
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
||||
AGUIJsonSerializerContext.Default,
|
||||
CustomTypesContext.Default)
|
||||
};
|
||||
|
||||
// Act
|
||||
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(combinedOptions);
|
||||
|
||||
// Assert
|
||||
AGUIMessage message = Assert.Single(aguiMessages);
|
||||
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(message);
|
||||
Assert.NotNull(assistantMessage.ToolCalls);
|
||||
var toolCall = Assert.Single(assistantMessage.ToolCalls);
|
||||
Assert.Equal("call_nested", toolCall.Id);
|
||||
Assert.Equal("GetDetailedWeather", toolCall.Function?.Name);
|
||||
|
||||
// Verify nested structure is preserved
|
||||
var deserializedArgs = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(
|
||||
toolCall.Function?.Arguments ?? "{}",
|
||||
combinedOptions);
|
||||
Assert.NotNull(deserializedArgs);
|
||||
Assert.True(deserializedArgs.ContainsKey("request"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUIMessages_WithDictionaryContainingCustomTypes_SerializesDirectly()
|
||||
{
|
||||
// Arrange - Create a dictionary with custom type values (not flattened)
|
||||
var customRequest = new WeatherRequest { Location = "Tokyo", Units = "celsius", IncludeForecast = true };
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["customRequest"] = customRequest, // Custom type as value
|
||||
["simpleString"] = "test",
|
||||
["simpleNumber"] = 42
|
||||
};
|
||||
|
||||
List<ChatMessage> chatMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_custom", "ProcessWeather", parameters)])
|
||||
];
|
||||
|
||||
// Combine contexts for serialization
|
||||
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
||||
AGUIJsonSerializerContext.Default,
|
||||
CustomTypesContext.Default)
|
||||
};
|
||||
|
||||
// Act
|
||||
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(combinedOptions);
|
||||
|
||||
// Assert
|
||||
AGUIMessage message = Assert.Single(aguiMessages);
|
||||
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(message);
|
||||
Assert.NotNull(assistantMessage.ToolCalls);
|
||||
var toolCall = Assert.Single(assistantMessage.ToolCalls);
|
||||
Assert.Equal("call_custom", toolCall.Id);
|
||||
Assert.Equal("ProcessWeather", toolCall.Function?.Name);
|
||||
|
||||
// Verify custom type was serialized correctly without flattening
|
||||
var deserializedArgs = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(
|
||||
toolCall.Function?.Arguments ?? "{}",
|
||||
combinedOptions);
|
||||
Assert.NotNull(deserializedArgs);
|
||||
Assert.True(deserializedArgs.ContainsKey("customRequest"));
|
||||
Assert.True(deserializedArgs.ContainsKey("simpleString"));
|
||||
Assert.True(deserializedArgs.ContainsKey("simpleNumber"));
|
||||
|
||||
// Verify the custom type properties are accessible
|
||||
var customRequestElement = deserializedArgs["customRequest"];
|
||||
Assert.Equal("Tokyo", customRequestElement.GetProperty("Location").GetString());
|
||||
Assert.Equal("celsius", customRequestElement.GetProperty("Units").GetString());
|
||||
Assert.True(customRequestElement.GetProperty("IncludeForecast").GetBoolean());
|
||||
|
||||
// Verify simple types
|
||||
Assert.Equal("test", deserializedArgs["simpleString"].GetString());
|
||||
Assert.Equal(42, deserializedArgs["simpleNumber"].GetInt32());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.AGUI.Shared;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.AGUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AGUIHttpService"/> class.
|
||||
/// </summary>
|
||||
public sealed class AGUIHttpServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PostRunAsync_SendsRequestAndParsesSSEStream_SuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
BaseEvent[] events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
|
||||
new TextMessageEndEvent { MessageId = "msg1" },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
HttpClient httpClient = CreateMockHttpClient(events, HttpStatusCode.OK);
|
||||
AGUIHttpService service = new(httpClient, "http://localhost/agent");
|
||||
RunAgentInput input = new()
|
||||
{
|
||||
ThreadId = "thread1",
|
||||
RunId = "run1",
|
||||
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
List<BaseEvent> resultEvents = [];
|
||||
await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None))
|
||||
{
|
||||
resultEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, resultEvents.Count);
|
||||
Assert.IsType<RunStartedEvent>(resultEvents[0]);
|
||||
Assert.IsType<TextMessageStartEvent>(resultEvents[1]);
|
||||
Assert.IsType<TextMessageContentEvent>(resultEvents[2]);
|
||||
Assert.IsType<TextMessageEndEvent>(resultEvents[3]);
|
||||
Assert.IsType<RunFinishedEvent>(resultEvents[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostRunAsync_WithNonSuccessStatusCode_ThrowsHttpRequestExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
HttpClient httpClient = CreateMockHttpClient([], HttpStatusCode.InternalServerError);
|
||||
AGUIHttpService service = new(httpClient, "http://localhost/agent");
|
||||
RunAgentInput input = new()
|
||||
{
|
||||
ThreadId = "thread1",
|
||||
RunId = "run1",
|
||||
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
await foreach (var _ in service.PostRunAsync(input, CancellationToken.None))
|
||||
{
|
||||
// Consume the stream
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostRunAsync_DeserializesMultipleEventTypes_CorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
BaseEvent[] events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new RunErrorEvent { Message = "Error occurred", Code = "ERR001" },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonElement.Parse("\"Success\"") }
|
||||
];
|
||||
|
||||
HttpClient httpClient = CreateMockHttpClient(events, HttpStatusCode.OK);
|
||||
AGUIHttpService service = new(httpClient, "http://localhost/agent");
|
||||
RunAgentInput input = new()
|
||||
{
|
||||
ThreadId = "thread1",
|
||||
RunId = "run1",
|
||||
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
List<BaseEvent> resultEvents = [];
|
||||
await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None))
|
||||
{
|
||||
resultEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, resultEvents.Count);
|
||||
RunStartedEvent startedEvent = Assert.IsType<RunStartedEvent>(resultEvents[0]);
|
||||
Assert.Equal("thread1", startedEvent.ThreadId);
|
||||
RunErrorEvent errorEvent = Assert.IsType<RunErrorEvent>(resultEvents[1]);
|
||||
Assert.Equal("Error occurred", errorEvent.Message);
|
||||
RunFinishedEvent finishedEvent = Assert.IsType<RunFinishedEvent>(resultEvents[2]);
|
||||
Assert.Equal("Success", finishedEvent.Result?.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostRunAsync_WithEmptyEventStream_CompletesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
HttpClient httpClient = CreateMockHttpClient([], HttpStatusCode.OK);
|
||||
AGUIHttpService service = new(httpClient, "http://localhost/agent");
|
||||
RunAgentInput input = new()
|
||||
{
|
||||
ThreadId = "thread1",
|
||||
RunId = "run1",
|
||||
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
List<BaseEvent> resultEvents = [];
|
||||
await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None))
|
||||
{
|
||||
resultEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Empty(resultEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostRunAsync_WithCancellationToken_CancelsRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
CancellationTokenSource cts = new();
|
||||
cts.Cancel();
|
||||
|
||||
Mock<HttpMessageHandler> handlerMock = new(MockBehavior.Strict);
|
||||
handlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new TaskCanceledException());
|
||||
|
||||
HttpClient httpClient = new(handlerMock.Object);
|
||||
AGUIHttpService service = new(httpClient, "http://localhost/agent");
|
||||
RunAgentInput input = new()
|
||||
{
|
||||
ThreadId = "thread1",
|
||||
RunId = "run1",
|
||||
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(async () =>
|
||||
{
|
||||
await foreach (var _ in service.PostRunAsync(input, cts.Token))
|
||||
{
|
||||
// Intentionally empty - consuming stream to trigger cancellation
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static HttpClient CreateMockHttpClient(BaseEvent[] events, HttpStatusCode statusCode)
|
||||
{
|
||||
string sseContent = string.Concat(events.Select(e =>
|
||||
$"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
|
||||
|
||||
Mock<HttpMessageHandler> handlerMock = new(MockBehavior.Strict);
|
||||
handlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
Content = new StringContent(sseContent)
|
||||
});
|
||||
|
||||
return new HttpClient(handlerMock.Object);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.AGUI.Shared;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.AGUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIToolExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AIToolExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AsAGUITools_WithAIFunction_ConvertsToAGUIToolCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
AIFunction function = AIFunctionFactory.Create(
|
||||
(string location) => $"Weather in {location}",
|
||||
"GetWeather",
|
||||
"Gets the current weather");
|
||||
List<AITool> tools = [function];
|
||||
|
||||
// Act
|
||||
List<AGUITool> aguiTools = tools.AsAGUITools().ToList();
|
||||
|
||||
// Assert
|
||||
AGUITool aguiTool = Assert.Single(aguiTools);
|
||||
Assert.Equal("GetWeather", aguiTool.Name);
|
||||
Assert.Equal("Gets the current weather", aguiTool.Description);
|
||||
Assert.NotEqual(default, aguiTool.Parameters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUITools_WithMultipleFunctions_ConvertsAllCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
List<AITool> tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Result1", "Tool1", "First tool"),
|
||||
AIFunctionFactory.Create(() => "Result2", "Tool2", "Second tool"),
|
||||
AIFunctionFactory.Create(() => "Result3", "Tool3", "Third tool")
|
||||
];
|
||||
|
||||
// Act
|
||||
List<AGUITool> aguiTools = tools.AsAGUITools().ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, aguiTools.Count);
|
||||
Assert.Equal("Tool1", aguiTools[0].Name);
|
||||
Assert.Equal("Tool2", aguiTools[1].Name);
|
||||
Assert.Equal("Tool3", aguiTools[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUITools_WithNullInput_ReturnsEmptyEnumerable()
|
||||
{
|
||||
// Arrange
|
||||
IEnumerable<AITool>? tools = null;
|
||||
|
||||
// Act
|
||||
IEnumerable<AGUITool> aguiTools = tools!.AsAGUITools();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(aguiTools);
|
||||
Assert.Empty(aguiTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUITools_WithEmptyInput_ReturnsEmptyEnumerable()
|
||||
{
|
||||
// Arrange
|
||||
List<AITool> tools = [];
|
||||
|
||||
// Act
|
||||
List<AGUITool> aguiTools = tools.AsAGUITools().ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(aguiTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAGUITools_FiltersOutNonAIFunctionTools()
|
||||
{
|
||||
// Arrange - mix of AIFunction and non-function tools
|
||||
AIFunction function = AIFunctionFactory.Create(() => "Result", "TestTool");
|
||||
// Create a custom AITool that's not an AIFunction
|
||||
var declaration = AIFunctionFactory.CreateDeclaration("DeclarationOnly", "Description", JsonElement.Parse("{}"));
|
||||
|
||||
List<AITool> tools = [function, declaration];
|
||||
|
||||
// Act
|
||||
List<AGUITool> aguiTools = tools.AsAGUITools().ToList();
|
||||
|
||||
// Assert
|
||||
// Only the AIFunction should be converted, declarations are filtered
|
||||
Assert.Equal(2, aguiTools.Count); // Actually both convert since declaration is also AIFunctionDeclaration
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAITools_WithAGUITool_ConvertsToAIFunctionDeclarationCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
AGUITool aguiTool = new()
|
||||
{
|
||||
Name = "TestTool",
|
||||
Description = "Test description",
|
||||
Parameters = JsonElement.Parse("""{"type":"object","properties":{}}""")
|
||||
};
|
||||
List<AGUITool> aguiTools = [aguiTool];
|
||||
|
||||
// Act
|
||||
List<AITool> tools = aguiTools.AsAITools().ToList();
|
||||
|
||||
// Assert
|
||||
AITool tool = Assert.Single(tools);
|
||||
Assert.IsType<AIFunctionDeclaration>(tool, exactMatch: false);
|
||||
var declaration = (AIFunctionDeclaration)tool;
|
||||
Assert.Equal("TestTool", declaration.Name);
|
||||
Assert.Equal("Test description", declaration.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAITools_WithMultipleAGUITools_ConvertsAllCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUITool> aguiTools =
|
||||
[
|
||||
new AGUITool { Name = "Tool1", Description = "Desc1", Parameters = JsonElement.Parse("{}") },
|
||||
new AGUITool { Name = "Tool2", Description = "Desc2", Parameters = JsonElement.Parse("{}") },
|
||||
new AGUITool { Name = "Tool3", Description = "Desc3", Parameters = JsonElement.Parse("{}") }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<AITool> tools = aguiTools.AsAITools().ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, tools.Count);
|
||||
Assert.All(tools, t => Assert.IsType<AIFunctionDeclaration>(t, exactMatch: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAITools_WithNullInput_ReturnsEmptyEnumerable()
|
||||
{
|
||||
// Arrange
|
||||
IEnumerable<AGUITool>? aguiTools = null;
|
||||
|
||||
// Act
|
||||
IEnumerable<AITool> tools = aguiTools!.AsAITools();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(tools);
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAITools_WithEmptyInput_ReturnsEmptyEnumerable()
|
||||
{
|
||||
// Arrange
|
||||
List<AGUITool> aguiTools = [];
|
||||
|
||||
// Act
|
||||
List<AITool> tools = aguiTools.AsAITools().ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAITools_CreatesDeclarationsOnly_NotInvokableFunctions()
|
||||
{
|
||||
// Arrange
|
||||
AGUITool aguiTool = new()
|
||||
{
|
||||
Name = "RemoteTool",
|
||||
Description = "Tool implemented on server",
|
||||
Parameters = JsonElement.Parse("""{"type":"object"}""")
|
||||
};
|
||||
|
||||
// Act
|
||||
List<AGUITool> aguiToolsList = [aguiTool];
|
||||
AITool tool = aguiToolsList.AsAITools().Single();
|
||||
|
||||
// Assert
|
||||
// The tool should be a declaration, not an executable function
|
||||
Assert.IsType<AIFunctionDeclaration>(tool, exactMatch: false);
|
||||
// AIFunctionDeclaration cannot be invoked (no implementation)
|
||||
// This is correct since the actual implementation exists on the client side
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_AIFunctionToAGUIToolBackToDeclaration_PreservesMetadata()
|
||||
{
|
||||
// Arrange
|
||||
AIFunction originalFunction = AIFunctionFactory.Create(
|
||||
(string name, int age) => $"{name} is {age} years old",
|
||||
"FormatPerson",
|
||||
"Formats person information");
|
||||
|
||||
// Act
|
||||
List<AIFunction> originalList = [originalFunction];
|
||||
AGUITool aguiTool = originalList.AsAGUITools().Single();
|
||||
List<AGUITool> aguiToolsList = [aguiTool];
|
||||
AITool reconstructed = aguiToolsList.AsAITools().Single();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<AIFunctionDeclaration>(reconstructed, exactMatch: false);
|
||||
var declaration = (AIFunctionDeclaration)reconstructed;
|
||||
Assert.Equal("FormatPerson", declaration.Name);
|
||||
Assert.Equal("Formats person information", declaration.Description);
|
||||
// Schema should be preserved through the round trip
|
||||
Assert.NotEqual(default, declaration.JsonSchema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
// 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.Agents.AI.AGUI.Shared;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.AGUI.UnitTests;
|
||||
|
||||
public sealed class ChatResponseUpdateAGUIExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_ConvertsRunStartedEvent_ToResponseUpdateWithMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
Assert.Equal(ChatRole.Assistant, updates[0].Role);
|
||||
Assert.Equal("run1", updates[0].ResponseId);
|
||||
Assert.NotNull(updates[0].CreatedAt);
|
||||
Assert.Equal("thread1", updates[0].ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_ConvertsRunFinishedEvent_ToResponseUpdateWithMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonSerializer.SerializeToElement("Success") }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, updates.Count);
|
||||
// First update is RunStarted
|
||||
Assert.Equal(ChatRole.Assistant, updates[0].Role);
|
||||
Assert.Equal("run1", updates[0].ResponseId);
|
||||
// Second update is RunFinished
|
||||
Assert.Equal(ChatRole.Assistant, updates[1].Role);
|
||||
Assert.Equal("run1", updates[1].ResponseId);
|
||||
Assert.NotNull(updates[1].CreatedAt);
|
||||
TextContent content = Assert.IsType<TextContent>(updates[1].Contents[0]);
|
||||
Assert.Equal("\"Success\"", content.Text); // JSON string representation includes quotes
|
||||
// ConversationId is stored in the ChatResponseUpdate
|
||||
Assert.Equal("thread1", updates[1].ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_ConvertsRunErrorEvent_ToErrorContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunErrorEvent { Message = "Error occurred", Code = "ERR001" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
Assert.Equal(ChatRole.Assistant, updates[0].Role);
|
||||
ErrorContent content = Assert.IsType<ErrorContent>(updates[0].Contents[0]);
|
||||
Assert.Equal("Error occurred", content.Message);
|
||||
// Code is stored in ErrorCode property
|
||||
Assert.Equal("ERR001", content.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_ConvertsTextMessageSequence_ToTextUpdatesWithCorrectRoleAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = " World" },
|
||||
new TextMessageEndEvent { MessageId = "msg1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, updates.Count);
|
||||
Assert.All(updates, u => Assert.Equal(ChatRole.Assistant, u.Role));
|
||||
Assert.Equal("Hello", ((TextContent)updates[0].Contents[0]).Text);
|
||||
Assert.Equal(" World", ((TextContent)updates[1].Contents[0]).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithTextMessageStartWhileMessageInProgress_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
|
||||
new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.User }
|
||||
];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
// Intentionally empty - consuming stream to trigger exception
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithTextMessageEndForWrongMessageId_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
|
||||
new TextMessageEndEvent { MessageId = "msg2" }
|
||||
];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
// Intentionally empty - consuming stream to trigger exception
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_MaintainsMessageContext_AcrossMultipleContentEventsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = " " },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = "World" },
|
||||
new TextMessageEndEvent { MessageId = "msg1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, updates.Count);
|
||||
Assert.All(updates, u => Assert.Equal(ChatRole.Assistant, u.Role));
|
||||
Assert.All(updates, u => Assert.Equal("msg1", u.MessageId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_ConvertsToolCallEvents_ToFunctionCallContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "GetWeather", ParentMessageId = "msg1" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"location\":" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "\"Seattle\"}" },
|
||||
new ToolCallEndEvent { ToolCallId = "call_1" },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
ChatResponseUpdate toolCallUpdate = updates.First(u => u.Contents.Any(c => c is FunctionCallContent));
|
||||
FunctionCallContent functionCall = Assert.IsType<FunctionCallContent>(toolCallUpdate.Contents[0]);
|
||||
Assert.Equal("call_1", functionCall.CallId);
|
||||
Assert.Equal("GetWeather", functionCall.Name);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Equal("Seattle", functionCall.Arguments!["location"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithMultipleToolCallArgsEvents_AccumulatesArgsCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "TestTool", ParentMessageId = "msg1" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"par" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "t1\":\"val" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "ue1\",\"part2" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "\":\"value2\"}" },
|
||||
new ToolCallEndEvent { ToolCallId = "call_1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
FunctionCallContent functionCall = updates
|
||||
.SelectMany(u => u.Contents)
|
||||
.OfType<FunctionCallContent>()
|
||||
.Single();
|
||||
Assert.Equal("value1", functionCall.Arguments!["part1"]?.ToString());
|
||||
Assert.Equal("value2", functionCall.Arguments!["part2"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithEmptyToolCallArgs_HandlesGracefullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "NoArgsTool", ParentMessageId = "msg1" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "" },
|
||||
new ToolCallEndEvent { ToolCallId = "call_1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
FunctionCallContent functionCall = updates
|
||||
.SelectMany(u => u.Contents)
|
||||
.OfType<FunctionCallContent>()
|
||||
.Single();
|
||||
Assert.Equal("call_1", functionCall.CallId);
|
||||
Assert.Equal("NoArgsTool", functionCall.Name);
|
||||
Assert.Null(functionCall.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithOverlappingToolCalls_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
|
||||
new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg1" } // Second start before first ends
|
||||
];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
// Consume stream to trigger exception
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithMismatchedToolCallId_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" } // Wrong call ID
|
||||
];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
// Consume stream to trigger exception
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithMismatchedToolCallEndId_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
|
||||
new ToolCallEndEvent { ToolCallId = "call_2" } // Wrong call ID
|
||||
];
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
// Consume stream to trigger exception
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithMultipleSequentialToolCalls_ProcessesAllCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"arg1\":\"val1\"}" },
|
||||
new ToolCallEndEvent { ToolCallId = "call_1" },
|
||||
new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg2" },
|
||||
new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{\"arg2\":\"val2\"}" },
|
||||
new ToolCallEndEvent { ToolCallId = "call_2" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
List<FunctionCallContent> functionCalls = updates
|
||||
.SelectMany(u => u.Contents)
|
||||
.OfType<FunctionCallContent>()
|
||||
.ToList();
|
||||
Assert.Equal(2, functionCalls.Count);
|
||||
Assert.Equal("call_1", functionCalls[0].CallId);
|
||||
Assert.Equal("Tool1", functionCalls[0].Name);
|
||||
Assert.Equal("call_2", functionCalls[1].CallId);
|
||||
Assert.Equal("Tool2", functionCalls[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_ConvertsStateSnapshotEvent_ToDataContentWithJsonAsync()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(new { counter = 42, status = "active" });
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new StateSnapshotEvent { Snapshot = stateSnapshot },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
|
||||
Assert.Equal(ChatRole.Assistant, stateUpdate.Role);
|
||||
Assert.Equal("thread1", stateUpdate.ConversationId);
|
||||
Assert.Equal("run1", stateUpdate.ResponseId);
|
||||
|
||||
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
|
||||
Assert.Equal("application/json", dataContent.MediaType);
|
||||
|
||||
// Verify the JSON content
|
||||
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
|
||||
JsonElement deserializedState = JsonElement.Parse(jsonText);
|
||||
Assert.Equal(42, deserializedState.GetProperty("counter").GetInt32());
|
||||
Assert.Equal("active", deserializedState.GetProperty("status").GetString());
|
||||
|
||||
// Verify additional properties
|
||||
Assert.NotNull(stateUpdate.AdditionalProperties);
|
||||
Assert.True((bool)stateUpdate.AdditionalProperties["is_state_snapshot"]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithNullStateSnapshot_DoesNotEmitUpdateAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new StateSnapshotEvent { Snapshot = null },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithEmptyObjectStateSnapshot_EmitsDataContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement emptyState = JsonSerializer.SerializeToElement(new { });
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new StateSnapshotEvent { Snapshot = emptyState },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
|
||||
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
|
||||
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
|
||||
Assert.Equal("{}", jsonText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithComplexStateSnapshot_PreservesJsonStructureAsync()
|
||||
{
|
||||
// Arrange
|
||||
var complexState = new
|
||||
{
|
||||
user = new { name = "Alice", age = 30 },
|
||||
items = new[] { "item1", "item2", "item3" },
|
||||
metadata = new { timestamp = "2024-01-01T00:00:00Z", version = 2 }
|
||||
};
|
||||
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(complexState);
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new StateSnapshotEvent { Snapshot = stateSnapshot }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
ChatResponseUpdate stateUpdate = updates.First();
|
||||
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
|
||||
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
|
||||
JsonElement roundTrippedState = JsonElement.Parse(jsonText);
|
||||
|
||||
Assert.Equal("Alice", roundTrippedState.GetProperty("user").GetProperty("name").GetString());
|
||||
Assert.Equal(30, roundTrippedState.GetProperty("user").GetProperty("age").GetInt32());
|
||||
Assert.Equal(3, roundTrippedState.GetProperty("items").GetArrayLength());
|
||||
Assert.Equal("item1", roundTrippedState.GetProperty("items")[0].GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithStateSnapshotAndTextMessages_EmitsBothAsync()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement state = JsonSerializer.SerializeToElement(new { step = 1 });
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
|
||||
new TextMessageContentEvent { MessageId = "msg1", Delta = "Processing..." },
|
||||
new TextMessageEndEvent { MessageId = "msg1" },
|
||||
new StateSnapshotEvent { Snapshot = state },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Contains(updates, u => u.Contents.Any(c => c is TextContent));
|
||||
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent));
|
||||
}
|
||||
|
||||
#region State Delta Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_ConvertsStateDeltaEvent_ToDataContentWithJsonPatchAsync()
|
||||
{
|
||||
// Arrange - Create JSON Patch operations (RFC 6902)
|
||||
JsonElement stateDelta = JsonSerializer.SerializeToElement(new object[]
|
||||
{
|
||||
new { op = "replace", path = "/counter", value = 43 },
|
||||
new { op = "add", path = "/newField", value = "test" }
|
||||
});
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new StateDeltaEvent { Delta = stateDelta },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
ChatResponseUpdate deltaUpdate = updates.First(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
|
||||
Assert.Equal(ChatRole.Assistant, deltaUpdate.Role);
|
||||
Assert.Equal("thread1", deltaUpdate.ConversationId);
|
||||
Assert.Equal("run1", deltaUpdate.ResponseId);
|
||||
|
||||
DataContent dataContent = Assert.IsType<DataContent>(deltaUpdate.Contents[0]);
|
||||
Assert.Equal("application/json-patch+json", dataContent.MediaType);
|
||||
|
||||
// Verify the JSON Patch content
|
||||
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
|
||||
JsonElement deserializedDelta = JsonElement.Parse(jsonText);
|
||||
Assert.Equal(JsonValueKind.Array, deserializedDelta.ValueKind);
|
||||
Assert.Equal(2, deserializedDelta.GetArrayLength());
|
||||
|
||||
// Verify first operation
|
||||
JsonElement firstOp = deserializedDelta[0];
|
||||
Assert.Equal("replace", firstOp.GetProperty("op").GetString());
|
||||
Assert.Equal("/counter", firstOp.GetProperty("path").GetString());
|
||||
Assert.Equal(43, firstOp.GetProperty("value").GetInt32());
|
||||
|
||||
// Verify second operation
|
||||
JsonElement secondOp = deserializedDelta[1];
|
||||
Assert.Equal("add", secondOp.GetProperty("op").GetString());
|
||||
Assert.Equal("/newField", secondOp.GetProperty("path").GetString());
|
||||
Assert.Equal("test", secondOp.GetProperty("value").GetString());
|
||||
|
||||
// Verify additional properties
|
||||
Assert.NotNull(deltaUpdate.AdditionalProperties);
|
||||
Assert.True((bool)deltaUpdate.AdditionalProperties["is_state_delta"]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithNullStateDelta_DoesNotEmitUpdateAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new StateDeltaEvent { Delta = null },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert - Only run started and finished should be present
|
||||
Assert.Equal(2, updates.Count);
|
||||
Assert.IsType<ChatResponseUpdate>(updates[0]); // Run started
|
||||
Assert.IsType<ChatResponseUpdate>(updates[1]); // Run finished
|
||||
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithEmptyStateDelta_EmitsUpdateAsync()
|
||||
{
|
||||
// Arrange - Empty JSON Patch array is valid
|
||||
JsonElement emptyDelta = JsonSerializer.SerializeToElement(Array.Empty<object>());
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new StateDeltaEvent { Delta = emptyDelta },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithMultipleStateDeltaEvents_ConvertsAllAsync()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement delta1 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
|
||||
JsonElement delta2 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 2 } });
|
||||
JsonElement delta3 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 3 } });
|
||||
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new StateDeltaEvent { Delta = delta1 },
|
||||
new StateDeltaEvent { Delta = delta2 },
|
||||
new StateDeltaEvent { Delta = delta3 },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var deltaUpdates = updates.Where(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json")).ToList();
|
||||
Assert.Equal(3, deltaUpdates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsAGUIEventStreamAsync_ConvertsDataContentWithJsonPatch_ToStateDeltaEventAsync()
|
||||
{
|
||||
// Arrange - Create a ChatResponseUpdate with JSON Patch DataContent
|
||||
JsonElement patchOps = JsonSerializer.SerializeToElement(new object[]
|
||||
{
|
||||
new { op = "remove", path = "/oldField" },
|
||||
new { op = "add", path = "/newField", value = "newValue" }
|
||||
});
|
||||
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(patchOps);
|
||||
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
|
||||
|
||||
List<ChatResponseUpdate> updates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
|
||||
{
|
||||
MessageId = "msg1"
|
||||
}
|
||||
];
|
||||
|
||||
// Act
|
||||
List<BaseEvent> outputEvents = [];
|
||||
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
outputEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
StateDeltaEvent? deltaEvent = outputEvents.OfType<StateDeltaEvent>().FirstOrDefault();
|
||||
Assert.NotNull(deltaEvent);
|
||||
Assert.NotNull(deltaEvent.Delta);
|
||||
Assert.Equal(JsonValueKind.Array, deltaEvent.Delta.Value.ValueKind);
|
||||
|
||||
// Verify patch operations
|
||||
JsonElement delta = deltaEvent.Delta.Value;
|
||||
Assert.Equal(2, delta.GetArrayLength());
|
||||
Assert.Equal("remove", delta[0].GetProperty("op").GetString());
|
||||
Assert.Equal("/oldField", delta[0].GetProperty("path").GetString());
|
||||
Assert.Equal("add", delta[1].GetProperty("op").GetString());
|
||||
Assert.Equal("/newField", delta[1].GetProperty("path").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsAGUIEventStreamAsync_WithBothSnapshotAndDelta_EmitsBothEventsAsync()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement snapshot = JsonSerializer.SerializeToElement(new { counter = 0 });
|
||||
byte[] snapshotBytes = JsonSerializer.SerializeToUtf8Bytes(snapshot);
|
||||
DataContent snapshotContent = new(snapshotBytes, "application/json");
|
||||
|
||||
JsonElement delta = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
|
||||
byte[] deltaBytes = JsonSerializer.SerializeToUtf8Bytes(delta);
|
||||
DataContent deltaContent = new(deltaBytes, "application/json-patch+json");
|
||||
|
||||
List<ChatResponseUpdate> updates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, [snapshotContent]) { MessageId = "msg1" },
|
||||
new ChatResponseUpdate(ChatRole.Assistant, [deltaContent]) { MessageId = "msg2" }
|
||||
];
|
||||
|
||||
// Act
|
||||
List<BaseEvent> outputEvents = [];
|
||||
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
outputEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Contains(outputEvents, e => e is StateSnapshotEvent);
|
||||
Assert.Contains(outputEvents, e => e is StateDeltaEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StateDeltaEvent_RoundTrip_PreservesJsonPatchOperationsAsync()
|
||||
{
|
||||
// Arrange - Create complex JSON Patch with various operations
|
||||
JsonElement originalDelta = JsonSerializer.SerializeToElement(new object[]
|
||||
{
|
||||
new { op = "add", path = "/user/email", value = "test@example.com" },
|
||||
new { op = "remove", path = "/user/tempData" },
|
||||
new { op = "replace", path = "/user/lastLogin", value = "2025-11-09T12:00:00Z" },
|
||||
new { op = "move", from = "/user/oldAddress", path = "/user/previousAddress" },
|
||||
new { op = "copy", from = "/user/name", path = "/user/displayName" },
|
||||
new { op = "test", path = "/user/version", value = 2 }
|
||||
});
|
||||
|
||||
List<BaseEvent> events =
|
||||
[
|
||||
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
||||
new StateDeltaEvent { Delta = originalDelta },
|
||||
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
||||
];
|
||||
|
||||
// Act - Convert to ChatResponseUpdate and back to events
|
||||
List<ChatResponseUpdate> updates = [];
|
||||
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
List<BaseEvent> roundTripEvents = [];
|
||||
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
|
||||
{
|
||||
roundTripEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
StateDeltaEvent? roundTripDelta = roundTripEvents.OfType<StateDeltaEvent>().FirstOrDefault();
|
||||
Assert.NotNull(roundTripDelta);
|
||||
Assert.NotNull(roundTripDelta.Delta);
|
||||
|
||||
JsonElement delta = roundTripDelta.Delta.Value;
|
||||
Assert.Equal(6, delta.GetArrayLength());
|
||||
|
||||
// Verify each operation type
|
||||
Assert.Equal("add", delta[0].GetProperty("op").GetString());
|
||||
Assert.Equal("remove", delta[1].GetProperty("op").GetString());
|
||||
Assert.Equal("replace", delta[2].GetProperty("op").GetString());
|
||||
Assert.Equal("move", delta[3].GetProperty("op").GetString());
|
||||
Assert.Equal("copy", delta[4].GetProperty("op").GetString());
|
||||
Assert.Equal("test", delta[5].GetProperty("op").GetString());
|
||||
}
|
||||
|
||||
#endregion State Delta Tests
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.AGUI.UnitTests;
|
||||
|
||||
internal static class TestHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension method to convert a synchronous enumerable to an async enumerable for testing purposes.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(this IEnumerable<T> source)
|
||||
{
|
||||
foreach (T item in source)
|
||||
{
|
||||
yield return item;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
// 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 Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIAgent"/> class.
|
||||
/// </summary>
|
||||
public class AIAgentTests
|
||||
{
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly Mock<AgentThread> _agentThreadMock;
|
||||
private readonly AgentResponse _invokeResponse;
|
||||
private readonly List<AgentResponseUpdate> _invokeStreamingResponses = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AIAgentTests"/> class.
|
||||
/// </summary>
|
||||
public AIAgentTests()
|
||||
{
|
||||
this._agentThreadMock = new Mock<AgentThread>(MockBehavior.Strict);
|
||||
|
||||
this._invokeResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hi"));
|
||||
this._invokeStreamingResponses.Add(new AgentResponseUpdate(ChatRole.Assistant, "Hi"));
|
||||
|
||||
this._agentMock = new Mock<AIAgent> { CallBase = true };
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(this._invokeResponse);
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(ToAsyncEnumerableAsync(this._invokeStreamingResponses));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that invoking without a message calls the mocked invoke method with an empty array.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeWithoutMessageCallsMockedInvokeWithEmptyArrayAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentRunOptions();
|
||||
var cancellationToken = default(CancellationToken);
|
||||
|
||||
// Act
|
||||
var response = await this._agentMock.Object.RunAsync(this._agentThreadMock.Object, options, cancellationToken);
|
||||
Assert.Equal(this._invokeResponse, response);
|
||||
|
||||
// Verify that the mocked method was called with the expected parameters
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Verify<Task<AgentResponse>>("RunCoreAsync",
|
||||
Times.Once(),
|
||||
ItExpr.Is<IEnumerable<ChatMessage>>(messages => !messages.Any()),
|
||||
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
|
||||
ItExpr.Is<AgentRunOptions?>(o => o == options),
|
||||
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that invoking with a string message calls the mocked invoke method with the message in the ICollection of messages.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string Message = "Hello, Agent!";
|
||||
var options = new AgentRunOptions();
|
||||
var cancellationToken = default(CancellationToken);
|
||||
|
||||
// Act
|
||||
var response = await this._agentMock.Object.RunAsync(Message, this._agentThreadMock.Object, options, cancellationToken);
|
||||
Assert.Equal(this._invokeResponse, response);
|
||||
|
||||
// Verify that the mocked method was called with the expected parameters
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Verify<Task<AgentResponse>>("RunCoreAsync",
|
||||
Times.Once(),
|
||||
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First().Text == Message),
|
||||
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
|
||||
ItExpr.Is<AgentRunOptions?>(o => o == options),
|
||||
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that invoking with a single message calls the mocked invoke method with the message in the ICollection of messages.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var message = new ChatMessage(ChatRole.User, "Hello, Agent!");
|
||||
var options = new AgentRunOptions();
|
||||
var cancellationToken = default(CancellationToken);
|
||||
|
||||
// Act
|
||||
var response = await this._agentMock.Object.RunAsync(message, this._agentThreadMock.Object, options, cancellationToken);
|
||||
Assert.Equal(this._invokeResponse, response);
|
||||
|
||||
// Verify that the mocked method was called with the expected parameters
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Verify<Task<AgentResponse>>("RunCoreAsync",
|
||||
Times.Once(),
|
||||
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First() == message),
|
||||
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
|
||||
ItExpr.Is<AgentRunOptions?>(o => o == options),
|
||||
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that invoking streaming without a message calls the mocked invoke method with an empty array.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeStreamingWithoutMessageCallsMockedInvokeWithEmptyArrayAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentRunOptions();
|
||||
var cancellationToken = default(CancellationToken);
|
||||
|
||||
// Act
|
||||
await foreach (var response in this._agentMock.Object.RunStreamingAsync(this._agentThreadMock.Object, options, cancellationToken))
|
||||
{
|
||||
// Assert
|
||||
Assert.Contains(response, this._invokeStreamingResponses);
|
||||
}
|
||||
|
||||
// Verify that the mocked method was called with the expected parameters
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Verify<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
Times.Once(),
|
||||
ItExpr.Is<IEnumerable<ChatMessage>>(messages => !messages.Any()),
|
||||
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
|
||||
ItExpr.Is<AgentRunOptions?>(o => o == options),
|
||||
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that invoking streaming with a string message calls the mocked invoke method with the message in the ICollection of messages.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeStreamingWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string Message = "Hello, Agent!";
|
||||
var options = new AgentRunOptions();
|
||||
var cancellationToken = default(CancellationToken);
|
||||
|
||||
// Act
|
||||
await foreach (var response in this._agentMock.Object.RunStreamingAsync(Message, this._agentThreadMock.Object, options, cancellationToken))
|
||||
{
|
||||
// Assert
|
||||
Assert.Contains(response, this._invokeStreamingResponses);
|
||||
}
|
||||
|
||||
// Verify that the mocked method was called with the expected parameters
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Verify<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
Times.Once(),
|
||||
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First().Text == Message),
|
||||
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
|
||||
ItExpr.Is<AgentRunOptions?>(o => o == options),
|
||||
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that invoking streaming with a single message calls the mocked invoke method with the message in the ICollection of messages.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeStreamingWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var message = new ChatMessage(ChatRole.User, "Hello, Agent!");
|
||||
var options = new AgentRunOptions();
|
||||
var cancellationToken = default(CancellationToken);
|
||||
|
||||
// Act
|
||||
await foreach (var response in this._agentMock.Object.RunStreamingAsync(message, this._agentThreadMock.Object, options, cancellationToken))
|
||||
{
|
||||
// Assert
|
||||
Assert.Contains(response, this._invokeStreamingResponses);
|
||||
}
|
||||
|
||||
// Verify that the mocked method was called with the expected parameters
|
||||
this._agentMock
|
||||
.Protected()
|
||||
.Verify<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
Times.Once(),
|
||||
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First() == message),
|
||||
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
|
||||
ItExpr.Is<AgentRunOptions?>(o => o == options),
|
||||
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAgentIDIsIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
string id = agent.Id;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(id);
|
||||
Assert.Equal(id, agent.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAgentIDCanBeProvidedByDerivedAgentClass()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent(id: "test-agent-id");
|
||||
|
||||
// Act
|
||||
string id = agent.Id;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(id);
|
||||
Assert.Equal("test-agent-id", id);
|
||||
}
|
||||
|
||||
#region GetService Method Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns the agent itself when requesting the exact agent type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_RequestingExactAgentType_ReturnsAgent()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
var result = agent.GetService(typeof(MockAgent));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(agent, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns the agent itself when requesting the base AIAgent type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_RequestingAIAgentType_ReturnsAgent()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
var result = agent.GetService(typeof(AIAgent));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(agent, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when requesting an unrelated type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_RequestingUnrelatedType_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
var result = agent.GetService(typeof(string));
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when a service key is provided, even for matching types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_WithServiceKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
var result = agent.GetService(typeof(MockAgent), "some-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_WithNullServiceType_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => agent.GetService(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_Generic_ReturnsCorrectType()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
var result = agent.GetService<MockAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(agent, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method returns null for unrelated types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_Generic_ReturnsNullForUnrelatedType()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
var result = agent.GetService<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Typed mock thread.
|
||||
/// </summary>
|
||||
public abstract class TestAgentThread : AgentThread;
|
||||
|
||||
private sealed class MockAgent : AIAgent
|
||||
{
|
||||
public MockAgent(string? id = null)
|
||||
{
|
||||
this.IdCore = id;
|
||||
}
|
||||
|
||||
protected override string? IdCore { get; }
|
||||
|
||||
public override async ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public override async ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
|
||||
{
|
||||
await Task.Yield();
|
||||
foreach (var update in values)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
public class AIContextProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokedAsync_ReturnsCompletedTaskAsync()
|
||||
{
|
||||
var provider = new TestAIContextProvider();
|
||||
var messages = new ReadOnlyCollection<ChatMessage>([]);
|
||||
var task = provider.InvokedAsync(new(messages, aiContextProviderMessages: null));
|
||||
Assert.Equal(default, task);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ReturnsEmptyElement()
|
||||
{
|
||||
var provider = new TestAIContextProvider();
|
||||
var actual = provider.Serialize();
|
||||
Assert.Equal(default, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Constructor_ThrowsForNullMessages()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokingContext(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Constructor_ThrowsForNullMessages()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokedContext(null!, aiContextProviderMessages: null));
|
||||
}
|
||||
|
||||
#region GetService Method Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns the context provider itself when requesting the exact context provider type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_RequestingExactContextProviderType_ReturnsContextProvider()
|
||||
{
|
||||
// Arrange
|
||||
var contextProvider = new TestAIContextProvider();
|
||||
|
||||
// Act
|
||||
var result = contextProvider.GetService(typeof(TestAIContextProvider));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(contextProvider, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns the context provider itself when requesting the base AIContextProvider type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_RequestingAIContextProviderType_ReturnsContextProvider()
|
||||
{
|
||||
// Arrange
|
||||
var contextProvider = new TestAIContextProvider();
|
||||
|
||||
// Act
|
||||
var result = contextProvider.GetService(typeof(AIContextProvider));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(contextProvider, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when requesting an unrelated type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_RequestingUnrelatedType_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var contextProvider = new TestAIContextProvider();
|
||||
|
||||
// Act
|
||||
var result = contextProvider.GetService(typeof(string));
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when a service key is provided, even for matching types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_WithServiceKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var contextProvider = new TestAIContextProvider();
|
||||
|
||||
// Act
|
||||
var result = contextProvider.GetService(typeof(TestAIContextProvider), "some-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_WithNullServiceType_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var contextProvider = new TestAIContextProvider();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => contextProvider.GetService(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_Generic_ReturnsCorrectType()
|
||||
{
|
||||
// Arrange
|
||||
var contextProvider = new TestAIContextProvider();
|
||||
|
||||
// Act
|
||||
var result = contextProvider.GetService<TestAIContextProvider>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(contextProvider, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method returns null for unrelated types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_Generic_ReturnsNullForUnrelatedType()
|
||||
{
|
||||
// Arrange
|
||||
var contextProvider = new TestAIContextProvider();
|
||||
|
||||
// Act
|
||||
var result = contextProvider.GetService<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestAIContextProvider : AIContextProvider
|
||||
{
|
||||
public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AIContext"/>.
|
||||
/// </summary>
|
||||
public class AIContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetInstructionsRoundtrips()
|
||||
{
|
||||
var context = new AIContext
|
||||
{
|
||||
Instructions = "Test Instructions"
|
||||
};
|
||||
|
||||
Assert.Equal("Test Instructions", context.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetMessagesRoundtrips()
|
||||
{
|
||||
var context = new AIContext
|
||||
{
|
||||
Messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
]
|
||||
};
|
||||
|
||||
Assert.NotNull(context.Messages);
|
||||
Assert.Equal(2, context.Messages.Count);
|
||||
Assert.Equal("Hello", context.Messages[0].Text);
|
||||
Assert.Equal("Hi there!", context.Messages[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetAIFunctionsRoundtrips()
|
||||
{
|
||||
var context = new AIContext
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(() => "Function1", "Function1", "Description1"),
|
||||
AIFunctionFactory.Create(() => "Function2", "Function2", "Description2"),
|
||||
]
|
||||
};
|
||||
|
||||
Assert.NotNull(context.Tools);
|
||||
Assert.Equal(2, context.Tools.Count);
|
||||
Assert.Equal("Function1", context.Tools[0].Name);
|
||||
Assert.Equal("Function2", context.Tools[1].Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="AdditionalPropertiesExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AdditionalPropertiesExtensionsTests
|
||||
{
|
||||
#region Add Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Add_WithValidValue_StoresValueUsingTypeName()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass value = new() { Name = "Test" };
|
||||
|
||||
// Act
|
||||
additionalProperties.Add(value);
|
||||
|
||||
// Assert
|
||||
Assert.True(additionalProperties.ContainsKey(typeof(TestClass).FullName!));
|
||||
Assert.Same(value, additionalProperties[typeof(TestClass).FullName!]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithNullDictionary_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary? additionalProperties = null;
|
||||
TestClass value = new() { Name = "Test" };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => additionalProperties!.Add(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithStringValue_StoresValueCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
const string Value = "test string";
|
||||
|
||||
// Act
|
||||
additionalProperties.Add(Value);
|
||||
|
||||
// Assert
|
||||
Assert.True(additionalProperties.ContainsKey(typeof(string).FullName!));
|
||||
Assert.Equal(Value, additionalProperties[typeof(string).FullName!]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithIntValue_StoresValueCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
const int Value = 42;
|
||||
|
||||
// Act
|
||||
additionalProperties.Add(Value);
|
||||
|
||||
// Assert
|
||||
Assert.True(additionalProperties.ContainsKey(typeof(int).FullName!));
|
||||
Assert.Equal(Value, additionalProperties[typeof(int).FullName!]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_ThrowsArgumentException_WhenSameTypeAddedTwice()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass firstValue = new() { Name = "First" };
|
||||
TestClass secondValue = new() { Name = "Second" };
|
||||
additionalProperties.Add(firstValue);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => additionalProperties.Add(secondValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_WithMultipleDifferentTypes_StoresAllValues()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass testClassValue = new() { Name = "Test" };
|
||||
AnotherTestClass anotherValue = new() { Id = 123 };
|
||||
const string StringValue = "test";
|
||||
|
||||
// Act
|
||||
additionalProperties.Add(testClassValue);
|
||||
additionalProperties.Add(anotherValue);
|
||||
additionalProperties.Add(StringValue);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, additionalProperties.Count);
|
||||
Assert.Same(testClassValue, additionalProperties[typeof(TestClass).FullName!]);
|
||||
Assert.Same(anotherValue, additionalProperties[typeof(AnotherTestClass).FullName!]);
|
||||
Assert.Equal(StringValue, additionalProperties[typeof(string).FullName!]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryAdd Method Tests
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_WithValidValue_ReturnsTrueAndStoresValue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass value = new() { Name = "Test" };
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryAdd(value);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(additionalProperties.ContainsKey(typeof(TestClass).FullName!));
|
||||
Assert.Same(value, additionalProperties[typeof(TestClass).FullName!]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_WithNullDictionary_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary? additionalProperties = null;
|
||||
TestClass value = new() { Name = "Test" };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => additionalProperties!.TryAdd(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_WithExistingType_ReturnsFalseAndKeepsOriginalValue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass firstValue = new() { Name = "First" };
|
||||
TestClass secondValue = new() { Name = "Second" };
|
||||
additionalProperties.Add(firstValue);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryAdd(secondValue);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Single(additionalProperties);
|
||||
Assert.Same(firstValue, additionalProperties[typeof(TestClass).FullName!]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_WithStringValue_ReturnsTrueAndStoresValue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
const string Value = "test string";
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryAdd(Value);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(additionalProperties.ContainsKey(typeof(string).FullName!));
|
||||
Assert.Equal(Value, additionalProperties[typeof(string).FullName!]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_WithIntValue_ReturnsTrueAndStoresValue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
const int Value = 42;
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryAdd(Value);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(additionalProperties.ContainsKey(typeof(int).FullName!));
|
||||
Assert.Equal(Value, additionalProperties[typeof(int).FullName!]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_WithMultipleDifferentTypes_StoresAllValues()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass testClassValue = new() { Name = "Test" };
|
||||
AnotherTestClass anotherValue = new() { Id = 123 };
|
||||
const string StringValue = "test";
|
||||
|
||||
// Act
|
||||
bool result1 = additionalProperties.TryAdd(testClassValue);
|
||||
bool result2 = additionalProperties.TryAdd(anotherValue);
|
||||
bool result3 = additionalProperties.TryAdd(StringValue);
|
||||
|
||||
// Assert
|
||||
Assert.True(result1);
|
||||
Assert.True(result2);
|
||||
Assert.True(result3);
|
||||
Assert.Equal(3, additionalProperties.Count);
|
||||
Assert.Same(testClassValue, additionalProperties[typeof(TestClass).FullName!]);
|
||||
Assert.Same(anotherValue, additionalProperties[typeof(AnotherTestClass).FullName!]);
|
||||
Assert.Equal(StringValue, additionalProperties[typeof(string).FullName!]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryGetValue Method Tests
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_WithExistingValue_ReturnsTrueAndValue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass expectedValue = new() { Name = "Test" };
|
||||
additionalProperties.Add(expectedValue);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryGetValue(out TestClass? actualValue);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.NotNull(actualValue);
|
||||
Assert.Same(expectedValue, actualValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_WithNonExistingValue_ReturnsFalseAndNull()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryGetValue(out TestClass? actualValue);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Null(actualValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_WithNullDictionary_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary? additionalProperties = null;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => additionalProperties!.TryGetValue<TestClass>(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_WithStringValue_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
const string ExpectedValue = "test string";
|
||||
additionalProperties.Add(ExpectedValue);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryGetValue(out string? actualValue);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(ExpectedValue, actualValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_WithIntValue_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
const int ExpectedValue = 42;
|
||||
additionalProperties.Add(ExpectedValue);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryGetValue(out int actualValue);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(ExpectedValue, actualValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_WithWrongType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass testValue = new() { Name = "Test" };
|
||||
additionalProperties.Add(testValue);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryGetValue(out AnotherTestClass? actualValue);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Null(actualValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_AfterTryAddFails_ReturnsOriginalValue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass firstValue = new() { Name = "First" };
|
||||
TestClass secondValue = new() { Name = "Second" };
|
||||
additionalProperties.Add(firstValue);
|
||||
additionalProperties.TryAdd(secondValue);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.TryGetValue(out TestClass? actualValue);
|
||||
|
||||
// Assert
|
||||
Assert.Single(additionalProperties);
|
||||
Assert.True(result);
|
||||
Assert.Same(firstValue, actualValue);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Contains Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Contains_WithExistingType_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass value = new() { Name = "Test" };
|
||||
additionalProperties.Add(value);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.Contains<TestClass>();
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contains_WithNonExistingType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.Contains<TestClass>();
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contains_WithNullDictionary_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary? additionalProperties = null;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => additionalProperties!.Contains<TestClass>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contains_WithDifferentType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass value = new() { Name = "Test" };
|
||||
additionalProperties.Add(value);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.Contains<AnotherTestClass>();
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contains_AfterRemove_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass value = new() { Name = "Test" };
|
||||
additionalProperties.Add(value);
|
||||
additionalProperties.Remove<TestClass>();
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.Contains<TestClass>();
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Remove Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Remove_WithExistingType_ReturnsTrueAndRemovesValue()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass value = new() { Name = "Test" };
|
||||
additionalProperties.Add(value);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.Remove<TestClass>();
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Empty(additionalProperties);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_WithNonExistingType_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.Remove<TestClass>();
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_WithNullDictionary_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary? additionalProperties = null;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => additionalProperties!.Remove<TestClass>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_OnlyRemovesSpecifiedType()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass testValue = new() { Name = "Test" };
|
||||
AnotherTestClass anotherValue = new() { Id = 123 };
|
||||
additionalProperties.Add(testValue);
|
||||
additionalProperties.Add(anotherValue);
|
||||
|
||||
// Act
|
||||
bool result = additionalProperties.Remove<TestClass>();
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Single(additionalProperties);
|
||||
Assert.False(additionalProperties.Contains<TestClass>());
|
||||
Assert.True(additionalProperties.Contains<AnotherTestClass>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_CalledTwice_ReturnsFalseOnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new();
|
||||
TestClass value = new() { Name = "Test" };
|
||||
additionalProperties.Add(value);
|
||||
|
||||
// Act
|
||||
bool firstResult = additionalProperties.Remove<TestClass>();
|
||||
bool secondResult = additionalProperties.Remove<TestClass>();
|
||||
|
||||
// Assert
|
||||
Assert.True(firstResult);
|
||||
Assert.False(secondResult);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helper Classes
|
||||
|
||||
private sealed class TestClass
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class AnotherTestClass
|
||||
{
|
||||
public int Id { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -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.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AgentAbstractionsJsonUtilities"/>
|
||||
/// </summary>
|
||||
public class AgentAbstractionsJsonUtilitiesTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultOptions_HasExpectedConfiguration()
|
||||
{
|
||||
var options = AgentAbstractionsJsonUtilities.DefaultOptions;
|
||||
|
||||
// Must be read-only singleton.
|
||||
Assert.NotNull(options);
|
||||
Assert.Same(options, AgentAbstractionsJsonUtilities.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 = AgentAbstractionsJsonUtilities.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, AgentAbstractionsJsonUtilities.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
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
Assert.NotNull(obj);
|
||||
Assert.Equal(42, obj!.Value);
|
||||
Assert.Null(obj.Optional);
|
||||
Assert.Equal("{\"value\":42}",
|
||||
JsonSerializer.Serialize(obj, AgentAbstractionsJsonUtilities.DefaultOptions)); // null omitted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_SerializesEnumsAsStrings()
|
||||
{
|
||||
Assert.Equal("\"Monday\"", JsonSerializer.Serialize(DayOfWeek.Monday, AgentAbstractionsJsonUtilities.DefaultOptions));
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_UsesCamelCasePropertyNames_ForAgentResponse()
|
||||
{
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hello"));
|
||||
string json = JsonSerializer.Serialize(response, AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
Assert.Contains("\"messages\"", json);
|
||||
Assert.DoesNotContain("\"Messages\"", json);
|
||||
}
|
||||
|
||||
private sealed class NumberContainer
|
||||
{
|
||||
public int Value { get; set; }
|
||||
public string? Optional { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Abstractions.UnitTests.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
public class AgentResponseTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructorWithNullEmptyArgsIsValid()
|
||||
{
|
||||
AgentResponse response;
|
||||
|
||||
response = new();
|
||||
Assert.Empty(response.Messages);
|
||||
Assert.Empty(response.Text);
|
||||
Assert.Null(response.ContinuationToken);
|
||||
|
||||
response = new((IList<ChatMessage>?)null);
|
||||
Assert.Empty(response.Messages);
|
||||
Assert.Empty(response.Text);
|
||||
Assert.Null(response.ContinuationToken);
|
||||
|
||||
Assert.Throws<ArgumentNullException>("message", () => new AgentResponse((ChatMessage)null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithMessagesRoundtrips()
|
||||
{
|
||||
AgentResponse response = new();
|
||||
Assert.NotNull(response.Messages);
|
||||
Assert.Same(response.Messages, response.Messages);
|
||||
|
||||
List<ChatMessage> messages = [];
|
||||
response = new(messages);
|
||||
Assert.Same(messages, response.Messages);
|
||||
|
||||
messages = [];
|
||||
Assert.NotSame(messages, response.Messages);
|
||||
response.Messages = messages;
|
||||
Assert.Same(messages, response.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithChatResponseRoundtrips()
|
||||
{
|
||||
ChatResponse chatResponse = new()
|
||||
{
|
||||
AdditionalProperties = [],
|
||||
CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Messages = [new(ChatRole.Assistant, "This is a test message.")],
|
||||
RawRepresentation = new object(),
|
||||
ResponseId = "responseId",
|
||||
Usage = new UsageDetails(),
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })
|
||||
};
|
||||
|
||||
AgentResponse response = new(chatResponse);
|
||||
Assert.Same(chatResponse.AdditionalProperties, response.AdditionalProperties);
|
||||
Assert.Equal(chatResponse.CreatedAt, response.CreatedAt);
|
||||
Assert.Same(chatResponse.Messages, response.Messages);
|
||||
Assert.Equal(chatResponse.ResponseId, response.ResponseId);
|
||||
Assert.Same(chatResponse, response.RawRepresentation as ChatResponse);
|
||||
Assert.Same(chatResponse.Usage, response.Usage);
|
||||
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), response.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropertiesRoundtrip()
|
||||
{
|
||||
AgentResponse response = new();
|
||||
|
||||
Assert.Null(response.AgentId);
|
||||
response.AgentId = "agentId";
|
||||
Assert.Equal("agentId", response.AgentId);
|
||||
|
||||
Assert.Null(response.ResponseId);
|
||||
response.ResponseId = "id";
|
||||
Assert.Equal("id", response.ResponseId);
|
||||
|
||||
Assert.Null(response.CreatedAt);
|
||||
response.CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
Assert.Equal(new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), response.CreatedAt);
|
||||
|
||||
Assert.Null(response.Usage);
|
||||
UsageDetails usage = new();
|
||||
response.Usage = usage;
|
||||
Assert.Same(usage, response.Usage);
|
||||
|
||||
Assert.Null(response.RawRepresentation);
|
||||
object raw = new();
|
||||
response.RawRepresentation = raw;
|
||||
Assert.Same(raw, response.RawRepresentation);
|
||||
|
||||
Assert.Null(response.AdditionalProperties);
|
||||
AdditionalPropertiesDictionary additionalProps = [];
|
||||
response.AdditionalProperties = additionalProps;
|
||||
Assert.Same(additionalProps, response.AdditionalProperties);
|
||||
|
||||
Assert.Null(response.ContinuationToken);
|
||||
response.ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), response.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonSerializationRoundtrips()
|
||||
{
|
||||
AgentResponse original = new(new ChatMessage(ChatRole.Assistant, "the message"))
|
||||
{
|
||||
AgentId = "agentId",
|
||||
ResponseId = "id",
|
||||
CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Usage = new UsageDetails(),
|
||||
RawRepresentation = new(),
|
||||
AdditionalProperties = new() { ["key"] = "value" },
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(original, AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
|
||||
AgentResponse? result = JsonSerializer.Deserialize<AgentResponse>(json, AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ChatRole.Assistant, result.Messages.Single().Role);
|
||||
Assert.Equal("the message", result.Messages.Single().Text);
|
||||
|
||||
Assert.Equal("agentId", result.AgentId);
|
||||
Assert.Equal("id", result.ResponseId);
|
||||
Assert.Equal(new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), result.CreatedAt);
|
||||
Assert.NotNull(result.Usage);
|
||||
|
||||
Assert.NotNull(result.AdditionalProperties);
|
||||
Assert.Single(result.AdditionalProperties);
|
||||
Assert.True(result.AdditionalProperties.TryGetValue("key", out object? value));
|
||||
Assert.IsType<JsonElement>(value);
|
||||
Assert.Equal("value", ((JsonElement)value!).GetString());
|
||||
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), result.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToStringOutputsText()
|
||||
{
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, $"This is a test.{Environment.NewLine}It's multiple lines."));
|
||||
|
||||
Assert.Equal(response.Text, response.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextGetConcatenatesAllTextContent()
|
||||
{
|
||||
AgentResponse response = new(
|
||||
[
|
||||
new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
[
|
||||
new DataContent("data:image/audio;base64,aGVsbG8="),
|
||||
new DataContent("data:image/image;base64,aGVsbG8="),
|
||||
new FunctionCallContent("callId1", "fc1"),
|
||||
new TextContent("message1-text-1"),
|
||||
new TextContent("message1-text-2"),
|
||||
new FunctionResultContent("callId1", "result"),
|
||||
]),
|
||||
new ChatMessage(ChatRole.Assistant, "message2")
|
||||
]);
|
||||
|
||||
Assert.Equal($"message1-text-1message1-text-2{Environment.NewLine}message2", response.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextGetReturnsEmptyStringWithNoMessages()
|
||||
{
|
||||
AgentResponse response = new();
|
||||
|
||||
Assert.Equal(string.Empty, response.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAgentResponseUpdatesProducesUpdates()
|
||||
{
|
||||
AgentResponse response = new(new ChatMessage(new ChatRole("customRole"), "Text") { MessageId = "someMessage" })
|
||||
{
|
||||
AgentId = "agentId",
|
||||
ResponseId = "12345",
|
||||
CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero),
|
||||
AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 },
|
||||
Usage = new UsageDetails
|
||||
{
|
||||
TotalTokenCount = 100
|
||||
},
|
||||
};
|
||||
|
||||
AgentResponseUpdate[] updates = response.ToAgentResponseUpdates();
|
||||
Assert.NotNull(updates);
|
||||
Assert.Equal(2, updates.Length);
|
||||
|
||||
AgentResponseUpdate update0 = updates[0];
|
||||
Assert.Equal("agentId", update0.AgentId);
|
||||
Assert.Equal("12345", update0.ResponseId);
|
||||
Assert.Equal("someMessage", update0.MessageId);
|
||||
Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt);
|
||||
Assert.Equal("customRole", update0.Role?.Value);
|
||||
Assert.Equal("Text", update0.Text);
|
||||
|
||||
AgentResponseUpdate update1 = updates[1];
|
||||
Assert.Equal("value1", update1.AdditionalProperties?["key1"]);
|
||||
Assert.Equal(42, update1.AdditionalProperties?["key2"]);
|
||||
Assert.IsType<UsageContent>(update1.Contents[0]);
|
||||
UsageContent usageContent = (UsageContent)update1.Contents[0];
|
||||
Assert.Equal(100, usageContent.Details.TotalTokenCount);
|
||||
}
|
||||
|
||||
#if NETFRAMEWORK
|
||||
/// <summary>
|
||||
/// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't
|
||||
/// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based
|
||||
/// serialization is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputSuccess()
|
||||
{
|
||||
// Arrange.
|
||||
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
|
||||
|
||||
// Act.
|
||||
var animal = response.Deserialize<Animal>();
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedResult.Id, animal.Id);
|
||||
Assert.Equal(expectedResult.FullName, animal.FullName);
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputWithJSOSuccess()
|
||||
{
|
||||
// Arrange.
|
||||
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
|
||||
|
||||
// Act.
|
||||
var animal = response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedResult.Id, animal.Id);
|
||||
Assert.Equal(expectedResult.FullName, animal.FullName);
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputFailsWithEmptyString()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, string.Empty));
|
||||
|
||||
// Act & Assert.
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
|
||||
Assert.Equal("The response did not contain JSON to be deserialized.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputFailsWithInvalidJson()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "invalid json"));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.Throws<JsonException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputFailsWithIncorrectTypedJson()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "[]"));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.Throws<JsonException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
|
||||
}
|
||||
|
||||
#if NETFRAMEWORK
|
||||
/// <summary>
|
||||
/// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't
|
||||
/// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based
|
||||
/// serialization is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputSuccess()
|
||||
{
|
||||
// Arrange.
|
||||
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
|
||||
|
||||
// Act.
|
||||
response.TryDeserialize(out Animal? animal);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedResult.Id, animal.Id);
|
||||
Assert.Equal(expectedResult.FullName, animal.FullName);
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputWithJSOSuccess()
|
||||
{
|
||||
// Arrange.
|
||||
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
|
||||
|
||||
// Act.
|
||||
response.TryDeserialize(TestJsonSerializerContext.Default.Options, out Animal? animal);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedResult.Id, animal.Id);
|
||||
Assert.Equal(expectedResult.FullName, animal.FullName);
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputFailsWithEmptyText()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, string.Empty));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.False(response.TryDeserialize<Animal>(TestJsonSerializerContext.Default.Options, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputFailsWithIncorrectTypedJson()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "[]"));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.False(response.TryDeserialize<Animal>(TestJsonSerializerContext.Default.Options, out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
public class AgentResponseUpdateExtensionsTests
|
||||
{
|
||||
public static IEnumerable<object[]> ToAgentResponseCoalescesVariousSequenceAndGapLengthsMemberData()
|
||||
{
|
||||
foreach (bool useAsync in new[] { false, true })
|
||||
{
|
||||
for (int numSequences = 1; numSequences <= 3; numSequences++)
|
||||
{
|
||||
for (int sequenceLength = 1; sequenceLength <= 3; sequenceLength++)
|
||||
{
|
||||
for (int gapLength = 1; gapLength <= 3; gapLength++)
|
||||
{
|
||||
foreach (bool gapBeginningEnd in new[] { false, true })
|
||||
{
|
||||
yield return new object[] { useAsync, numSequences, sequenceLength, gapLength, false };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAgentResponseWithInvalidArgsThrows() =>
|
||||
Assert.Throws<ArgumentNullException>("updates", () => ((List<AgentResponseUpdate>)null!).ToAgentResponse());
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task ToAgentResponseSuccessfullyCreatesResponseAsync(bool useAsync)
|
||||
{
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), AgentId = "agentId" },
|
||||
new(new("human"), ", ") { AuthorName = "Someone", AdditionalProperties = new() { ["a"] = "b" } },
|
||||
new(null, "world!") { CreatedAt = new DateTimeOffset(2025, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["c"] = "d" } },
|
||||
|
||||
new() { Contents = [new UsageContent(new() { InputTokenCount = 1, OutputTokenCount = 2 })] },
|
||||
new() { Contents = [new UsageContent(new() { InputTokenCount = 4, OutputTokenCount = 5 })] },
|
||||
];
|
||||
|
||||
AgentResponse response = useAsync ?
|
||||
updates.ToAgentResponse() :
|
||||
await YieldAsync(updates).ToAgentResponseAsync();
|
||||
Assert.NotNull(response);
|
||||
|
||||
Assert.Equal("agentId", response.AgentId);
|
||||
|
||||
Assert.NotNull(response.Usage);
|
||||
Assert.Equal(5, response.Usage.InputTokenCount);
|
||||
Assert.Equal(7, response.Usage.OutputTokenCount);
|
||||
|
||||
Assert.Equal("someResponse", response.ResponseId);
|
||||
Assert.Equal(new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), response.CreatedAt);
|
||||
|
||||
Assert.Equal(2, response.Messages.Count);
|
||||
|
||||
ChatMessage message = response.Messages[0];
|
||||
Assert.Equal("12345", message.MessageId);
|
||||
Assert.Equal(ChatRole.Assistant, message.Role);
|
||||
Assert.Null(message.AuthorName);
|
||||
Assert.Null(message.AdditionalProperties);
|
||||
Assert.Single(message.Contents);
|
||||
Assert.Equal("Hello", Assert.IsType<TextContent>(message.Contents[0]).Text);
|
||||
|
||||
message = response.Messages[1];
|
||||
Assert.Null(message.MessageId);
|
||||
Assert.Equal(new("human"), message.Role);
|
||||
Assert.Equal("Someone", message.AuthorName);
|
||||
Assert.Single(message.Contents);
|
||||
Assert.Equal(", world!", Assert.IsType<TextContent>(message.Contents[0]).Text);
|
||||
|
||||
Assert.NotNull(response.AdditionalProperties);
|
||||
Assert.Equal(2, response.AdditionalProperties.Count);
|
||||
Assert.Equal("b", response.AdditionalProperties["a"]);
|
||||
Assert.Equal("d", response.AdditionalProperties["c"]);
|
||||
|
||||
Assert.Equal("Hello" + Environment.NewLine + ", world!", response.Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ToAgentResponseCoalescesVariousSequenceAndGapLengthsMemberData))]
|
||||
public async Task ToAgentResponseCoalescesVariousSequenceAndGapLengthsAsync(bool useAsync, int numSequences, int sequenceLength, int gapLength, bool gapBeginningEnd)
|
||||
{
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
|
||||
List<string> expected = [];
|
||||
|
||||
if (gapBeginningEnd)
|
||||
{
|
||||
AddGap();
|
||||
}
|
||||
|
||||
for (int sequenceNum = 0; sequenceNum < numSequences; sequenceNum++)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
for (int i = 0; i < sequenceLength; i++)
|
||||
{
|
||||
string text = $"{(char)('A' + sequenceNum)}{i}";
|
||||
updates.Add(new(null, text));
|
||||
sb.Append(text);
|
||||
}
|
||||
|
||||
expected.Add(sb.ToString());
|
||||
|
||||
if (sequenceNum < numSequences - 1)
|
||||
{
|
||||
AddGap();
|
||||
}
|
||||
}
|
||||
|
||||
if (gapBeginningEnd)
|
||||
{
|
||||
AddGap();
|
||||
}
|
||||
|
||||
void AddGap()
|
||||
{
|
||||
for (int i = 0; i < gapLength; i++)
|
||||
{
|
||||
updates.Add(new() { Contents = [new DataContent("data:image/png;base64,aGVsbG8=")] });
|
||||
}
|
||||
}
|
||||
|
||||
AgentResponse response = useAsync ? await YieldAsync(updates).ToAgentResponseAsync() : updates.ToAgentResponse();
|
||||
Assert.NotNull(response);
|
||||
|
||||
ChatMessage message = response.Messages.Single();
|
||||
Assert.NotNull(message);
|
||||
|
||||
Assert.Equal(expected.Count + (gapLength * (numSequences - 1 + (gapBeginningEnd ? 2 : 0))), message.Contents.Count);
|
||||
|
||||
TextContent[] contents = message.Contents.OfType<TextContent>().ToArray();
|
||||
Assert.Equal(expected.Count, contents.Length);
|
||||
for (int i = 0; i < expected.Count; i++)
|
||||
{
|
||||
Assert.Equal(expected[i], contents[i].Text);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task ToAgentResponseCoalescesTextContentAndTextReasoningContentSeparatelyAsync(bool useAsync)
|
||||
{
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new(null, "A"),
|
||||
new(null, "B"),
|
||||
new(null, "C"),
|
||||
new() { Contents = [new TextReasoningContent("D")] },
|
||||
new() { Contents = [new TextReasoningContent("E")] },
|
||||
new() { Contents = [new TextReasoningContent("F")] },
|
||||
new(null, "G"),
|
||||
new(null, "H"),
|
||||
new() { Contents = [new TextReasoningContent("I")] },
|
||||
new() { Contents = [new TextReasoningContent("J")] },
|
||||
new(null, "K"),
|
||||
new() { Contents = [new TextReasoningContent("L")] },
|
||||
new(null, "M"),
|
||||
new(null, "N"),
|
||||
new() { Contents = [new TextReasoningContent("O")] },
|
||||
new() { Contents = [new TextReasoningContent("P")] },
|
||||
];
|
||||
|
||||
AgentResponse response = useAsync ? await YieldAsync(updates).ToAgentResponseAsync() : updates.ToAgentResponse();
|
||||
ChatMessage message = Assert.Single(response.Messages);
|
||||
Assert.Equal(8, message.Contents.Count);
|
||||
Assert.Equal("ABC", Assert.IsType<TextContent>(message.Contents[0]).Text);
|
||||
Assert.Equal("DEF", Assert.IsType<TextReasoningContent>(message.Contents[1]).Text);
|
||||
Assert.Equal("GH", Assert.IsType<TextContent>(message.Contents[2]).Text);
|
||||
Assert.Equal("IJ", Assert.IsType<TextReasoningContent>(message.Contents[3]).Text);
|
||||
Assert.Equal("K", Assert.IsType<TextContent>(message.Contents[4]).Text);
|
||||
Assert.Equal("L", Assert.IsType<TextReasoningContent>(message.Contents[5]).Text);
|
||||
Assert.Equal("MN", Assert.IsType<TextContent>(message.Contents[6]).Text);
|
||||
Assert.Equal("OP", Assert.IsType<TextReasoningContent>(message.Contents[7]).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToAgentResponseUsesContentExtractedFromContentsAsync()
|
||||
{
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new(null, "Hello, "),
|
||||
new(null, "world!"),
|
||||
new() { Contents = [new UsageContent(new() { TotalTokenCount = 42 })] },
|
||||
];
|
||||
|
||||
AgentResponse response = await YieldAsync(updates).ToAgentResponseAsync();
|
||||
|
||||
Assert.NotNull(response);
|
||||
|
||||
Assert.NotNull(response.Usage);
|
||||
Assert.Equal(42, response.Usage.TotalTokenCount);
|
||||
|
||||
Assert.Equal("Hello, world!", Assert.IsType<TextContent>(Assert.Single(Assert.Single(response.Messages).Contents)).Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task ToAgentResponse_AlternativeTimestampsAsync(bool useAsync)
|
||||
{
|
||||
DateTimeOffset early = new(2024, 1, 1, 10, 0, 0, TimeSpan.Zero);
|
||||
DateTimeOffset middle = new(2024, 1, 1, 11, 0, 0, TimeSpan.Zero);
|
||||
DateTimeOffset late = new(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
DateTimeOffset unixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
|
||||
// Start with an early timestamp
|
||||
new(ChatRole.Tool, "a") { MessageId = "4", CreatedAt = early },
|
||||
|
||||
// Unix epoch (as "null") should not overwrite
|
||||
new(null, "b") { CreatedAt = unixEpoch },
|
||||
|
||||
// Newer timestamp should not overwrite (first timestamp wins)
|
||||
new(null, "c") { CreatedAt = middle },
|
||||
|
||||
// Older timestamp should not overwrite
|
||||
new(null, "d") { CreatedAt = early },
|
||||
|
||||
// Even newer timestamp should not overwrite (first timestamp wins)
|
||||
new(null, "e") { CreatedAt = late },
|
||||
|
||||
// Unix epoch should not overwrite again
|
||||
new(null, "f") { CreatedAt = unixEpoch },
|
||||
|
||||
// null should not overwrite
|
||||
new(null, "g") { CreatedAt = null },
|
||||
];
|
||||
|
||||
AgentResponse response = useAsync ?
|
||||
updates.ToAgentResponse() :
|
||||
await YieldAsync(updates).ToAgentResponseAsync();
|
||||
Assert.Single(response.Messages);
|
||||
|
||||
Assert.Equal("abcdefg", response.Messages[0].Text);
|
||||
Assert.Equal(ChatRole.Tool, response.Messages[0].Role);
|
||||
Assert.Equal(early, response.Messages[0].CreatedAt);
|
||||
Assert.Equal(early, response.CreatedAt);
|
||||
}
|
||||
|
||||
public static IEnumerable<object?[]> ToAgentResponse_TimestampFolding_MemberData()
|
||||
{
|
||||
// Base test cases - first non-null valid timestamp wins
|
||||
var testCases = new (string? timestamp1, string? timestamp2, string? expectedTimestamp)[]
|
||||
{
|
||||
(null, null, null),
|
||||
("2024-01-01T10:00:00Z", null, "2024-01-01T10:00:00Z"),
|
||||
(null, "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"),
|
||||
("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z"), // First timestamp wins
|
||||
("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"), // First timestamp wins
|
||||
("2024-01-01T10:00:00Z", "1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z"),
|
||||
("1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"),
|
||||
};
|
||||
|
||||
// Yield each test case twice, once for useAsync = false and once for useAsync = true
|
||||
foreach (var (timestamp1, timestamp2, expectedTimestamp) in testCases)
|
||||
{
|
||||
yield return new object?[] { false, timestamp1, timestamp2, expectedTimestamp };
|
||||
yield return new object?[] { true, timestamp1, timestamp2, expectedTimestamp };
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ToAgentResponse_TimestampFolding_MemberData))]
|
||||
public async Task ToAgentResponse_TimestampFoldingAsync(bool useAsync, string? timestamp1, string? timestamp2, string? expectedTimestamp)
|
||||
{
|
||||
DateTimeOffset? first = timestamp1 is not null ? DateTimeOffset.Parse(timestamp1) : null;
|
||||
DateTimeOffset? second = timestamp2 is not null ? DateTimeOffset.Parse(timestamp2) : null;
|
||||
DateTimeOffset? expected = expectedTimestamp is not null ? DateTimeOffset.Parse(expectedTimestamp) : null;
|
||||
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new(ChatRole.Assistant, "a") { CreatedAt = first },
|
||||
new(null, "b") { CreatedAt = second },
|
||||
];
|
||||
|
||||
AgentResponse response = useAsync ?
|
||||
updates.ToAgentResponse() :
|
||||
await YieldAsync(updates).ToAgentResponseAsync();
|
||||
|
||||
Assert.Single(response.Messages);
|
||||
Assert.Equal("ab", response.Messages[0].Text);
|
||||
Assert.Equal(expected, response.Messages[0].CreatedAt);
|
||||
Assert.Equal(expected, response.CreatedAt);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> YieldAsync(IEnumerable<AgentResponseUpdate> updates)
|
||||
{
|
||||
foreach (AgentResponseUpdate update in updates)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
public class AgentResponseUpdateTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructorPropsDefaulted()
|
||||
{
|
||||
AgentResponseUpdate update = new();
|
||||
Assert.Null(update.AuthorName);
|
||||
Assert.Null(update.Role);
|
||||
Assert.Empty(update.Text);
|
||||
Assert.Empty(update.Contents);
|
||||
Assert.Null(update.RawRepresentation);
|
||||
Assert.Null(update.AdditionalProperties);
|
||||
Assert.Null(update.ResponseId);
|
||||
Assert.Null(update.MessageId);
|
||||
Assert.Null(update.CreatedAt);
|
||||
Assert.Equal(string.Empty, update.ToString());
|
||||
Assert.Null(update.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithChatResponseUpdateRoundtrips()
|
||||
{
|
||||
ChatResponseUpdate chatResponseUpdate = new()
|
||||
{
|
||||
AdditionalProperties = [],
|
||||
AuthorName = "author",
|
||||
Contents = [new TextContent("hello")],
|
||||
ConversationId = "conversationId",
|
||||
CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
FinishReason = ChatFinishReason.Length,
|
||||
MessageId = "messageId",
|
||||
ModelId = "modelId",
|
||||
RawRepresentation = new object(),
|
||||
ResponseId = "responseId",
|
||||
Role = ChatRole.Assistant,
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
};
|
||||
|
||||
AgentResponseUpdate response = new(chatResponseUpdate);
|
||||
Assert.Same(chatResponseUpdate.AdditionalProperties, response.AdditionalProperties);
|
||||
Assert.Equal(chatResponseUpdate.AuthorName, response.AuthorName);
|
||||
Assert.Same(chatResponseUpdate.Contents, response.Contents);
|
||||
Assert.Equal(chatResponseUpdate.CreatedAt, response.CreatedAt);
|
||||
Assert.Equal(chatResponseUpdate.MessageId, response.MessageId);
|
||||
Assert.Same(chatResponseUpdate, response.RawRepresentation as ChatResponseUpdate);
|
||||
Assert.Equal(chatResponseUpdate.ResponseId, response.ResponseId);
|
||||
Assert.Equal(chatResponseUpdate.Role, response.Role);
|
||||
Assert.Same(chatResponseUpdate.ContinuationToken, response.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropertiesRoundtrip()
|
||||
{
|
||||
AgentResponseUpdate update = new();
|
||||
|
||||
Assert.Null(update.AuthorName);
|
||||
update.AuthorName = "author";
|
||||
Assert.Equal("author", update.AuthorName);
|
||||
|
||||
Assert.Null(update.Role);
|
||||
update.Role = ChatRole.Assistant;
|
||||
Assert.Equal(ChatRole.Assistant, update.Role);
|
||||
|
||||
Assert.Empty(update.Contents);
|
||||
update.Contents.Add(new TextContent("text"));
|
||||
Assert.Single(update.Contents);
|
||||
Assert.Equal("text", update.Text);
|
||||
Assert.Same(update.Contents, update.Contents);
|
||||
IList<AIContent> newList = [new TextContent("text")];
|
||||
update.Contents = newList;
|
||||
Assert.Same(newList, update.Contents);
|
||||
update.Contents = null;
|
||||
Assert.NotNull(update.Contents);
|
||||
Assert.Empty(update.Contents);
|
||||
|
||||
Assert.Empty(update.Text);
|
||||
|
||||
Assert.Null(update.RawRepresentation);
|
||||
object raw = new();
|
||||
update.RawRepresentation = raw;
|
||||
Assert.Same(raw, update.RawRepresentation);
|
||||
|
||||
Assert.Null(update.AdditionalProperties);
|
||||
AdditionalPropertiesDictionary props = new() { ["key"] = "value" };
|
||||
update.AdditionalProperties = props;
|
||||
Assert.Same(props, update.AdditionalProperties);
|
||||
|
||||
Assert.Null(update.ResponseId);
|
||||
update.ResponseId = "id";
|
||||
Assert.Equal("id", update.ResponseId);
|
||||
|
||||
Assert.Null(update.MessageId);
|
||||
update.MessageId = "messageid";
|
||||
Assert.Equal("messageid", update.MessageId);
|
||||
|
||||
Assert.Null(update.CreatedAt);
|
||||
update.CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
Assert.Equal(new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), update.CreatedAt);
|
||||
|
||||
Assert.Null(update.ContinuationToken);
|
||||
update.ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), update.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextGetUsesAllTextContent()
|
||||
{
|
||||
AgentResponseUpdate update = new()
|
||||
{
|
||||
Role = ChatRole.User,
|
||||
Contents =
|
||||
[
|
||||
new DataContent("data:image/audio;base64,aGVsbG8="),
|
||||
new DataContent("data:image/image;base64,aGVsbG8="),
|
||||
new FunctionCallContent("callId1", "fc1"),
|
||||
new TextContent("text-1"),
|
||||
new TextContent("text-2"),
|
||||
new FunctionResultContent("callId1", "result"),
|
||||
],
|
||||
};
|
||||
|
||||
TextContent textContent = Assert.IsType<TextContent>(update.Contents[3]);
|
||||
Assert.Equal("text-1", textContent.Text);
|
||||
Assert.Equal("text-1text-2", update.Text);
|
||||
Assert.Equal("text-1text-2", update.ToString());
|
||||
|
||||
((TextContent)update.Contents[3]).Text = "text-3";
|
||||
Assert.Equal("text-3text-2", update.Text);
|
||||
Assert.Same(textContent, update.Contents[3]);
|
||||
Assert.Equal("text-3text-2", update.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonSerializationRoundtrips()
|
||||
{
|
||||
AgentResponseUpdate original = new()
|
||||
{
|
||||
AuthorName = "author",
|
||||
Role = ChatRole.Assistant,
|
||||
Contents =
|
||||
[
|
||||
new TextContent("text-1"),
|
||||
new DataContent("data:image/png;base64,aGVsbG8="),
|
||||
new FunctionCallContent("callId1", "fc1"),
|
||||
new DataContent("data"u8.ToArray(), "text/plain"),
|
||||
new TextContent("text-2"),
|
||||
],
|
||||
RawRepresentation = new object(),
|
||||
ResponseId = "id",
|
||||
MessageId = "messageid",
|
||||
CreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
AdditionalProperties = new() { ["key"] = "value" },
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(original, AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
|
||||
AgentResponseUpdate? result = JsonSerializer.Deserialize<AgentResponseUpdate>(json, AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(5, result.Contents.Count);
|
||||
|
||||
Assert.IsType<TextContent>(result.Contents[0]);
|
||||
Assert.Equal("text-1", ((TextContent)result.Contents[0]).Text);
|
||||
|
||||
Assert.IsType<DataContent>(result.Contents[1]);
|
||||
Assert.Equal("data:image/png;base64,aGVsbG8=", ((DataContent)result.Contents[1]).Uri);
|
||||
|
||||
Assert.IsType<FunctionCallContent>(result.Contents[2]);
|
||||
Assert.Equal("fc1", ((FunctionCallContent)result.Contents[2]).Name);
|
||||
|
||||
Assert.IsType<DataContent>(result.Contents[3]);
|
||||
Assert.Equal("data"u8.ToArray(), ((DataContent)result.Contents[3]).Data.ToArray());
|
||||
|
||||
Assert.IsType<TextContent>(result.Contents[4]);
|
||||
Assert.Equal("text-2", ((TextContent)result.Contents[4]).Text);
|
||||
|
||||
Assert.Equal("author", result.AuthorName);
|
||||
Assert.Equal(ChatRole.Assistant, result.Role);
|
||||
Assert.Equal("id", result.ResponseId);
|
||||
Assert.Equal("messageid", result.MessageId);
|
||||
Assert.Equal(new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero), result.CreatedAt);
|
||||
|
||||
Assert.NotNull(result.AdditionalProperties);
|
||||
Assert.Single(result.AdditionalProperties);
|
||||
Assert.True(result.AdditionalProperties.TryGetValue("key", out object? value));
|
||||
Assert.IsType<JsonElement>(value);
|
||||
Assert.Equal("value", ((JsonElement)value!).GetString());
|
||||
|
||||
Assert.NotNull(result.ContinuationToken);
|
||||
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), result.ContinuationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentRunOptions"/> class.
|
||||
/// </summary>
|
||||
public class AgentRunOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void CloningConstructorCopiesProperties()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentRunOptions
|
||||
{
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
AllowBackgroundResponses = true,
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1",
|
||||
["key2"] = 42
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var clone = new AgentRunOptions(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(clone);
|
||||
Assert.Same(options.ContinuationToken, clone.ContinuationToken);
|
||||
Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses);
|
||||
Assert.NotNull(clone.AdditionalProperties);
|
||||
Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties);
|
||||
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
|
||||
Assert.Equal(42, clone.AdditionalProperties["key2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloningConstructorThrowsIfNull() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentRunOptions(null!));
|
||||
|
||||
[Fact]
|
||||
public void JsonSerializationRoundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var options = new AgentRunOptions
|
||||
{
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
AllowBackgroundResponses = true,
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1",
|
||||
["key2"] = 42
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
string json = JsonSerializer.Serialize(options, AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<AgentRunOptions>(json, AgentAbstractionsJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), deserialized!.ContinuationToken);
|
||||
Assert.Equal(options.AllowBackgroundResponses, deserialized.AllowBackgroundResponses);
|
||||
Assert.NotNull(deserialized.AdditionalProperties);
|
||||
Assert.Equal(2, deserialized.AdditionalProperties.Count);
|
||||
Assert.True(deserialized.AdditionalProperties.TryGetValue("key1", out object? value1));
|
||||
Assert.IsType<JsonElement>(value1);
|
||||
Assert.Equal("value1", ((JsonElement)value1!).GetString());
|
||||
Assert.True(deserialized.AdditionalProperties.TryGetValue("key2", out object? value2));
|
||||
Assert.IsType<JsonElement>(value2);
|
||||
Assert.Equal(42, ((JsonElement)value2!).GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
#pragma warning disable CA1861 // Avoid constant arrays as arguments
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AgentThread"/>
|
||||
/// </summary>
|
||||
public class AgentThreadTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_ReturnsDefaultJsonElement()
|
||||
{
|
||||
var thread = new TestAgentThread();
|
||||
var result = thread.Serialize();
|
||||
Assert.Equal(default, result);
|
||||
}
|
||||
|
||||
#region GetService Method Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns the thread itself when requesting the exact thread type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_RequestingExactThreadType_ReturnsThread()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestAgentThread();
|
||||
|
||||
// Act
|
||||
var result = thread.GetService(typeof(TestAgentThread));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(thread, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns the thread itself when requesting the base AgentThread type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_RequestingAgentThreadType_ReturnsThread()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestAgentThread();
|
||||
|
||||
// Act
|
||||
var result = thread.GetService(typeof(AgentThread));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(thread, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when requesting an unrelated type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_RequestingUnrelatedType_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestAgentThread();
|
||||
|
||||
// Act
|
||||
var result = thread.GetService(typeof(string));
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when a service key is provided, even for matching types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_WithServiceKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestAgentThread();
|
||||
|
||||
// Act
|
||||
var result = thread.GetService(typeof(TestAgentThread), "some-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_WithNullServiceType_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestAgentThread();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => thread.GetService(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_Generic_ReturnsCorrectType()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestAgentThread();
|
||||
|
||||
// Act
|
||||
var result = thread.GetService<TestAgentThread>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(thread, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method returns null for unrelated types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_Generic_ReturnsNullForUnrelatedType()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestAgentThread();
|
||||
|
||||
// Act
|
||||
var result = thread.GetService<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestAgentThread : AgentThread;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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 Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="ChatMessageStoreMessageFilter"/> class.
|
||||
/// </summary>
|
||||
public sealed class ChatMessageStoreMessageFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullInnerStore_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange, Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ChatMessageStoreMessageFilter(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithOnlyInnerStore_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var innerStoreMock = new Mock<ChatMessageStore>();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new ChatMessageStoreMessageFilter(innerStoreMock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithAllParameters_CreatesInstance()
|
||||
{
|
||||
// Arrange
|
||||
var innerStoreMock = new Mock<ChatMessageStore>();
|
||||
|
||||
IEnumerable<ChatMessage> InvokingFilter(IEnumerable<ChatMessage> msgs) => msgs;
|
||||
ChatMessageStore.InvokedContext InvokedFilter(ChatMessageStore.InvokedContext ctx) => ctx;
|
||||
|
||||
// Act
|
||||
var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, InvokingFilter, InvokedFilter);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithNoOpFilters_ReturnsInnerStoreMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerStoreMock = new Mock<ChatMessageStore>();
|
||||
var expectedMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
var context = new ChatMessageStore.InvokingContext([new ChatMessage(ChatRole.User, "Test")]);
|
||||
|
||||
innerStoreMock
|
||||
.Setup(s => s.InvokingAsync(context, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedMessages);
|
||||
|
||||
var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, x => x, x => x);
|
||||
|
||||
// Act
|
||||
var result = (await filter.InvokingAsync(context, CancellationToken.None)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("Hello", result[0].Text);
|
||||
Assert.Equal("Hi there!", result[1].Text);
|
||||
innerStoreMock.Verify(s => s.InvokingAsync(context, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithInvokingFilter_AppliesFilterAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerStoreMock = new Mock<ChatMessageStore>();
|
||||
var innerMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!"),
|
||||
new(ChatRole.User, "How are you?")
|
||||
};
|
||||
var context = new ChatMessageStore.InvokingContext([new ChatMessage(ChatRole.User, "Test")]);
|
||||
|
||||
innerStoreMock
|
||||
.Setup(s => s.InvokingAsync(context, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerMessages);
|
||||
|
||||
// Filter to only user messages
|
||||
IEnumerable<ChatMessage> InvokingFilter(IEnumerable<ChatMessage> msgs) => msgs.Where(m => m.Role == ChatRole.User);
|
||||
|
||||
var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, InvokingFilter);
|
||||
|
||||
// Act
|
||||
var result = (await filter.InvokingAsync(context, CancellationToken.None)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, msg => Assert.Equal(ChatRole.User, msg.Role));
|
||||
innerStoreMock.Verify(s => s.InvokingAsync(context, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithInvokingFilter_CanModifyMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerStoreMock = new Mock<ChatMessageStore>();
|
||||
var innerMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
var context = new ChatMessageStore.InvokingContext([new ChatMessage(ChatRole.User, "Test")]);
|
||||
|
||||
innerStoreMock
|
||||
.Setup(s => s.InvokingAsync(context, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(innerMessages);
|
||||
|
||||
// Filter that transforms messages
|
||||
IEnumerable<ChatMessage> InvokingFilter(IEnumerable<ChatMessage> msgs) =>
|
||||
msgs.Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}"));
|
||||
|
||||
var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, InvokingFilter);
|
||||
|
||||
// Act
|
||||
var result = (await filter.InvokingAsync(context, CancellationToken.None)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("[FILTERED] Hello", result[0].Text);
|
||||
Assert.Equal("[FILTERED] Hi there!", result[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_WithInvokedFilter_AppliesFilterAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerStoreMock = new Mock<ChatMessageStore>();
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var chatMessageStoreMessages = new List<ChatMessage> { new(ChatRole.System, "System") };
|
||||
var responseMessages = new List<ChatMessage> { new(ChatRole.Assistant, "Response") };
|
||||
var context = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages)
|
||||
{
|
||||
ResponseMessages = responseMessages
|
||||
};
|
||||
|
||||
ChatMessageStore.InvokedContext? capturedContext = null;
|
||||
innerStoreMock
|
||||
.Setup(s => s.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ChatMessageStore.InvokedContext, CancellationToken>((ctx, ct) => capturedContext = ctx)
|
||||
.Returns(default(ValueTask));
|
||||
|
||||
// Filter that modifies the context
|
||||
ChatMessageStore.InvokedContext InvokedFilter(ChatMessageStore.InvokedContext ctx)
|
||||
{
|
||||
var modifiedRequestMessages = ctx.RequestMessages.Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}")).ToList();
|
||||
return new ChatMessageStore.InvokedContext(modifiedRequestMessages, ctx.ChatMessageStoreMessages)
|
||||
{
|
||||
ResponseMessages = ctx.ResponseMessages,
|
||||
AIContextProviderMessages = ctx.AIContextProviderMessages,
|
||||
InvokeException = ctx.InvokeException
|
||||
};
|
||||
}
|
||||
|
||||
var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, invokedMessagesFilter: InvokedFilter);
|
||||
|
||||
// Act
|
||||
await filter.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Single(capturedContext.RequestMessages);
|
||||
Assert.Equal("[FILTERED] Hello", capturedContext.RequestMessages.First().Text);
|
||||
innerStoreMock.Verify(s => s.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_DelegatesToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStoreMock = new Mock<ChatMessageStore>();
|
||||
var expectedJson = JsonSerializer.SerializeToElement("data", TestJsonSerializerContext.Default.String);
|
||||
|
||||
innerStoreMock
|
||||
.Setup(s => s.Serialize(It.IsAny<JsonSerializerOptions>()))
|
||||
.Returns(expectedJson);
|
||||
|
||||
var filter = new ChatMessageStoreMessageFilter(innerStoreMock.Object, x => x, x => x);
|
||||
|
||||
// Act
|
||||
var result = filter.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedJson.GetRawText(), result.GetRawText());
|
||||
innerStoreMock.Verify(s => s.Serialize(null), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="ChatMessageStore"/> class.
|
||||
/// </summary>
|
||||
public class ChatMessageStoreTests
|
||||
{
|
||||
#region GetService Method Tests
|
||||
|
||||
[Fact]
|
||||
public void GetService_RequestingExactStoreType_ReturnsStore()
|
||||
{
|
||||
var store = new TestChatMessageStore();
|
||||
var result = store.GetService(typeof(TestChatMessageStore));
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(store, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetService_RequestingBaseStoreType_ReturnsStore()
|
||||
{
|
||||
var store = new TestChatMessageStore();
|
||||
var result = store.GetService(typeof(ChatMessageStore));
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(store, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetService_RequestingUnrelatedType_ReturnsNull()
|
||||
{
|
||||
var store = new TestChatMessageStore();
|
||||
var result = store.GetService(typeof(string));
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetService_WithServiceKey_ReturnsNull()
|
||||
{
|
||||
var store = new TestChatMessageStore();
|
||||
var result = store.GetService(typeof(TestChatMessageStore), "some-key");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetService_WithNullServiceType_ThrowsArgumentNullException()
|
||||
{
|
||||
var store = new TestChatMessageStore();
|
||||
Assert.Throws<ArgumentNullException>(() => store.GetService(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetService_Generic_ReturnsCorrectType()
|
||||
{
|
||||
var store = new TestChatMessageStore();
|
||||
var result = store.GetService<TestChatMessageStore>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(store, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetService_Generic_ReturnsNullForUnrelatedType()
|
||||
{
|
||||
var store = new TestChatMessageStore();
|
||||
var result = store.GetService<string>();
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestChatMessageStore : ChatMessageStore
|
||||
{
|
||||
public override ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
=> new(Array.Empty<ChatMessage>());
|
||||
|
||||
public override ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DelegatingAIAgent"/> class.
|
||||
/// </summary>
|
||||
public class DelegatingAIAgentTests
|
||||
{
|
||||
private readonly Mock<AIAgent> _innerAgentMock;
|
||||
private readonly TestDelegatingAIAgent _delegatingAgent;
|
||||
private readonly AgentResponse _testResponse;
|
||||
private readonly List<AgentResponseUpdate> _testStreamingResponses;
|
||||
private readonly AgentThread _testThread;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAIAgentTests"/> class.
|
||||
/// </summary>
|
||||
public DelegatingAIAgentTests()
|
||||
{
|
||||
this._innerAgentMock = new Mock<AIAgent>();
|
||||
this._testResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
|
||||
this._testStreamingResponses = [new AgentResponseUpdate(ChatRole.Assistant, "Test streaming response")];
|
||||
this._testThread = new TestAgentThread();
|
||||
|
||||
// Setup inner agent mock
|
||||
this._innerAgentMock.Protected().SetupGet<string>("IdCore").Returns("test-agent-id");
|
||||
this._innerAgentMock.Setup(x => x.Name).Returns("Test Agent");
|
||||
this._innerAgentMock.Setup(x => x.Description).Returns("Test Description");
|
||||
this._innerAgentMock.Setup(x => x.GetNewThreadAsync()).ReturnsAsync(this._testThread);
|
||||
|
||||
this._innerAgentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentThread?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(this._testResponse);
|
||||
|
||||
this._innerAgentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentThread?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(ToAsyncEnumerableAsync(this._testStreamingResponses));
|
||||
|
||||
this._delegatingAgent = new TestDelegatingAIAgent(this._innerAgentMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerAgent is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerAgent() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerAgent", () => new TestDelegatingAIAgent(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor sets the inner agent correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithValidInnerAgent_SetsInnerAgent()
|
||||
{
|
||||
// Act
|
||||
var delegatingAgent = new TestDelegatingAIAgent(this._innerAgentMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._innerAgentMock.Object, delegatingAgent.InnerAgent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Delegation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Id property delegates to inner agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Id_DelegatesToInnerAgent()
|
||||
{
|
||||
// Act
|
||||
var id = this._delegatingAgent.Id;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-agent-id", id);
|
||||
this._innerAgentMock.Protected().VerifyGet<string>("IdCore", Times.Once());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Name property delegates to inner agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Name_DelegatesToInnerAgent()
|
||||
{
|
||||
// Act
|
||||
var name = this._delegatingAgent.Name;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Test Agent", name);
|
||||
this._innerAgentMock.Verify(x => x.Name, Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Description property delegates to inner agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Description_DelegatesToInnerAgent()
|
||||
{
|
||||
// Act
|
||||
var description = this._delegatingAgent.Description;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Test Description", description);
|
||||
this._innerAgentMock.Verify(x => x.Description, Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Method Delegation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetNewThreadAsync delegates to inner agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetNewThreadAsync_DelegatesToInnerAgentAsync()
|
||||
{
|
||||
// Act
|
||||
var thread = await this._delegatingAgent.GetNewThreadAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testThread, thread);
|
||||
this._innerAgentMock.Verify(x => x.GetNewThreadAsync(), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync delegates to inner agent with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncDefaultsToInnerAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var expectedMessages = new[] { new ChatMessage(ChatRole.User, "Test message") };
|
||||
var expectedThread = new TestAgentThread();
|
||||
var expectedOptions = new AgentRunOptions();
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
var expectedResult = new TaskCompletionSource<AgentResponse>();
|
||||
var expectedResponse = new AgentResponse();
|
||||
|
||||
var innerAgentMock = new Mock<AIAgent>();
|
||||
innerAgentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.Is<IEnumerable<ChatMessage>>(m => m == expectedMessages),
|
||||
ItExpr.Is<AgentThread?>(t => t == expectedThread),
|
||||
ItExpr.Is<AgentRunOptions?>(o => o == expectedOptions),
|
||||
ItExpr.Is<CancellationToken>(ct => ct == expectedCancellationToken))
|
||||
.Returns(expectedResult.Task);
|
||||
|
||||
var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingAgent.RunAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
expectedResult.SetResult(expectedResponse);
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
Assert.Same(expectedResponse, await resultTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunStreamingAsync delegates to inner agent with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncDefaultsToInnerAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var expectedMessages = new[] { new ChatMessage(ChatRole.User, "Test message") };
|
||||
var expectedThread = new TestAgentThread();
|
||||
var expectedOptions = new AgentRunOptions();
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
AgentResponseUpdate[] expectedResults =
|
||||
[
|
||||
new(ChatRole.Assistant, "Message 1"),
|
||||
new(ChatRole.Assistant, "Message 2")
|
||||
];
|
||||
|
||||
var innerAgentMock = new Mock<AIAgent>();
|
||||
innerAgentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.Is<IEnumerable<ChatMessage>>(m => m == expectedMessages),
|
||||
ItExpr.Is<AgentThread?>(t => t == expectedThread),
|
||||
ItExpr.Is<AgentRunOptions?>(o => o == expectedOptions),
|
||||
ItExpr.Is<CancellationToken>(ct => ct == expectedCancellationToken))
|
||||
.Returns(ToAsyncEnumerableAsync(expectedResults));
|
||||
|
||||
var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object);
|
||||
|
||||
// Act
|
||||
var resultAsyncEnumerable = delegatingAgent.RunStreamingAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
var enumerator = resultAsyncEnumerable.GetAsyncEnumerator();
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
Assert.Same(expectedResults[0], enumerator.Current);
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
Assert.Same(expectedResults[1], enumerator.Current);
|
||||
Assert.False(await enumerator.MoveNextAsync());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceThrowsForNullType() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("serviceType", () => this._delegatingAgent.GetService(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns the delegating agent itself when requesting compatible type and key is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull()
|
||||
{
|
||||
// Act
|
||||
var agent = this._delegatingAgent.GetService<DelegatingAIAgent>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingAgent, agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService delegates to inner agent when service key is not null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceDelegatesToInnerIfKeyIsNotNull()
|
||||
{
|
||||
// Arrange
|
||||
var expectedKey = new object();
|
||||
var expectedResult = new Mock<AIAgent>().Object;
|
||||
var innerAgentMock = new Mock<AIAgent>();
|
||||
innerAgentMock.Setup(x => x.GetService(typeof(AIAgent), expectedKey)).Returns(expectedResult);
|
||||
var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object);
|
||||
|
||||
// Act
|
||||
var agent = delegatingAgent.GetService<AIAgent>(expectedKey);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expectedResult, agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService delegates to inner agent when not compatible with request.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest()
|
||||
{
|
||||
// Arrange
|
||||
var expectedResult = TimeZoneInfo.Local;
|
||||
var expectedKey = new object();
|
||||
var innerAgentMock = new Mock<AIAgent>();
|
||||
innerAgentMock
|
||||
.Setup(x => x.GetService(typeof(TimeZoneInfo), expectedKey))
|
||||
.Returns(expectedResult);
|
||||
var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object);
|
||||
|
||||
// Act
|
||||
var tzi = delegatingAgent.GetService<TimeZoneInfo>(expectedKey);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expectedResult, tzi);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
|
||||
{
|
||||
await Task.Yield();
|
||||
foreach (var value in values)
|
||||
{
|
||||
yield return value;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Implementation
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of DelegatingAIAgent for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestDelegatingAIAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent)
|
||||
{
|
||||
public new AIAgent InnerAgent => base.InnerAgent;
|
||||
}
|
||||
|
||||
private sealed class TestAgentThread : AgentThread;
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="InMemoryAgentThread"/>.
|
||||
/// </summary>
|
||||
public class InMemoryAgentThreadTests
|
||||
{
|
||||
#region Constructor and Property Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsDefaultMessageStore()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread = new TestInMemoryAgentThread();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(thread.GetMessageStore());
|
||||
Assert.Empty(thread.GetMessageStore());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithMessageStore_SetsProperty()
|
||||
{
|
||||
// Arrange
|
||||
InMemoryChatMessageStore store = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
var thread = new TestInMemoryAgentThread(store);
|
||||
|
||||
// Assert
|
||||
Assert.Same(store, thread.GetMessageStore());
|
||||
Assert.Single(thread.GetMessageStore());
|
||||
Assert.Equal("Hello", thread.GetMessageStore()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithMessages_SetsProperty()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Hi") };
|
||||
|
||||
// Act
|
||||
var thread = new TestInMemoryAgentThread(messages);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(thread.GetMessageStore());
|
||||
Assert.Single(thread.GetMessageStore());
|
||||
Assert.Equal("Hi", thread.GetMessageStore()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithSerializedState_SetsProperty()
|
||||
{
|
||||
// Arrange
|
||||
InMemoryChatMessageStore store = [new(ChatRole.User, "TestMsg")];
|
||||
var storeState = store.Serialize();
|
||||
var threadStateWrapper = new InMemoryAgentThread.InMemoryAgentThreadState { StoreState = storeState };
|
||||
var json = JsonSerializer.SerializeToElement(threadStateWrapper, TestJsonSerializerContext.Default.InMemoryAgentThreadState);
|
||||
|
||||
// Act
|
||||
var thread = new TestInMemoryAgentThread(json);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(thread.GetMessageStore());
|
||||
Assert.Single(thread.GetMessageStore());
|
||||
Assert.Equal("TestMsg", thread.GetMessageStore()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithInvalidJson_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = JsonSerializer.SerializeToElement(42, TestJsonSerializerContext.Default.Int32);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new TestInMemoryAgentThread(invalidJson));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SerializeAsync Tests
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ReturnsCorrectJson_WhenMessagesExist()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestInMemoryAgentThread([new(ChatRole.User, "TestContent")]);
|
||||
|
||||
// Act
|
||||
var json = thread.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
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);
|
||||
var messagesList = messagesProperty.EnumerateArray().ToList();
|
||||
Assert.Single(messagesList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ReturnsEmptyMessages_WhenNoMessages()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestInMemoryAgentThread();
|
||||
|
||||
// Act
|
||||
var json = thread.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
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.Empty(messagesProperty.EnumerateArray());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
[Fact]
|
||||
public void GetService_RequestingChatMessageStore_ReturnsChatMessageStore()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestInMemoryAgentThread();
|
||||
|
||||
// Act & Assert
|
||||
Assert.NotNull(thread.GetService(typeof(ChatMessageStore)));
|
||||
Assert.Same(thread.GetMessageStore(), thread.GetService(typeof(ChatMessageStore)));
|
||||
Assert.Same(thread.GetMessageStore(), thread.GetService(typeof(InMemoryChatMessageStore)));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// Sealed test subclass to expose protected members for testing
|
||||
private sealed class TestInMemoryAgentThread : InMemoryAgentThread
|
||||
{
|
||||
public TestInMemoryAgentThread() { }
|
||||
public TestInMemoryAgentThread(InMemoryChatMessageStore? store) : base(store) { }
|
||||
public TestInMemoryAgentThread(IEnumerable<ChatMessage> messages) : base(messages) { }
|
||||
public TestInMemoryAgentThread(JsonElement serializedThreadState) : base(serializedThreadState) { }
|
||||
public InMemoryChatMessageStore GetMessageStore() => this.MessageStore;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="InMemoryChatMessageStore"/> class.
|
||||
/// </summary>
|
||||
public class InMemoryChatMessageStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_Throws_ForNullReducer() =>
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new InMemoryChatMessageStore(null!));
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultsToBeforeMessageRetrieval_ForNotProvidedTriggerEvent()
|
||||
{
|
||||
// Arrange & Act
|
||||
var reducerMock = new Mock<IChatReducer>();
|
||||
var store = new InMemoryChatMessageStore(reducerMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(InMemoryChatMessageStore.ChatReducerTriggerEvent.BeforeMessagesRetrieval, store.ReducerTriggerEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Arguments_SetOnPropertiesCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var reducerMock = new Mock<IChatReducer>();
|
||||
var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.AfterMessageAdded);
|
||||
|
||||
// Assert
|
||||
Assert.Same(reducerMock.Object, store.ChatReducer);
|
||||
Assert.Equal(InMemoryChatMessageStore.ChatReducerTriggerEvent.AfterMessageAdded, store.ReducerTriggerEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsyncAddsMessagesAsync()
|
||||
{
|
||||
var requestMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello")
|
||||
};
|
||||
var responseMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
var messageStoreMessages = new List<ChatMessage>()
|
||||
{
|
||||
new(ChatRole.System, "original instructions")
|
||||
};
|
||||
var aiContextProviderMessages = new List<ChatMessage>()
|
||||
{
|
||||
new(ChatRole.System, "additional context")
|
||||
};
|
||||
|
||||
var store = new InMemoryChatMessageStore();
|
||||
store.Add(messageStoreMessages[0]);
|
||||
var context = new ChatMessageStore.InvokedContext(requestMessages, messageStoreMessages)
|
||||
{
|
||||
AIContextProviderMessages = aiContextProviderMessages,
|
||||
ResponseMessages = responseMessages
|
||||
};
|
||||
await store.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(4, store.Count);
|
||||
Assert.Equal("original instructions", store[0].Text);
|
||||
Assert.Equal("Hello", store[1].Text);
|
||||
Assert.Equal("additional context", store[2].Text);
|
||||
Assert.Equal("Hi there!", store[3].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsyncWithEmptyDoesNotFailAsync()
|
||||
{
|
||||
var store = new InMemoryChatMessageStore();
|
||||
|
||||
var context = new ChatMessageStore.InvokedContext([], []);
|
||||
await store.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Empty(store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsyncReturnsAllMessagesAsync()
|
||||
{
|
||||
var store = new InMemoryChatMessageStore
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "Test1"),
|
||||
new ChatMessage(ChatRole.Assistant, "Test2")
|
||||
};
|
||||
|
||||
var context = new ChatMessageStore.InvokingContext([]);
|
||||
var result = (await store.InvokingAsync(context, CancellationToken.None)).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, m => m.Text == "Test1");
|
||||
Assert.Contains(result, m => m.Text == "Test2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeserializeConstructorWithEmptyElementAsync()
|
||||
{
|
||||
var emptyObject = JsonSerializer.Deserialize("{}", TestJsonSerializerContext.Default.JsonElement);
|
||||
|
||||
var newStore = new InMemoryChatMessageStore(emptyObject);
|
||||
|
||||
Assert.Empty(newStore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SerializeAndDeserializeConstructorRoundtripsAsync()
|
||||
{
|
||||
var store = new InMemoryChatMessageStore
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.Assistant, "B")
|
||||
};
|
||||
|
||||
var jsonElement = store.Serialize();
|
||||
var newStore = new InMemoryChatMessageStore(jsonElement);
|
||||
|
||||
Assert.Equal(2, newStore.Count);
|
||||
Assert.Equal("A", newStore[0].Text);
|
||||
Assert.Equal("B", newStore[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SerializeAndDeserializeConstructorRoundtripsWithCustomAIContentAsync()
|
||||
{
|
||||
JsonSerializerOptions options = new(TestJsonSerializerContext.Default.Options)
|
||||
{
|
||||
TypeInfoResolver = JsonTypeInfoResolver.Combine(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver, TestJsonSerializerContext.Default),
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
options.AddAIContentType<TestAIContent>(typeDiscriminatorId: "testContent");
|
||||
|
||||
var store = new InMemoryChatMessageStore
|
||||
{
|
||||
new ChatMessage(ChatRole.User, [new TestAIContent("foo data")]),
|
||||
};
|
||||
|
||||
var jsonElement = store.Serialize(options);
|
||||
var newStore = new InMemoryChatMessageStore(jsonElement, options);
|
||||
|
||||
Assert.Single(newStore);
|
||||
var actualTestAIContent = Assert.IsType<TestAIContent>(newStore[0].Contents[0]);
|
||||
Assert.Equal("foo data", actualTestAIContent.TestData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SerializeAndDeserializeWorksWithExperimentalContentTypesAsync()
|
||||
{
|
||||
var store = new InMemoryChatMessageStore
|
||||
{
|
||||
new ChatMessage(ChatRole.User, [new FunctionApprovalRequestContent("call123", new FunctionCallContent("call123", "some_func"))]),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionApprovalResponseContent("call123", true, new FunctionCallContent("call123", "some_func"))])
|
||||
};
|
||||
|
||||
var jsonElement = store.Serialize();
|
||||
var newStore = new InMemoryChatMessageStore(jsonElement);
|
||||
|
||||
Assert.Equal(2, newStore.Count);
|
||||
Assert.IsType<FunctionApprovalRequestContent>(newStore[0].Contents[0]);
|
||||
Assert.IsType<FunctionApprovalResponseContent>(newStore[1].Contents[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsyncWithEmptyMessagesDoesNotChangeStoreAsync()
|
||||
{
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
var context = new ChatMessageStore.InvokedContext(messages, []);
|
||||
await store.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Empty(store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_WithNullContext_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => store.InvokedAsync(null!, CancellationToken.None).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeserializeContructor_WithNullSerializedState_CreatesEmptyStore()
|
||||
{
|
||||
// Act
|
||||
var store = new InMemoryChatMessageStore(new JsonElement());
|
||||
|
||||
// Assert
|
||||
Assert.Empty(store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeserializeContructor_WithEmptyMessages_DoesNotAddMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var stateWithEmptyMessages = JsonSerializer.SerializeToElement(
|
||||
new Dictionary<string, object> { ["messages"] = new List<ChatMessage>() },
|
||||
TestJsonSerializerContext.Default.IDictionaryStringObject);
|
||||
|
||||
// Act
|
||||
var store = new InMemoryChatMessageStore(stateWithEmptyMessages);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeserializeConstructor_WithNullMessages_DoesNotAddMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var stateWithNullMessages = JsonSerializer.SerializeToElement(
|
||||
new Dictionary<string, object> { ["messages"] = null! },
|
||||
TestJsonSerializerContext.Default.DictionaryStringObject);
|
||||
|
||||
// Act
|
||||
var store = new InMemoryChatMessageStore(stateWithNullMessages);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeserializeConstructor_WithValidMessages_AddsMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "User message"),
|
||||
new(ChatRole.Assistant, "Assistant message")
|
||||
};
|
||||
var state = new Dictionary<string, object> { ["messages"] = messages };
|
||||
var serializedState = JsonSerializer.SerializeToElement(
|
||||
state,
|
||||
TestJsonSerializerContext.Default.DictionaryStringObject);
|
||||
|
||||
// Act
|
||||
var store = new InMemoryChatMessageStore(serializedState);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, store.Count);
|
||||
Assert.Equal("User message", store[0].Text);
|
||||
Assert.Equal("Assistant message", store[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexerGet_ReturnsCorrectMessage()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
store.Add(message1);
|
||||
store.Add(message2);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Same(message1, store[0]);
|
||||
Assert.Same(message2, store[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexerSet_UpdatesMessage()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var originalMessage = new ChatMessage(ChatRole.User, "Original");
|
||||
var newMessage = new ChatMessage(ChatRole.User, "Updated");
|
||||
store.Add(originalMessage);
|
||||
|
||||
// Act
|
||||
store[0] = newMessage;
|
||||
|
||||
// Assert
|
||||
Assert.Same(newMessage, store[0]);
|
||||
Assert.Equal("Updated", store[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadOnly_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(store.IsReadOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexOf_ReturnsCorrectIndex()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
var message3 = new ChatMessage(ChatRole.User, "Third");
|
||||
store.Add(message1);
|
||||
store.Add(message2);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(0, store.IndexOf(message1));
|
||||
Assert.Equal(1, store.IndexOf(message2));
|
||||
Assert.Equal(-1, store.IndexOf(message3)); // Not in store
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Insert_InsertsMessageAtCorrectIndex()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
var insertMessage = new ChatMessage(ChatRole.User, "Inserted");
|
||||
store.Add(message1);
|
||||
store.Add(message2);
|
||||
|
||||
// Act
|
||||
store.Insert(1, insertMessage);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, store.Count);
|
||||
Assert.Same(message1, store[0]);
|
||||
Assert.Same(insertMessage, store[1]);
|
||||
Assert.Same(message2, store[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAt_RemovesMessageAtIndex()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
var message3 = new ChatMessage(ChatRole.User, "Third");
|
||||
store.Add(message1);
|
||||
store.Add(message2);
|
||||
store.Add(message3);
|
||||
|
||||
// Act
|
||||
store.RemoveAt(1);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, store.Count);
|
||||
Assert.Same(message1, store[0]);
|
||||
Assert.Same(message3, store[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllMessages()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Second")
|
||||
};
|
||||
|
||||
// Act
|
||||
store.Clear();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contains_ReturnsTrueForExistingMessage()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
store.Add(message1);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Contains(message1, store);
|
||||
Assert.DoesNotContain(message2, store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopyTo_CopiesMessagesToArray()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
store.Add(message1);
|
||||
store.Add(message2);
|
||||
var array = new ChatMessage[4];
|
||||
|
||||
// Act
|
||||
store.CopyTo(array, 1);
|
||||
|
||||
// Assert
|
||||
Assert.Null(array[0]);
|
||||
Assert.Same(message1, array[1]);
|
||||
Assert.Same(message2, array[2]);
|
||||
Assert.Null(array[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_RemovesSpecificMessage()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
var message3 = new ChatMessage(ChatRole.User, "Third");
|
||||
store.Add(message1);
|
||||
store.Add(message2);
|
||||
store.Add(message3);
|
||||
|
||||
// Act
|
||||
var removed = store.Remove(message2);
|
||||
|
||||
// Assert
|
||||
Assert.True(removed);
|
||||
Assert.Equal(2, store.Count);
|
||||
Assert.Same(message1, store[0]);
|
||||
Assert.Same(message3, store[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_ReturnsFalseForNonExistentMessage()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
store.Add(message1);
|
||||
|
||||
// Act
|
||||
var removed = store.Remove(message2);
|
||||
|
||||
// Assert
|
||||
Assert.False(removed);
|
||||
Assert.Single(store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnumerator_Generic_ReturnsAllMessages()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
store.Add(message1);
|
||||
store.Add(message2);
|
||||
|
||||
// Act
|
||||
var messages = new List<ChatMessage>();
|
||||
messages.AddRange(store);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, messages.Count);
|
||||
Assert.Same(message1, messages[0]);
|
||||
Assert.Same(message2, messages[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnumerator_NonGeneric_ReturnsAllMessages()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryChatMessageStore();
|
||||
var message1 = new ChatMessage(ChatRole.User, "First");
|
||||
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
|
||||
store.Add(message1);
|
||||
store.Add(message2);
|
||||
|
||||
// Act
|
||||
var messages = new List<ChatMessage>();
|
||||
var enumerator = ((System.Collections.IEnumerable)store).GetEnumerator();
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
messages.Add((ChatMessage)enumerator.Current);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, messages.Count);
|
||||
Assert.Same(message1, messages[0]);
|
||||
Assert.Same(message2, messages[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMessagesAsync_WithReducer_AfterMessageAdded_InvokesReducerAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
var reducedMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Reduced")
|
||||
};
|
||||
|
||||
var reducerMock = new Mock<IChatReducer>();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(reducedMessages);
|
||||
|
||||
var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.AfterMessageAdded);
|
||||
|
||||
// Act
|
||||
var context = new ChatMessageStore.InvokedContext(originalMessages, []);
|
||||
await store.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(store);
|
||||
Assert.Equal("Reduced", store[0].Text);
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMessagesAsync_WithReducer_BeforeMessagesRetrieval_InvokesReducerAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
var reducedMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Reduced")
|
||||
};
|
||||
|
||||
var reducerMock = new Mock<IChatReducer>();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(reducedMessages);
|
||||
|
||||
var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.BeforeMessagesRetrieval);
|
||||
// Add messages directly to the store for this test
|
||||
foreach (var msg in originalMessages)
|
||||
{
|
||||
store.Add(msg);
|
||||
}
|
||||
|
||||
// Act
|
||||
var invokingContext = new ChatMessageStore.InvokingContext(Array.Empty<ChatMessage>());
|
||||
var result = (await store.InvokingAsync(invokingContext, CancellationToken.None)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Reduced", result[0].Text);
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeReducerAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello")
|
||||
};
|
||||
|
||||
var reducerMock = new Mock<IChatReducer>();
|
||||
|
||||
var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.BeforeMessagesRetrieval);
|
||||
|
||||
// Act
|
||||
var context = new ChatMessageStore.InvokedContext(originalMessages, []);
|
||||
await store.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(store);
|
||||
Assert.Equal("Hello", store[0].Text);
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeReducerAsync()
|
||||
{
|
||||
// Arrange
|
||||
var originalMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello")
|
||||
};
|
||||
|
||||
var reducerMock = new Mock<IChatReducer>();
|
||||
|
||||
var store = new InMemoryChatMessageStore(reducerMock.Object, InMemoryChatMessageStore.ChatReducerTriggerEvent.AfterMessageAdded)
|
||||
{
|
||||
originalMessages[0]
|
||||
};
|
||||
|
||||
// Act
|
||||
var invokingContext = new ChatMessageStore.InvokingContext(Array.Empty<ChatMessage>());
|
||||
var result = (await store.InvokingAsync(invokingContext, CancellationToken.None)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Hello", result[0].Text);
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
public class TestAIContent(string testData) : AIContent
|
||||
{
|
||||
public string TestData => testData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Models;
|
||||
|
||||
[Description("Some test description")]
|
||||
internal sealed class Animal
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public Species Species { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Models;
|
||||
|
||||
internal enum Species
|
||||
{
|
||||
Bear,
|
||||
Tiger,
|
||||
Walrus,
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ServiceIdAgentThread"/>.
|
||||
/// </summary>
|
||||
public class ServiceIdAgentThreadTests
|
||||
{
|
||||
#region Constructor and Property Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread = new TestServiceIdAgentThread();
|
||||
|
||||
// Assert
|
||||
Assert.Null(thread.GetServiceThreadId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithServiceThreadId_SetsProperty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var thread = new TestServiceIdAgentThread("service-id-123");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("service-id-123", thread.GetServiceThreadId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithSerializedId_SetsProperty()
|
||||
{
|
||||
// Arrange
|
||||
var serviceThreadWrapper = new ServiceIdAgentThread.ServiceIdAgentThreadState { ServiceThreadId = "service-id-456" };
|
||||
var json = JsonSerializer.SerializeToElement(serviceThreadWrapper, TestJsonSerializerContext.Default.ServiceIdAgentThreadState);
|
||||
|
||||
// Act
|
||||
var thread = new TestServiceIdAgentThread(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("service-id-456", thread.GetServiceThreadId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithSerializedUndefinedId_SetsProperty()
|
||||
{
|
||||
// Arrange
|
||||
var emptyObject = new EmptyObject();
|
||||
var json = JsonSerializer.SerializeToElement(emptyObject, TestJsonSerializerContext.Default.EmptyObject);
|
||||
|
||||
// Act
|
||||
var thread = new TestServiceIdAgentThread(json);
|
||||
|
||||
// Assert
|
||||
Assert.Null(thread.GetServiceThreadId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithInvalidJson_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = JsonSerializer.SerializeToElement(42, TestJsonSerializerContext.Default.Int32);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => new TestServiceIdAgentThread(invalidJson));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SerializeAsync Tests
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ReturnsCorrectJson_WhenServiceThreadIdIsSet()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestServiceIdAgentThread("service-id-789");
|
||||
|
||||
// Act
|
||||
var json = thread.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
Assert.True(json.TryGetProperty("serviceThreadId", out var idProperty));
|
||||
Assert.Equal("service-id-789", idProperty.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ReturnsUndefinedServiceThreadId_WhenNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var thread = new TestServiceIdAgentThread();
|
||||
|
||||
// Act
|
||||
var json = thread.Serialize();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
Assert.False(json.TryGetProperty("serviceThreadId", out _));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// Sealed test subclass to expose protected members for testing
|
||||
private sealed class TestServiceIdAgentThread : ServiceIdAgentThread
|
||||
{
|
||||
public TestServiceIdAgentThread() { }
|
||||
public TestServiceIdAgentThread(string serviceThreadId) : base(serviceThreadId) { }
|
||||
public TestServiceIdAgentThread(JsonElement serializedThreadState) : base(serializedThreadState) { }
|
||||
public string? GetServiceThreadId() => this.ServiceThreadId;
|
||||
}
|
||||
|
||||
// Helper class to represent empty objects
|
||||
internal sealed class EmptyObject;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.Abstractions.UnitTests.Models;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UseStringEnumConverter = true)]
|
||||
[JsonSerializable(typeof(AgentResponse))]
|
||||
[JsonSerializable(typeof(AgentResponseUpdate))]
|
||||
[JsonSerializable(typeof(AgentRunOptions))]
|
||||
[JsonSerializable(typeof(Animal))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(string[]))]
|
||||
[JsonSerializable(typeof(int))]
|
||||
[JsonSerializable(typeof(InMemoryAgentThread.InMemoryAgentThreadState))]
|
||||
[JsonSerializable(typeof(ServiceIdAgentThread.ServiceIdAgentThreadState))]
|
||||
[JsonSerializable(typeof(ServiceIdAgentThreadTests.EmptyObject))]
|
||||
[JsonSerializable(typeof(InMemoryChatMessageStoreTests.TestAIContent))]
|
||||
internal sealed partial class TestJsonSerializerContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable IDE0052 // Remove unread private members
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Anthropic;
|
||||
using Anthropic.Core;
|
||||
using Anthropic.Services;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using IBetaMessageService = Anthropic.Services.Beta.IMessageService;
|
||||
using IMessageService = Anthropic.Services.IMessageService;
|
||||
|
||||
namespace Microsoft.Agents.AI.Anthropic.UnitTests.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the AnthropicClientExtensions class.
|
||||
/// </summary>
|
||||
public sealed class AnthropicBetaServiceExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
var testChatClient = new TestChatClient(chatClient.Beta.AsIChatClient());
|
||||
|
||||
// Act
|
||||
var agent = chatClient.Beta.AsAIAgent(
|
||||
model: "test-model",
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
description: "Test description",
|
||||
clientFactory: (innerClient) => testChatClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
Assert.Equal("Test description", agent.Description);
|
||||
|
||||
// Verify that the custom chat client can be retrieved from the agent's service collection
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
TestChatClient? testChatClient = null;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.Beta.AsAIAgent(
|
||||
model: "test-model",
|
||||
instructions: "Test instructions",
|
||||
clientFactory: (innerClient) =>
|
||||
innerClient.AsBuilder().Use((innerClient) => testChatClient = new TestChatClient(innerClient)).Build());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
|
||||
// Verify that the custom chat client can be retrieved from the agent's service collection
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
var testChatClient = new TestChatClient(chatClient.Beta.AsIChatClient());
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "Test description",
|
||||
ChatOptions = new() { Instructions = "Test instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = chatClient.Beta.AsAIAgent(
|
||||
options,
|
||||
clientFactory: (innerClient) => testChatClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
Assert.Equal("Test description", agent.Description);
|
||||
|
||||
// Verify that the custom chat client can be retrieved from the agent's service collection
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent without clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
|
||||
// Act
|
||||
var agent = chatClient.Beta.AsAIAgent(
|
||||
model: "test-model",
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
|
||||
// Verify that no TestChatClient is available since no factory was provided
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with null clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
|
||||
// Act
|
||||
var agent = chatClient.Beta.AsAIAgent(
|
||||
model: "test-model",
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
clientFactory: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
|
||||
// Verify that no TestChatClient is available since no factory was provided
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
((IBetaService)null!).AsAIAgent("test-model"));
|
||||
|
||||
Assert.Equal("betaService", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with options throws ArgumentNullException when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
chatClient.Beta.AsAIAgent((ChatClientAgentOptions)null!));
|
||||
|
||||
Assert.Equal("options", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test custom chat client that can be used to verify clientFactory functionality.
|
||||
/// </summary>
|
||||
private sealed class TestChatClient : IChatClient
|
||||
{
|
||||
private readonly IChatClient _innerClient;
|
||||
|
||||
public TestChatClient(IChatClient innerClient)
|
||||
{
|
||||
this._innerClient = innerClient;
|
||||
}
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> this._innerClient.GetResponseAsync(messages, options, cancellationToken);
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await foreach (var update in this._innerClient.GetStreamingResponseAsync(messages, options, cancellationToken))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
// Return this instance when requested
|
||||
if (serviceType == typeof(TestChatClient))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
return this._innerClient.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
public void Dispose() => this._innerClient.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test ChatClient implementation for testing.
|
||||
/// </summary>
|
||||
private sealed class TestAnthropicChatClient : IAnthropicClient
|
||||
{
|
||||
public TestAnthropicChatClient()
|
||||
{
|
||||
this.BetaService = new TestBetaService(this);
|
||||
}
|
||||
|
||||
public HttpClient HttpClient { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public Uri BaseUrl { get => new("http://localhost"); init => throw new NotImplementedException(); }
|
||||
public bool ResponseValidation { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public int? MaxRetries { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public TimeSpan? Timeout { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public string? APIKey { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public string? AuthToken { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
|
||||
public IMessageService Messages => throw new NotImplementedException();
|
||||
|
||||
public IModelService Models => throw new NotImplementedException();
|
||||
|
||||
public IBetaService Beta => this.BetaService;
|
||||
|
||||
public IBetaService BetaService { get; }
|
||||
|
||||
IMessageService IAnthropicClient.Messages => new Mock<IMessageService>().Object;
|
||||
|
||||
public Task<HttpResponse> Execute<T>(HttpRequest<T> request, CancellationToken cancellationToken = default) where T : ParamsBase
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public IAnthropicClient WithOptions(Func<ClientOptions, ClientOptions> modifier)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class TestBetaService : IBetaService
|
||||
{
|
||||
private readonly IAnthropicClient _client;
|
||||
|
||||
public TestBetaService(IAnthropicClient client)
|
||||
{
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
public global::Anthropic.Services.Beta.IModelService Models => throw new NotImplementedException();
|
||||
|
||||
public global::Anthropic.Services.Beta.IFileService Files => throw new NotImplementedException();
|
||||
|
||||
public global::Anthropic.Services.Beta.ISkillService Skills => throw new NotImplementedException();
|
||||
|
||||
public IBetaMessageService Messages => new Mock<IBetaMessageService>().Object;
|
||||
|
||||
public IBetaService WithOptions(Func<ClientOptions, ClientOptions> modifier)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Anthropic;
|
||||
using Anthropic.Core;
|
||||
using Anthropic.Services;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Anthropic.UnitTests.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the AnthropicClientExtensions class.
|
||||
/// </summary>
|
||||
public sealed class AnthropicClientExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Test custom chat client that can be used to verify clientFactory functionality.
|
||||
/// </summary>
|
||||
private sealed class TestChatClient : IChatClient
|
||||
{
|
||||
private readonly IChatClient _innerClient;
|
||||
|
||||
public TestChatClient(IChatClient innerClient)
|
||||
{
|
||||
this._innerClient = innerClient;
|
||||
}
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> this._innerClient.GetResponseAsync(messages, options, cancellationToken);
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await foreach (var update in this._innerClient.GetStreamingResponseAsync(messages, options, cancellationToken))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
// Return this instance when requested
|
||||
if (serviceType == typeof(TestChatClient))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
return this._innerClient.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
public void Dispose() => this._innerClient.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test ChatClient implementation for testing.
|
||||
/// </summary>
|
||||
private sealed class TestAnthropicChatClient : IAnthropicClient
|
||||
{
|
||||
public TestAnthropicChatClient()
|
||||
{
|
||||
}
|
||||
|
||||
public HttpClient HttpClient { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public Uri BaseUrl { get => new("http://localhost"); init => throw new NotImplementedException(); }
|
||||
public bool ResponseValidation { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public int? MaxRetries { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public TimeSpan? Timeout { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public string? APIKey { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
public string? AuthToken { get => throw new NotImplementedException(); init => throw new NotImplementedException(); }
|
||||
|
||||
public IMessageService Messages => throw new NotImplementedException();
|
||||
|
||||
public IModelService Models => throw new NotImplementedException();
|
||||
|
||||
public IBetaService Beta => throw new NotImplementedException();
|
||||
|
||||
public Task<HttpResponse> Execute<T>(HttpRequest<T> request, CancellationToken cancellationToken = default) where T : ParamsBase
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public IAnthropicClient WithOptions(Func<ClientOptions, ClientOptions> modifier)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
var testChatClient = new TestChatClient(chatClient.AsIChatClient());
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsAIAgent(
|
||||
model: "test-model",
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
description: "Test description",
|
||||
clientFactory: (innerClient) => testChatClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
Assert.Equal("Test description", agent.Description);
|
||||
|
||||
// Verify that the custom chat client can be retrieved from the agent's service collection
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
TestChatClient? testChatClient = null;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsAIAgent(
|
||||
model: "test-model",
|
||||
instructions: "Test instructions",
|
||||
clientFactory: (innerClient) =>
|
||||
innerClient.AsBuilder().Use((innerClient) => testChatClient = new TestChatClient(innerClient)).Build());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
|
||||
// Verify that the custom chat client can be retrieved from the agent's service collection
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
var testChatClient = new TestChatClient(chatClient.AsIChatClient());
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "Test description",
|
||||
ChatOptions = new() { Instructions = "Test instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsAIAgent(
|
||||
options,
|
||||
clientFactory: (innerClient) => testChatClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
Assert.Equal("Test description", agent.Description);
|
||||
|
||||
// Verify that the custom chat client can be retrieved from the agent's service collection
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent without clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsAIAgent(
|
||||
model: "test-model",
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
|
||||
// Verify that no TestChatClient is available since no factory was provided
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with null clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsAIAgent(
|
||||
model: "test-model",
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
clientFactory: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
|
||||
// Verify that no TestChatClient is available since no factory was provided
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
((TestAnthropicChatClient)null!).AsAIAgent("test-model"));
|
||||
|
||||
Assert.Equal("client", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with options throws ArgumentNullException when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new TestAnthropicChatClient();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
chatClient.AsAIAgent((ChatClientAgentOptions)null!));
|
||||
|
||||
Assert.Equal("options", exception.ParamName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Anthropic\Microsoft.Agents.AI.Anthropic.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,666 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.AI.Agents.Persistent;
|
||||
using Azure.Core;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.Extensions;
|
||||
|
||||
public sealed class PersistentAgentsClientExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithNullClient_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
((PersistentAgentsClient)null!).GetAIAgentAsync("test-agent"));
|
||||
|
||||
Assert.Equal("persistentAgentsClient", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync throws ArgumentException when agentId is null or whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithNullOrWhitespaceAgentId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<PersistentAgentsClient>();
|
||||
|
||||
// Act & Assert - null agentId
|
||||
var exception1 = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
mockClient.Object.GetAIAgentAsync(null!));
|
||||
Assert.Equal("agentId", exception1.ParamName);
|
||||
|
||||
// Act & Assert - empty agentId
|
||||
var exception2 = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
mockClient.Object.GetAIAgentAsync(""));
|
||||
Assert.Equal("agentId", exception2.ParamName);
|
||||
|
||||
// Act & Assert - whitespace agentId
|
||||
var exception3 = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
mockClient.Object.GetAIAgentAsync(" "));
|
||||
Assert.Equal("agentId", exception3.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithNullClient_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
((PersistentAgentsClient)null!).CreateAIAgentAsync("test-model"));
|
||||
|
||||
Assert.Equal("persistentAgentsClient", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent with clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithClientFactory_AppliesFactoryCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
TestChatClient? testChatClient = null;
|
||||
|
||||
// Act
|
||||
var agent = await client.GetAIAgentAsync(
|
||||
agentId: "test-agent-id",
|
||||
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent without clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithoutClientFactory_WorksNormallyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act
|
||||
var agent = await client.GetAIAgentAsync(agentId: "test-agent-id");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent with null clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithNullClientFactory_WorksNormallyAsync()
|
||||
{
|
||||
// Arrange
|
||||
PersistentAgentsClient client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act
|
||||
var agent = await client.GetAIAgentAsync(agentId: "test-agent-id", clientFactory: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync with clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithClientFactory_AppliesFactoryCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
TestChatClient? testChatClient = null;
|
||||
|
||||
// Act
|
||||
var agent = await client.CreateAIAgentAsync(
|
||||
model: "test-model",
|
||||
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent without clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithoutClientFactory_WorksNormallyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act
|
||||
var agent = await client.CreateAIAgentAsync(model: "test-model");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with null clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithNullClientFactory_WorksNormallyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act
|
||||
var agent = await client.CreateAIAgentAsync(model: "test-model", clientFactory: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent with Response and options works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithResponseAndOptions_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var persistentAgent = ModelReaderWriter.Read<PersistentAgent>(BinaryData.FromString("""{"id": "agent_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!;
|
||||
var response = Response.FromValue(persistentAgent, new FakeResponse());
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Override Name",
|
||||
Description = "Override Description",
|
||||
ChatOptions = new() { Instructions = "Override Instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(response, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Override Name", agent.Name);
|
||||
Assert.Equal("Override Description", agent.Description);
|
||||
Assert.Equal("Override Instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent with PersistentAgent and options works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithPersistentAgentAndOptions_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var persistentAgent = ModelReaderWriter.Read<PersistentAgent>(BinaryData.FromString("""{"id": "agent_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!;
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Override Name",
|
||||
Description = "Override Description",
|
||||
ChatOptions = new() { Instructions = "Override Instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(persistentAgent, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Override Name", agent.Name);
|
||||
Assert.Equal("Override Description", agent.Description);
|
||||
Assert.Equal("Override Instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent with PersistentAgent and options falls back to agent metadata when options are null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithPersistentAgentAndOptionsWithNullFields_FallsBackToAgentMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var persistentAgent = ModelReaderWriter.Read<PersistentAgent>(BinaryData.FromString("""{"id": "agent_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!;
|
||||
|
||||
var options = new ChatClientAgentOptions(); // Empty options
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(persistentAgent, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Original Name", agent.Name);
|
||||
Assert.Equal("Original Description", agent.Description);
|
||||
Assert.Equal("Original Instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync with agentId and options works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithAgentIdAndOptions_WorksCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
const string AgentId = "agent_abc123";
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Override Name",
|
||||
Description = "Override Description",
|
||||
ChatOptions = new() { Instructions = "Override Instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = await client.GetAIAgentAsync(AgentId, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Override Name", agent.Name);
|
||||
Assert.Equal("Override Description", agent.Description);
|
||||
Assert.Equal("Override Instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent with clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var persistentAgent = ModelReaderWriter.Read<PersistentAgent>(BinaryData.FromString("""{"id": "agent_abc123", "name": "Test Agent"}"""))!;
|
||||
var testChatClient = new TestChatClient(client.AsIChatClient("agent_abc123"));
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Test Agent"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(
|
||||
persistentAgent,
|
||||
options,
|
||||
clientFactory: (innerClient) => testChatClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
|
||||
// Verify that the custom chat client can be retrieved from the agent's service collection
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent throws ArgumentNullException when response is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithNullResponse_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var options = new ChatClientAgentOptions();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
client.AsAIAgent(null!, options));
|
||||
|
||||
Assert.Equal("persistentAgentResponse", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent throws ArgumentNullException when persistentAgent is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithNullPersistentAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var options = new ChatClientAgentOptions();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
client.AsAIAgent((PersistentAgent)null!, options));
|
||||
|
||||
Assert.Equal("persistentAgentMetadata", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent throws ArgumentNullException when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithNullOptions_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var persistentAgent = ModelReaderWriter.Read<PersistentAgent>(BinaryData.FromString("""{"id": "agent_abc123"}"""))!;
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
client.AsAIAgent(persistentAgent, (ChatClientAgentOptions)null!));
|
||||
|
||||
Assert.Equal("options", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithOptionsAndEmptyAgentId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var options = new ChatClientAgentOptions();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
client.GetAIAgentAsync(string.Empty, options));
|
||||
|
||||
Assert.Equal("agentId", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync with options works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithOptions_WorksCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
const string Model = "test-model";
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "Test description",
|
||||
ChatOptions = new() { Instructions = "Test instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = await client.CreateAIAgentAsync(Model, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
Assert.Equal("Test description", agent.Description);
|
||||
Assert.Equal("Test instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync with options and clientFactory applies the factory correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithOptionsAndClientFactory_AppliesFactoryCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
TestChatClient? testChatClient = null;
|
||||
const string Model = "test-model";
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Test Agent"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = await client.CreateAIAgentAsync(
|
||||
Model,
|
||||
options,
|
||||
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
|
||||
// Verify that the custom chat client can be retrieved from the agent's service collection
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync throws ArgumentNullException when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithNullOptions_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
client.CreateAIAgentAsync("test-model", (ChatClientAgentOptions)null!));
|
||||
|
||||
Assert.Equal("options", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync throws ArgumentException when model is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithEmptyModel_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var options = new ChatClientAgentOptions();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
client.CreateAIAgentAsync(string.Empty, options));
|
||||
|
||||
Assert.Equal("model", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithServices_PassesServicesToAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var serviceProvider = new TestServiceProvider();
|
||||
const string Model = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = await client.CreateAIAgentAsync(
|
||||
Model,
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
services: serviceProvider);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
|
||||
// Verify the IServiceProvider was passed through to the FunctionInvokingChatClient
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
Assert.NotNull(chatClient);
|
||||
var functionInvokingClient = chatClient.GetService<FunctionInvokingChatClient>();
|
||||
Assert.NotNull(functionInvokingClient);
|
||||
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithServices_PassesServicesToAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var serviceProvider = new TestServiceProvider();
|
||||
|
||||
// Act
|
||||
var agent = await client.GetAIAgentAsync("agent_abc123", services: serviceProvider);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
|
||||
// Verify the IServiceProvider was passed through to the FunctionInvokingChatClient
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
Assert.NotNull(chatClient);
|
||||
var functionInvokingClient = chatClient.GetService<FunctionInvokingChatClient>();
|
||||
Assert.NotNull(functionInvokingClient);
|
||||
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with both clientFactory and services works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAIAgentAsync_WithClientFactoryAndServices_AppliesBothCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var serviceProvider = new TestServiceProvider();
|
||||
TestChatClient? testChatClient = null;
|
||||
const string Model = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = await client.CreateAIAgentAsync(
|
||||
Model,
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient),
|
||||
services: serviceProvider);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
|
||||
// Verify the custom chat client was applied
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
|
||||
// Verify the IServiceProvider was passed through
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
Assert.NotNull(chatClient);
|
||||
var functionInvokingClient = chatClient.GetService<FunctionInvokingChatClient>();
|
||||
Assert.NotNull(functionInvokingClient);
|
||||
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses reflection to access the FunctionInvocationServices property which is not public.
|
||||
/// </summary>
|
||||
private static IServiceProvider? GetFunctionInvocationServices(FunctionInvokingChatClient client)
|
||||
{
|
||||
var property = typeof(FunctionInvokingChatClient).GetProperty(
|
||||
"FunctionInvocationServices",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
return property?.GetValue(client) as IServiceProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test custom chat client that can be used to verify clientFactory functionality.
|
||||
/// </summary>
|
||||
private sealed class TestChatClient : DelegatingChatClient
|
||||
{
|
||||
public TestChatClient(IChatClient innerClient) : base(innerClient)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple test IServiceProvider implementation for testing.
|
||||
/// </summary>
|
||||
private sealed class TestServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
public sealed class FakePersistentAgentsAdministrationClient : PersistentAgentsAdministrationClient
|
||||
{
|
||||
public FakePersistentAgentsAdministrationClient()
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<Response<PersistentAgent>> CreateAgentAsync(string model, string? name = null, string? description = null, string? instructions = null, IEnumerable<ToolDefinition>? tools = null, ToolResources? toolResources = null, float? temperature = null, float? topP = null, BinaryData? responseFormat = null, IReadOnlyDictionary<string, string>? metadata = null, CancellationToken cancellationToken = default)
|
||||
=> await Task.FromResult(this.FakeResponse);
|
||||
|
||||
public override Response<PersistentAgent> CreateAgent(string model, string? name = null, string? description = null, string? instructions = null, IEnumerable<ToolDefinition>? tools = null, ToolResources? toolResources = null, float? temperature = null, float? topP = null, BinaryData? responseFormat = null, IReadOnlyDictionary<string, string>? metadata = null, CancellationToken cancellationToken = default)
|
||||
=> this.FakeResponse;
|
||||
|
||||
public override Response<PersistentAgent> GetAgent(string assistantId, CancellationToken cancellationToken = default)
|
||||
=> this.FakeResponse;
|
||||
|
||||
public override async Task<Response<PersistentAgent>> GetAgentAsync(string assistantId, CancellationToken cancellationToken = default)
|
||||
=> await Task.FromResult(this.FakeResponse);
|
||||
|
||||
private Response<PersistentAgent> FakeResponse => Response.FromValue(ModelReaderWriter.Read<PersistentAgent>(BinaryData.FromString("""{"id": "agent_abc123"}""")), new FakeResponse())!;
|
||||
}
|
||||
|
||||
private static PersistentAgentsClient CreateFakePersistentAgentsClient()
|
||||
{
|
||||
var client = new PersistentAgentsClient("https://any.com", DelegatedTokenCredential.Create((_, _) => new AccessToken()));
|
||||
|
||||
((TypeInfo)typeof(PersistentAgentsClient)).DeclaredFields.First(f => f.Name == "_client")
|
||||
.SetValue(client, new FakePersistentAgentsAdministrationClient());
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed class FakeResponse : Response
|
||||
{
|
||||
public override int Status => throw new NotImplementedException();
|
||||
|
||||
public override string ReasonPhrase => throw new NotImplementedException();
|
||||
|
||||
public override Stream? ContentStream { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
|
||||
public override string ClientRequestId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override bool ContainsHeader(string name)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override IEnumerable<HttpHeader> EnumerateHeaders()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override bool TryGetHeader(string name, out string value)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override bool TryGetHeaderValues(string name, out IEnumerable<string> values)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
|
||||
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
|
||||
|
||||
public class AzureAIProjectChatClientTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that when the ChatOptions has a "conv_" prefixed conversation ID, the chat client uses conversation in the http requests via the chat client
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesDefaultConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.RequestUri!.PathAndQuery.Contains("openai/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = await client.GetAIAgentAsync(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "test-agent",
|
||||
ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_12345" }
|
||||
});
|
||||
|
||||
// Act
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await agent.RunAsync("Hello", thread);
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientThread = Assert.IsType<ChatClientAgentThread>(thread);
|
||||
Assert.Equal("conv_12345", chatClientThread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client doesn't have a default "conv_" conversation id, the chat client still uses the conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesPerRequestConversationId_WhenNoDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.RequestUri!.PathAndQuery.Contains("openai/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = await client.GetAIAgentAsync(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "test-agent",
|
||||
ChatOptions = new() { Instructions = "Test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await agent.RunAsync("Hello", thread, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientThread = Assert.IsType<ChatClientAgentThread>(thread);
|
||||
Assert.Equal("conv_12345", chatClientThread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that even when the chat client has a default conversation id, the chat client will prioritize the per-request conversation id provided in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesPerRequestConversationId_EvenWhenDefaultConversationIdIsProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.RequestUri!.PathAndQuery.Contains("openai/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("conv_12345", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = await client.GetAIAgentAsync(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "test-agent",
|
||||
ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_should_not_use_default" }
|
||||
});
|
||||
|
||||
// Act
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await agent.RunAsync("Hello", thread, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientThread = Assert.IsType<ChatClientAgentThread>(thread);
|
||||
Assert.Equal("conv_12345", chatClientThread.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when the chat client is provided without a "conv_" prefixed conversation ID, the chat client uses the previous conversation ID in HTTP requests.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatClient_UsesPreviousResponseId_WhenConversationIsNotPrefixedAsConvAsync()
|
||||
{
|
||||
// Arrange
|
||||
var requestTriggered = false;
|
||||
using var httpHandler = new HttpHandlerAssert(async (request) =>
|
||||
{
|
||||
if (request.RequestUri!.PathAndQuery.Contains("openai/responses"))
|
||||
{
|
||||
requestTriggered = true;
|
||||
|
||||
// Assert
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
Assert.Contains("resp_0888a", requestBody);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") };
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var client = new AIProjectClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) });
|
||||
|
||||
var agent = await client.GetAIAgentAsync(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "test-agent",
|
||||
ChatOptions = new() { Instructions = "Test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
var thread = await agent.GetNewThreadAsync();
|
||||
await agent.RunAsync("Hello", thread, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "resp_0888a" } });
|
||||
|
||||
Assert.True(requestTriggered);
|
||||
var chatClientThread = Assert.IsType<ChatClientAgentThread>(thread);
|
||||
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientThread.ConversationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
|
||||
|
||||
internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider
|
||||
{
|
||||
public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary<string, object> properties)
|
||||
{
|
||||
return new GetTokenOptions(new Dictionary<string, object>());
|
||||
}
|
||||
|
||||
public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
|
||||
public override ValueTask<AuthenticationToken> GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AuthenticationToken>(this.GetToken(options, cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
|
||||
|
||||
internal sealed class HttpHandlerAssert : HttpClientHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage>? _assertion;
|
||||
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>>? _assertionAsync;
|
||||
|
||||
public HttpHandlerAssert(Func<HttpRequestMessage, HttpResponseMessage> assertion)
|
||||
{
|
||||
this._assertion = assertion;
|
||||
}
|
||||
public HttpHandlerAssert(Func<HttpRequestMessage, Task<HttpResponseMessage>> assertionAsync)
|
||||
{
|
||||
this._assertionAsync = assertionAsync;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._assertionAsync is not null)
|
||||
{
|
||||
return await this._assertionAsync.Invoke(request);
|
||||
}
|
||||
|
||||
return this._assertion!.Invoke(request);
|
||||
}
|
||||
|
||||
#if NET
|
||||
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return this._assertion!(request);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="TestData\AgentResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\AgentVersionResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\OpenAIDefaultResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"object": "agent",
|
||||
"id": "agent_abc123",
|
||||
"name": "agent_abc123",
|
||||
"versions": {
|
||||
"latest": {
|
||||
"metadata": {},
|
||||
"object": "agent.version",
|
||||
"id": "agent_abc123:1",
|
||||
"name": "agent_abc123",
|
||||
"version": "1",
|
||||
"description": "",
|
||||
"created_at": 1761771936,
|
||||
"definition": "agent-definition-placeholder"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"object": "agent.version",
|
||||
"id": "agent_abc123:1",
|
||||
"name": "agent_abc123",
|
||||
"version": "1",
|
||||
"description": "",
|
||||
"created_at": 1761771936,
|
||||
"definition": "agent-definition-placeholder"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"id": "resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7",
|
||||
"object": "response",
|
||||
"created_at": 1762941294,
|
||||
"status": "completed",
|
||||
"background": false,
|
||||
"billing": {
|
||||
"payer": "developer"
|
||||
},
|
||||
"error": null,
|
||||
"incomplete_details": null,
|
||||
"instructions": null,
|
||||
"max_output_tokens": null,
|
||||
"max_tool_calls": null,
|
||||
"model": "gpt-4o-mini-2024-07-18",
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_0888a46cbf2b1ff3006914596f814481958e8cf500a6dabbec",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"annotations": [],
|
||||
"logprobs": [],
|
||||
"text": "Hello! How can I assist you today?"
|
||||
}
|
||||
],
|
||||
"role": "assistant"
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": true,
|
||||
"previous_response_id": null,
|
||||
"prompt_cache_key": null,
|
||||
"prompt_cache_retention": null,
|
||||
"reasoning": {
|
||||
"effort": null,
|
||||
"summary": null
|
||||
},
|
||||
"safety_identifier": null,
|
||||
"service_tier": "default",
|
||||
"store": true,
|
||||
"temperature": 1.0,
|
||||
"text": {
|
||||
"format": {
|
||||
"type": "text"
|
||||
},
|
||||
"verbosity": "medium"
|
||||
},
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"top_logprobs": 0,
|
||||
"top_p": 1.0,
|
||||
"truncation": "disabled",
|
||||
"usage": {
|
||||
"input_tokens": 9,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
},
|
||||
"output_tokens": 10,
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 0
|
||||
},
|
||||
"total_tokens": 19
|
||||
},
|
||||
"user": null,
|
||||
"metadata": {}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.IO;
|
||||
using Azure.AI.Projects.OpenAI;
|
||||
|
||||
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for loading and processing test data files.
|
||||
/// </summary>
|
||||
internal static class TestDataUtil
|
||||
{
|
||||
private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json");
|
||||
private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json");
|
||||
private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json");
|
||||
|
||||
private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\"";
|
||||
|
||||
private const string DefaultAgentDefinition = """
|
||||
{
|
||||
"kind": "prompt",
|
||||
"model": "gpt-5-mini",
|
||||
"instructions": "You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.",
|
||||
"tools": []
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent response JSON with optional placeholder replacements applied.
|
||||
/// </summary>
|
||||
public static string GetAgentResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
|
||||
{
|
||||
var json = s_agentResponseJson;
|
||||
json = ApplyAgentName(json, agentName);
|
||||
json = ApplyAgentDefinition(json, agentDefinition);
|
||||
json = ApplyInstructions(json, instructions);
|
||||
json = ApplyDescription(json, description);
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent version response JSON with optional placeholder replacements applied.
|
||||
/// </summary>
|
||||
public static string GetAgentVersionResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
|
||||
{
|
||||
var json = s_agentVersionResponseJson;
|
||||
json = ApplyAgentName(json, agentName);
|
||||
json = ApplyAgentDefinition(json, agentDefinition);
|
||||
json = ApplyInstructions(json, instructions);
|
||||
json = ApplyDescription(json, description);
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OpenAI default response JSON with optional placeholder replacements applied.
|
||||
/// </summary>
|
||||
public static string GetOpenAIDefaultResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
|
||||
{
|
||||
var json = s_openAIDefaultResponseJson;
|
||||
json = ApplyAgentName(json, agentName);
|
||||
json = ApplyAgentDefinition(json, agentDefinition);
|
||||
json = ApplyInstructions(json, instructions);
|
||||
json = ApplyDescription(json, description);
|
||||
return json;
|
||||
}
|
||||
|
||||
private static string ApplyAgentName(string json, string? agentName)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
return json.Replace("\"agent_abc123\"", $"\"{agentName}\"");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
private static string ApplyAgentDefinition(string json, AgentDefinition? definition)
|
||||
{
|
||||
return (definition is not null)
|
||||
? json.Replace(AgentDefinitionPlaceholder, ModelReaderWriter.Write(definition).ToString())
|
||||
: json.Replace(AgentDefinitionPlaceholder, DefaultAgentDefinition);
|
||||
}
|
||||
|
||||
private static string ApplyInstructions(string json, string? instructions)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(instructions))
|
||||
{
|
||||
return json.Replace("You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.", instructions);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
private static string ApplyDescription(string json, string? description)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
{
|
||||
return json.Replace("\"description\": \"\"", $"\"description\": \"{description}\"");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# EditorConfig overrides for Cosmos DB Unit Tests
|
||||
# Multi-targeting (net472 + net9.0) causes false positives for IDE0005 (unnecessary using directives)
|
||||
|
||||
root = false
|
||||
|
||||
[*.cs]
|
||||
# Suppress IDE0005 for this project - multi-targeting causes false positives
|
||||
# These using directives ARE necessary but appear unnecessary in one target framework
|
||||
dotnet_diagnostic.IDE0005.severity = none
|
||||
@@ -0,0 +1,819 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="CosmosChatMessageStore"/>.
|
||||
///
|
||||
/// Test Modes:
|
||||
/// - Default Mode: Cleans up all test data after each test run (deletes database)
|
||||
/// - Preserve Mode: Keeps containers and data for inspection in Cosmos DB Emulator Data Explorer
|
||||
///
|
||||
/// To enable Preserve Mode, set environment variable: COSMOS_PRESERVE_CONTAINERS=true
|
||||
/// Example: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test
|
||||
///
|
||||
/// In Preserve Mode, you can view the data in Cosmos DB Emulator Data Explorer at:
|
||||
/// https://localhost:8081/_explorer/index.html
|
||||
/// Database: AgentFrameworkTests
|
||||
/// Container: ChatMessages
|
||||
///
|
||||
/// Environment Variable Reference:
|
||||
/// | Variable | Values | Description |
|
||||
/// |----------|--------|-------------|
|
||||
/// | COSMOS_PRESERVE_CONTAINERS | true / false | Controls whether to preserve test data after completion |
|
||||
///
|
||||
/// Usage Examples:
|
||||
/// - Run all tests in preserve mode: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/
|
||||
/// - Run specific test category in preserve mode: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/ --filter "Category=CosmosDB"
|
||||
/// - Reset to cleanup mode: $env:COSMOS_PRESERVE_CONTAINERS=""; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/
|
||||
/// </summary>
|
||||
[Collection("CosmosDB")]
|
||||
public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
|
||||
{
|
||||
// Cosmos DB Emulator connection settings
|
||||
private const string EmulatorEndpoint = "https://localhost:8081";
|
||||
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
|
||||
private const string TestContainerId = "ChatMessages";
|
||||
private const string HierarchicalTestContainerId = "HierarchicalChatMessages";
|
||||
// Use unique database ID per test class instance to avoid conflicts
|
||||
#pragma warning disable CA1802 // Use literals where appropriate
|
||||
private static readonly string s_testDatabaseId = $"AgentFrameworkTests-ChatStore-{Guid.NewGuid():N}";
|
||||
#pragma warning restore CA1802
|
||||
|
||||
private string _connectionString = string.Empty;
|
||||
private bool _emulatorAvailable;
|
||||
private bool _preserveContainer;
|
||||
private CosmosClient? _setupClient; // Only used for test setup/cleanup
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Fail fast if emulator is not available
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Check environment variable to determine if we should preserve containers
|
||||
// Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection
|
||||
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
this._connectionString = $"AccountEndpoint={EmulatorEndpoint};AccountKey={EmulatorKey}";
|
||||
|
||||
try
|
||||
{
|
||||
// Only create CosmosClient for test setup - the actual tests will use connection string constructors
|
||||
this._setupClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
|
||||
// Test connection by attempting to create database
|
||||
var databaseResponse = await this._setupClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId);
|
||||
|
||||
// Create container for simple partitioning tests
|
||||
await databaseResponse.Database.CreateContainerIfNotExistsAsync(
|
||||
TestContainerId,
|
||||
"/conversationId",
|
||||
throughput: 400);
|
||||
|
||||
// Create container for hierarchical partitioning tests with hierarchical partition key
|
||||
var hierarchicalContainerProperties = new ContainerProperties(HierarchicalTestContainerId, ["/tenantId", "/userId", "/sessionId"]);
|
||||
await databaseResponse.Database.CreateContainerIfNotExistsAsync(
|
||||
hierarchicalContainerProperties,
|
||||
throughput: 400);
|
||||
|
||||
this._emulatorAvailable = true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Emulator not available, tests will be skipped
|
||||
this._emulatorAvailable = false;
|
||||
this._setupClient?.Dispose();
|
||||
this._setupClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (this._setupClient != null && this._emulatorAvailable)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (this._preserveContainer)
|
||||
{
|
||||
// Preserve mode: Don't delete the database/container, keep data for inspection
|
||||
// This allows viewing data in the Cosmos DB Emulator Data Explorer
|
||||
// No cleanup needed - data persists for debugging
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clean mode: Delete the test database and all data
|
||||
var database = this._setupClient.GetDatabase(s_testDatabaseId);
|
||||
await database.DeleteAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore cleanup errors during test teardown
|
||||
Console.WriteLine($"Warning: Cleanup failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._setupClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._setupClient?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void SkipIfEmulatorNotAvailable()
|
||||
{
|
||||
// In CI: Skip if COSMOS_EMULATOR_AVAILABLE is not set to "true"
|
||||
// Locally: Skip if emulator connection check failed
|
||||
var ciEmulatorAvailable = string.Equals(Environment.GetEnvironmentVariable("COSMOS_EMULATOR_AVAILABLE"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Xunit.Skip.If(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available");
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithConnectionString_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, "test-conversation");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.Equal("test-conversation", store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(TestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithConnectionStringNoConversationId_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.NotNull(store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(TestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithNullConnectionString_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new CosmosChatMessageStore((string)null!, s_testDatabaseId, TestContainerId, "test-conversation"));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithEmptyConversationId_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, ""));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokedAsync Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_WithSingleMessage_ShouldAddMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var conversationId = Guid.NewGuid().ToString();
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
|
||||
var message = new ChatMessage(ChatRole.User, "Hello, world!");
|
||||
|
||||
var context = new ChatMessageStore.InvokedContext([message], [])
|
||||
{
|
||||
ResponseMessages = []
|
||||
};
|
||||
|
||||
// Act
|
||||
await store.InvokedAsync(context);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
var messages = await store.InvokingAsync(invokingContext);
|
||||
var messageList = messages.ToList();
|
||||
|
||||
// Simple assertion - if this fails, we know the deserialization is the issue
|
||||
if (messageList.Count == 0)
|
||||
{
|
||||
// Let's check if we can find ANY items in the container for this conversation
|
||||
var directQuery = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId")
|
||||
.WithParameter("@conversationId", conversationId);
|
||||
var countIterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(TestContainerId)
|
||||
.GetItemQueryIterator<int>(directQuery, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = new PartitionKey(conversationId)
|
||||
});
|
||||
|
||||
var countResponse = await countIterator.ReadNextAsync();
|
||||
var count = countResponse.FirstOrDefault();
|
||||
|
||||
// Debug: Let's see what the raw query returns
|
||||
var rawQuery = new QueryDefinition("SELECT * FROM c WHERE c.conversationId = @conversationId")
|
||||
.WithParameter("@conversationId", conversationId);
|
||||
var rawIterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(TestContainerId)
|
||||
.GetItemQueryIterator<dynamic>(rawQuery, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = new PartitionKey(conversationId)
|
||||
});
|
||||
|
||||
List<dynamic> rawResults = [];
|
||||
while (rawIterator.HasMoreResults)
|
||||
{
|
||||
var rawResponse = await rawIterator.ReadNextAsync();
|
||||
rawResults.AddRange(rawResponse);
|
||||
}
|
||||
|
||||
string rawJson = rawResults.Count > 0 ? Newtonsoft.Json.JsonConvert.SerializeObject(rawResults[0], Newtonsoft.Json.Formatting.Indented) : "null";
|
||||
Assert.Fail($"InvokingAsync returned 0 messages, but direct count query found {count} items for conversation {conversationId}. Raw document: {rawJson}");
|
||||
}
|
||||
|
||||
Assert.Single(messageList);
|
||||
Assert.Equal("Hello, world!", messageList[0].Text);
|
||||
Assert.Equal(ChatRole.User, messageList[0].Role);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_WithMultipleMessages_ShouldAddAllMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var conversationId = Guid.NewGuid().ToString();
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
|
||||
var requestMessages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "First message"),
|
||||
new ChatMessage(ChatRole.Assistant, "Second message"),
|
||||
new ChatMessage(ChatRole.User, "Third message")
|
||||
};
|
||||
var aiContextProviderMessages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.System, "System context message")
|
||||
};
|
||||
var responseMessages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.Assistant, "Response message")
|
||||
};
|
||||
|
||||
var context = new ChatMessageStore.InvokedContext(requestMessages, [])
|
||||
{
|
||||
AIContextProviderMessages = aiContextProviderMessages,
|
||||
ResponseMessages = responseMessages
|
||||
};
|
||||
|
||||
// Act
|
||||
await store.InvokedAsync(context);
|
||||
|
||||
// Assert
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
var retrievedMessages = await store.InvokingAsync(invokingContext);
|
||||
var messageList = retrievedMessages.ToList();
|
||||
Assert.Equal(5, messageList.Count);
|
||||
Assert.Equal("First message", messageList[0].Text);
|
||||
Assert.Equal("Second message", messageList[1].Text);
|
||||
Assert.Equal("Third message", messageList[2].Text);
|
||||
Assert.Equal("System context message", messageList[3].Text);
|
||||
Assert.Equal("Response message", messageList[4].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokingAsync Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokingAsync_WithNoMessages_ShouldReturnEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString());
|
||||
|
||||
// Act
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
var messages = await store.InvokingAsync(invokingContext);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(messages);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokingAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var conversation1 = Guid.NewGuid().ToString();
|
||||
var conversation2 = Guid.NewGuid().ToString();
|
||||
|
||||
using var store1 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation1);
|
||||
using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation2);
|
||||
|
||||
var context1 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message for conversation 1")], []);
|
||||
var context2 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message for conversation 2")], []);
|
||||
|
||||
await store1.InvokedAsync(context1);
|
||||
await store2.InvokedAsync(context2);
|
||||
|
||||
// Act
|
||||
var invokingContext1 = new ChatMessageStore.InvokingContext([]);
|
||||
var invokingContext2 = new ChatMessageStore.InvokingContext([]);
|
||||
|
||||
var messages1 = await store1.InvokingAsync(invokingContext1);
|
||||
var messages2 = await store2.InvokingAsync(invokingContext2);
|
||||
|
||||
// Assert
|
||||
var messageList1 = messages1.ToList();
|
||||
var messageList2 = messages2.ToList();
|
||||
Assert.Single(messageList1);
|
||||
Assert.Single(messageList2);
|
||||
Assert.Equal("Message for conversation 1", messageList1[0].Text);
|
||||
Assert.Equal("Message for conversation 2", messageList2[0].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task FullWorkflow_AddAndGet_ShouldWorkCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var conversationId = $"test-conversation-{Guid.NewGuid():N}"; // Use unique conversation ID
|
||||
using var originalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
|
||||
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.System, "You are a helpful assistant."),
|
||||
new ChatMessage(ChatRole.User, "Hello!"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi there! How can I help you today?"),
|
||||
new ChatMessage(ChatRole.User, "What's the weather like?"),
|
||||
new ChatMessage(ChatRole.Assistant, "I'm sorry, I don't have access to current weather data.")
|
||||
};
|
||||
|
||||
// Act 1: Add messages
|
||||
var invokedContext = new ChatMessageStore.InvokedContext(messages, []);
|
||||
await originalStore.InvokedAsync(invokedContext);
|
||||
|
||||
// Act 2: Verify messages were added
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
var retrievedMessages = await originalStore.InvokingAsync(invokingContext);
|
||||
var retrievedList = retrievedMessages.ToList();
|
||||
Assert.Equal(5, retrievedList.Count);
|
||||
|
||||
// Act 3: Create new store instance for same conversation (test persistence)
|
||||
using var newStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
|
||||
var persistedMessages = await newStore.InvokingAsync(invokingContext);
|
||||
var persistedList = persistedMessages.ToList();
|
||||
|
||||
// Assert final state
|
||||
Assert.Equal(5, persistedList.Count);
|
||||
Assert.Equal("You are a helpful assistant.", persistedList[0].Text);
|
||||
Assert.Equal("Hello!", persistedList[1].Text);
|
||||
Assert.Equal("Hi there! How can I help you today?", persistedList[2].Text);
|
||||
Assert.Equal("What's the weather like?", persistedList[3].Text);
|
||||
Assert.Equal("I'm sorry, I don't have access to current weather data.", persistedList[4].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Disposal Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Dispose_AfterUse_ShouldNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString());
|
||||
|
||||
// Act & Assert
|
||||
store.Dispose(); // Should not throw
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Dispose_MultipleCalls_ShouldNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString());
|
||||
|
||||
// Act & Assert
|
||||
store.Dispose(); // First call
|
||||
store.Dispose(); // Second call - should not throw
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hierarchical Partitioning Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalConnectionString_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", "session-789");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.Equal("session-789", store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(HierarchicalTestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalEndpoint_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
TokenCredential credential = new DefaultAzureCredential();
|
||||
using var store = new CosmosChatMessageStore(EmulatorEndpoint, credential, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", "session-789");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.Equal("session-789", store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(HierarchicalTestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalCosmosClient_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
using var cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
using var store = new CosmosChatMessageStore(cosmosClient, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", "session-789");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.Equal("session-789", store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(HierarchicalTestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalNullTenantId_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, null!, "user-456", "session-789"));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalEmptyUserId_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "", "session-789"));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalWhitespaceSessionId_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", " "));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string TenantId = "tenant-123";
|
||||
const string UserId = "user-456";
|
||||
const string SessionId = "session-789";
|
||||
// Test hierarchical partitioning constructor with connection string
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
|
||||
var message = new ChatMessage(ChatRole.User, "Hello from hierarchical partitioning!");
|
||||
|
||||
var context = new ChatMessageStore.InvokedContext([message], []);
|
||||
|
||||
// Act
|
||||
await store.InvokedAsync(context);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
var messages = await store.InvokingAsync(invokingContext);
|
||||
var messageList = messages.ToList();
|
||||
|
||||
Assert.Single(messageList);
|
||||
Assert.Equal("Hello from hierarchical partitioning!", messageList[0].Text);
|
||||
Assert.Equal(ChatRole.User, messageList[0].Role);
|
||||
|
||||
// Verify that the document is stored with hierarchical partitioning metadata
|
||||
var directQuery = new QueryDefinition("SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
|
||||
.WithParameter("@conversationId", SessionId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(HierarchicalTestContainerId)
|
||||
.GetItemQueryIterator<dynamic>(directQuery, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = new PartitionKeyBuilder().Add(TenantId).Add(UserId).Add(SessionId).Build()
|
||||
});
|
||||
|
||||
var response = await iterator.ReadNextAsync();
|
||||
var document = response.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(document);
|
||||
// The document should have hierarchical metadata
|
||||
Assert.Equal(SessionId, (string)document!.conversationId);
|
||||
Assert.Equal(TenantId, (string)document!.tenantId);
|
||||
Assert.Equal(UserId, (string)document!.userId);
|
||||
Assert.Equal(SessionId, (string)document!.sessionId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokedAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string TenantId = "tenant-batch";
|
||||
const string UserId = "user-batch";
|
||||
const string SessionId = "session-batch";
|
||||
// Test hierarchical partitioning constructor with connection string
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "First hierarchical message"),
|
||||
new ChatMessage(ChatRole.Assistant, "Second hierarchical message"),
|
||||
new ChatMessage(ChatRole.User, "Third hierarchical message")
|
||||
};
|
||||
|
||||
var context = new ChatMessageStore.InvokedContext(messages, []);
|
||||
|
||||
// Act
|
||||
await store.InvokedAsync(context);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
var retrievedMessages = await store.InvokingAsync(invokingContext);
|
||||
var messageList = retrievedMessages.ToList();
|
||||
|
||||
Assert.Equal(3, messageList.Count);
|
||||
Assert.Equal("First hierarchical message", messageList[0].Text);
|
||||
Assert.Equal("Second hierarchical message", messageList[1].Text);
|
||||
Assert.Equal("Third hierarchical message", messageList[2].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task InvokingAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string TenantId = "tenant-isolation";
|
||||
const string UserId1 = "user-1";
|
||||
const string UserId2 = "user-2";
|
||||
const string SessionId = "session-isolation";
|
||||
|
||||
// Different userIds create different hierarchical partitions, providing proper isolation
|
||||
using var store1 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId1, SessionId);
|
||||
using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId2, SessionId);
|
||||
|
||||
// Add messages to both stores
|
||||
var context1 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message from user 1")], []);
|
||||
var context2 = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Message from user 2")], []);
|
||||
|
||||
await store1.InvokedAsync(context1);
|
||||
await store2.InvokedAsync(context2);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act & Assert
|
||||
var invokingContext1 = new ChatMessageStore.InvokingContext([]);
|
||||
var invokingContext2 = new ChatMessageStore.InvokingContext([]);
|
||||
|
||||
var messages1 = await store1.InvokingAsync(invokingContext1);
|
||||
var messageList1 = messages1.ToList();
|
||||
|
||||
var messages2 = await store2.InvokingAsync(invokingContext2);
|
||||
var messageList2 = messages2.ToList();
|
||||
|
||||
// With true hierarchical partitioning, each user sees only their own messages
|
||||
Assert.Single(messageList1);
|
||||
Assert.Single(messageList2);
|
||||
Assert.Equal("Message from user 1", messageList1[0].Text);
|
||||
Assert.Equal("Message from user 2", messageList2[0].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task SerializeDeserialize_WithHierarchicalPartitioning_ShouldPreserveStateAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string TenantId = "tenant-serialize";
|
||||
const string UserId = "user-serialize";
|
||||
const string SessionId = "session-serialize";
|
||||
|
||||
using var originalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
|
||||
|
||||
var context = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Test serialization message")], []);
|
||||
await originalStore.InvokedAsync(context);
|
||||
|
||||
// Act - Serialize the store state
|
||||
var serializedState = originalStore.Serialize();
|
||||
|
||||
// Create a new store from the serialized state
|
||||
using var cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
var serializerOptions = new JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
|
||||
};
|
||||
using var deserializedStore = CosmosChatMessageStore.CreateFromSerializedState(cosmosClient, serializedState, s_testDatabaseId, HierarchicalTestContainerId, serializerOptions);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert - The deserialized store should have the same functionality
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
var messages = await deserializedStore.InvokingAsync(invokingContext);
|
||||
var messageList = messages.ToList();
|
||||
|
||||
Assert.Single(messageList);
|
||||
Assert.Equal("Test serialization message", messageList[0].Text);
|
||||
Assert.Equal(SessionId, deserializedStore.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, deserializedStore.DatabaseId);
|
||||
Assert.Equal(HierarchicalTestContainerId, deserializedStore.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task HierarchicalAndSimplePartitioning_ShouldCoexistAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string SessionId = "coexist-session";
|
||||
|
||||
// Create simple store using simple partitioning container and hierarchical store using hierarchical container
|
||||
using var simpleStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, SessionId);
|
||||
using var hierarchicalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-coexist", "user-coexist", SessionId);
|
||||
|
||||
// Add messages to both
|
||||
var simpleContext = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Simple partitioning message")], []);
|
||||
var hierarchicalContext = new ChatMessageStore.InvokedContext([new ChatMessage(ChatRole.User, "Hierarchical partitioning message")], []);
|
||||
|
||||
await simpleStore.InvokedAsync(simpleContext);
|
||||
await hierarchicalStore.InvokedAsync(hierarchicalContext);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act & Assert
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
|
||||
var simpleMessages = await simpleStore.InvokingAsync(invokingContext);
|
||||
var simpleMessageList = simpleMessages.ToList();
|
||||
|
||||
var hierarchicalMessages = await hierarchicalStore.InvokingAsync(invokingContext);
|
||||
var hierarchicalMessageList = hierarchicalMessages.ToList();
|
||||
|
||||
// Each should only see its own messages since they use different containers
|
||||
Assert.Single(simpleMessageList);
|
||||
Assert.Single(hierarchicalMessageList);
|
||||
Assert.Equal("Simple partitioning message", simpleMessageList[0].Text);
|
||||
Assert.Equal("Hierarchical partitioning message", hierarchicalMessageList[0].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task MaxMessagesToRetrieve_ShouldLimitAndReturnMostRecentAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string ConversationId = "max-messages-test";
|
||||
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, ConversationId);
|
||||
|
||||
// Add 10 messages
|
||||
var messages = new List<ChatMessage>();
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
messages.Add(new ChatMessage(ChatRole.User, $"Message {i}"));
|
||||
await Task.Delay(10); // Small delay to ensure different timestamps
|
||||
}
|
||||
|
||||
var context = new ChatMessageStore.InvokedContext(messages, []);
|
||||
await store.InvokedAsync(context);
|
||||
|
||||
// Wait for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - Set max to 5 and retrieve
|
||||
store.MaxMessagesToRetrieve = 5;
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
var retrievedMessages = await store.InvokingAsync(invokingContext);
|
||||
var messageList = retrievedMessages.ToList();
|
||||
|
||||
// Assert - Should get the 5 most recent messages (6-10) in ascending order
|
||||
Assert.Equal(5, messageList.Count);
|
||||
Assert.Equal("Message 6", messageList[0].Text);
|
||||
Assert.Equal("Message 7", messageList[1].Text);
|
||||
Assert.Equal("Message 8", messageList[2].Text);
|
||||
Assert.Equal("Message 9", messageList[3].Text);
|
||||
Assert.Equal("Message 10", messageList[4].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task MaxMessagesToRetrieve_Null_ShouldReturnAllMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string ConversationId = "max-messages-null-test";
|
||||
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, ConversationId);
|
||||
|
||||
// Add 10 messages
|
||||
var messages = new List<ChatMessage>();
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
messages.Add(new ChatMessage(ChatRole.User, $"Message {i}"));
|
||||
}
|
||||
|
||||
var context = new ChatMessageStore.InvokedContext(messages, []);
|
||||
await store.InvokedAsync(context);
|
||||
|
||||
// Wait for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - No limit set (default null)
|
||||
var invokingContext = new ChatMessageStore.InvokingContext([]);
|
||||
var retrievedMessages = await store.InvokingAsync(invokingContext);
|
||||
var messageList = retrievedMessages.ToList();
|
||||
|
||||
// Assert - Should get all 10 messages
|
||||
Assert.Equal(10, messageList.Count);
|
||||
Assert.Equal("Message 1", messageList[0].Text);
|
||||
Assert.Equal("Message 10", messageList[9].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user