Compare commits

...
Author SHA1 Message Date
Korolev Dmitry 4ebbed6fd6 copilot changes 2025-11-04 19:30:46 +01:00
Korolev DmitryandGitHub 900135b092 Merge branch 'main' into dmkorolev/agentthreadstorages 2025-11-04 19:05:16 +01:00
Korolev Dmitry 9bbfab6c1e rename to unit tests 2025-11-04 19:00:34 +01:00
Korolev Dmitry 1538c631c8 renames / fixes 2025-11-04 18:47:35 +01:00
Korolev Dmitry 448785b6b3 move and rename 2025-11-04 18:43:34 +01:00
Korolev Dmitry 58af9ecb89 azurite as a step 2025-11-04 18:35:10 +01:00
Korolev Dmitry 027d1e04a7 rollback 2025-11-04 18:32:37 +01:00
Korolev Dmitry 3f7b0d1085 use services 2025-11-04 13:49:50 +01:00
Korolev Dmitry 8baacc4fe3 nit 2025-11-04 13:42:01 +01:00
Korolev Dmitry 2663467914 add azurite as dependency for dotnet tests 2025-11-04 13:38:05 +01:00
Korolev Dmitry 4de5f1b421 setup for azure blob as agentthreadstore 2025-11-04 13:29:56 +01:00
Korolev Dmitry 6c969418ac setup azurestorage proj 2025-11-03 19:19:14 +01:00
14 changed files with 571 additions and 3 deletions
+10 -1
View File
@@ -76,6 +76,15 @@ jobs:
dotnet
workflow-samples
- name: Start Azurite
if: matrix.os == 'ubuntu-latest'
run: |
docker run -d \
-p 10000:10000 \
-p 10001:10001 \
-p 10002:10002 \
mcr.microsoft.com/azure-storage/azurite:latest
- name: Setup dotnet
uses: actions/setup-dotnet@v5.0.0
with:
@@ -239,4 +248,4 @@ jobs:
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
script: core.setFailed('Integration Tests Cancelled!')
+2
View File
@@ -21,6 +21,7 @@
<PackageVersion Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.26.0" />
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
@@ -105,6 +106,7 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
<PackageVersion Include="xretry" Version="1.9.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<!-- Symbols -->
+4 -2
View File
@@ -133,7 +133,7 @@
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/_Foundational/">
<Project Path="samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj" />
@@ -275,6 +275,7 @@
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
@@ -298,8 +299,9 @@
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
@@ -3,6 +3,7 @@
using A2A.AspNetCore;
using AgentWebChat.AgentHost;
using AgentWebChat.AgentHost.Utilities;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Workflows;
@@ -27,6 +28,13 @@ builder.AddAIAgent(
chatClientServiceKey: "chat-model")
.WithInMemoryThreadStore();
builder.AddAIAgent(
"gambler",
instructions: "You are a gambler. Talk like a gambler.",
description: "An agent which gambles",
chatClientServiceKey: "chat-model")
.WithAzureBlobThreadStore(sp => new BlobContainerClient(connectionString: "UseDevelopmentStorage=true", "agent-threads"));
builder.AddAIAgent("knights-and-knaves", (sp, key) =>
{
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
@@ -0,0 +1,157 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting.AzureStorage.Blob;
internal sealed class AzureBlobAgentThreadStore : AgentThreadStore
{
private static readonly BlobOpenWriteOptions s_uploadJsonOptions = new()
{
HttpHeaders = new()
{
ContentType = "application/json"
}
};
private readonly BlobContainerClient _containerClient;
private readonly AzureBlobAgentThreadStoreOptions _options;
private bool _containerInitialized;
/// <summary>
/// Initializes a new instance of the <see cref="AzureBlobAgentThreadStore"/> class using a <see cref="BlobContainerClient"/>.
/// </summary>
/// <param name="containerClient">The blob container client to use for storage operations.</param>
/// <param name="options">Optional configuration options. If <see langword="null"/>, default options will be used.</param>
/// <exception cref="ArgumentNullException"><paramref name="containerClient"/> is <see langword="null"/>.</exception>
public AzureBlobAgentThreadStore(BlobContainerClient containerClient, AzureBlobAgentThreadStoreOptions? options = null)
{
this._containerClient = containerClient ?? throw new ArgumentNullException(nameof(containerClient));
this._options = options ?? new AzureBlobAgentThreadStoreOptions();
}
/// <inheritdoc/>
public override async ValueTask SaveThreadAsync(
AIAgent agent,
string conversationId,
AgentThread thread,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(conversationId);
Throw.IfNull(thread);
await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false);
var blobName = this.GetBlobName(agent.Id, conversationId);
var blobClient = this._containerClient.GetBlobClient(blobName);
JsonElement serializedThread = thread.Serialize();
using Stream stream = await blobClient.OpenWriteAsync(overwrite: true, s_uploadJsonOptions, cancellationToken).ConfigureAwait(false);
using Utf8JsonWriter writer = new(stream);
serializedThread.WriteTo(writer);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public override async ValueTask<AgentThread> GetThreadAsync(
AIAgent agent,
string conversationId,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(conversationId);
await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false);
var blobName = this.GetBlobName(agent.Id, conversationId);
var blobClient = this._containerClient.GetBlobClient(blobName);
try
{
var stream = await blobClient.OpenReadAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
var jsonDoc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var serializedThread = jsonDoc.RootElement;
return agent.DeserializeThread(serializedThread);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Blob doesn't exist, return a new thread
return agent.GetNewThread();
}
}
/// <summary>
/// Ensures that the blob container exists, creating it if necessary.
/// </summary>
private async Task EnsureContainerExistsAsync(CancellationToken cancellationToken)
{
if (this._containerInitialized)
{
return;
}
if (!this._containerInitialized)
{
if (this._options.CreateContainerIfNotExists)
{
await this._containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
this._containerInitialized = true;
}
}
/// <summary>
/// Generates the blob name for a given agent and conversation.
/// </summary>
// internal for testing
internal string GetBlobName(string agentId, string conversationId)
{
string sanitizedAgentId = this.SanitizeBlobNameSegment(agentId);
string sanitizedConversationId = this.SanitizeBlobNameSegment(conversationId);
string baseName = $"{sanitizedAgentId}/{sanitizedConversationId}.json";
return string.IsNullOrEmpty(this._options.BlobNamePrefix)
? baseName
: $"{this._options.BlobNamePrefix.TrimEnd('/')}/{baseName}";
}
/// <summary>
/// Sanitizes a string to be safe for use in blob names.
/// </summary>
private string SanitizeBlobNameSegment(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return "default";
}
// Replace invalid characters with underscore
StringBuilder builder = new(input.Length);
foreach (char c in input)
{
if (char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.')
{
builder.Append(c);
}
else
{
builder.Append('_');
}
}
return builder.ToString();
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hosting.AzureStorage.Blob;
/// <summary>
/// Configuration options for <see cref="AzureBlobAgentThreadStore"/>.
/// </summary>
public sealed class AzureBlobAgentThreadStoreOptions
{
/// <summary>
/// Gets or sets a value indicating whether to automatically create the container if it doesn't exist.
/// </summary>
/// <remarks>
/// Defaults to <see langword="true"/>.
/// </remarks>
public bool CreateContainerIfNotExists { get; set; } = true;
/// <summary>
/// Gets or sets the blob name prefix to use for organizing threads.
/// </summary>
/// <remarks>
/// This can be used to namespace threads within a container.
/// For example, setting this to "prod/" will store all blobs under a "prod/" prefix.
/// </remarks>
public string? BlobNamePrefix { get; set; }
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI.Hosting.AzureStorage.Blob;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides extension methods for configuring <see cref="AIAgent"/>.
/// </summary>
public static partial class HostedAgentBuilderExtensions
{
/// <summary>
/// Configures the host agent builder to use an Azure Blob thread store for agent thread management.
/// </summary>
/// <param name="builder">The host agent builder to configure with the Azure blob thread store.</param>
/// <param name="containerClient">The blob container client to use for storage operations.</param>
/// <param name="options">Optional configuration options for the blob thread store.</param>
/// <returns>The same <paramref name="builder"/> instance, configured to use Azure blob thread store.</returns>
public static IHostedAgentBuilder WithAzureBlobThreadStore(this IHostedAgentBuilder builder, BlobContainerClient containerClient, AzureBlobAgentThreadStoreOptions? options = null)
=> WithAzureBlobThreadStore(builder, sp => containerClient, options);
/// <summary>
/// Configures the agent builder to use Azure Blob Storage as the thread store for agent state persistence.
/// </summary>
/// <param name="builder">The agent builder to configure with Azure Blob thread store support.</param>
/// <param name="createBlobContainer">A factory function that provides a configured BlobContainerClient instance for accessing the Azure Blob
/// container used to store thread data.</param>
/// <param name="options">Optional settings for customizing the Azure Blob thread store behavior. If null, default options are used.</param>
/// <returns>The same agent builder instance, configured to use Azure Blob Storage for thread persistence.</returns>
public static IHostedAgentBuilder WithAzureBlobThreadStore(
this IHostedAgentBuilder builder,
Func<IServiceProvider, BlobContainerClient> createBlobContainer,
AzureBlobAgentThreadStoreOptions? options = null)
=> builder.WithThreadStore((sp, key) =>
{
var blobContainer = createBlobContainer(sp);
return new AzureBlobAgentThreadStore(blobContainer, options);
});
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<InjectSharedThrow>true</InjectSharedThrow>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework AzureStorage</Title>
<Description>Provides AzureStorage integration with Microsoft Agent Framework.</Description>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI.Hosting.AzureStorage.Blob;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
/// <summary>
/// Tests for <see cref="AzureBlobAgentThreadStore"/>.
/// </summary>
public sealed class AzureBlobAgentThreadStoreTests(ITestOutputHelper output) : IAsyncLifetime
{
private const string AzuriteConnectionString = "UseDevelopmentStorage=true";
private const string TestContainerName = "agent-threads-test";
#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.
private BlobServiceClient _blobServiceClient;
private BlobContainerClient _containerClient;
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
public async Task InitializeAsync()
{
await AzureStorageEmulatorAvailabilityHelper.SkipIfNotAvailableAsync();
this._blobServiceClient = new BlobServiceClient(AzuriteConnectionString);
this._containerClient = this._blobServiceClient.GetBlobContainerClient(TestContainerName);
// Clean up any existing test container
await this._containerClient.DeleteIfExistsAsync();
}
public async Task DisposeAsync()
{
if (this._containerClient is not null)
{
await this._containerClient.DeleteIfExistsAsync();
}
}
[SkippableFact]
public async Task AIHostAgent_SavesAndRetrievesThread_UsingAzureBlobStoreAsync()
{
AzureBlobAgentThreadStore threadStore = new(this._containerClient!);
var testRunner = TestRunner.Initialize(output, threadStore);
var blobName = GetBlobContainerName(threadStore, testRunner);
var runResult = await testRunner.RunAgentAsync("hello agent");
Assert.Single(runResult.ResponseMessages);
Assert.Equal(2, runResult.ThreadMessages.Count);
await this.AssertBlobHasTextAsync(blobName, runResult.ThreadMessages);
var runResult2 = await testRunner.RunAgentAsync("hello again");
Assert.Single(runResult2.ResponseMessages);
Assert.Equal(4, runResult2.ThreadMessages.Count);
await this.AssertBlobHasTextAsync(blobName, runResult2.ThreadMessages);
}
private Task AssertBlobHasTextAsync(string blobName, IList<ChatMessage> chatMessages)
{
var texts = chatMessages.SelectMany(x => x.Contents).OfType<TextContent>().Select(x => x.Text).ToArray();
return this.AssertBlobHasTextAsync(blobName, texts);
}
private async Task AssertBlobHasTextAsync(string blobName, params string[] expectedTexts)
{
var blobClient = this._containerClient.GetBlobClient(blobName);
var exists = await blobClient.ExistsAsync();
Assert.True(exists, $"Blob '{blobName}' should exist.");
var downloadResponse = await blobClient.DownloadContentAsync();
var blobJson = downloadResponse.Value.Content.ToString();
output.WriteLine($"Actual blob json: {blobJson}");
foreach (var text in expectedTexts)
{
Assert.Contains(text, blobJson);
}
}
private static string GetBlobContainerName(AzureBlobAgentThreadStore threadStore, TestRunner testRunner)
=> threadStore.GetBlobName(testRunner.HostAgent.Id, testRunner.ConversationId);
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Storage.Blobs;
using Skip = Xunit.Skip;
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
/// <summary>
/// Helper class to check if Azurite (Azure Storage Emulator) is available and running.
/// </summary>
internal static class AzureStorageEmulatorAvailabilityHelper
{
private const string AzuriteConnectionString = "UseDevelopmentStorage=true";
/// <summary>
/// Checks if Azurite is running and accessible.
/// </summary>
/// <returns><see langword="true"/> if Azurite is available; otherwise, <see langword="false"/>.</returns>
public static async Task<bool> IsAvailableAsync(CancellationToken cancellationToken)
{
try
{
BlobServiceClient serviceClient = new(AzuriteConnectionString);
// Try to get service properties to verify connection
await serviceClient.GetPropertiesAsync(cancellationToken);
return true;
}
catch (RequestFailedException)
{
// Azurite is not running or not accessible
return false;
}
catch (Exception)
{
// Any other exception means Azurite is not available
return false;
}
}
public static async Task SkipIfNotAvailableAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
bool isAvailable = await IsAvailableAsync(cts.Token);
Skip.IfNot(isAvailable, "Azurite / Azure Storage Emulator is not running. Start Azurite to run these tests.");
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Xunit.SkippableFact" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.Mock;
internal sealed class MockChatClient : IChatClient
{
private int _responsesCounter = 1;
public ChatClientMetadata? Metadata { get; }
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
await Task.Yield();
ChatMessage responseMessage = new(ChatRole.Assistant, $"Response #{this._responsesCounter++}");
return new ChatResponse([responseMessage]);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var responseText = $"Response #{this._responsesCounter++}";
yield return new ChatResponseUpdate(ChatRole.Assistant, responseText);
}
public object? GetService(Type serviceType, object? serviceKey = null)
{
return null;
}
public TService? GetService<TService>(object? serviceKey = null)
{
return default;
}
public void Dispose()
{
}
}
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.Mock;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
using ThreadStore = Microsoft.Agents.AI.Hosting.AgentThreadStore;
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
internal sealed class TestRunner
{
private int _requestCounter = 1;
private readonly ITestOutputHelper _testOutputHelper;
public AIHostAgent HostAgent { get; }
public string ConversationId { get; }
private TestRunner(ITestOutputHelper testOutputHelper, AIHostAgent hostAgent, string conversationId)
{
this._testOutputHelper = testOutputHelper;
this.HostAgent = hostAgent;
this.ConversationId = conversationId;
}
public static TestRunner Initialize(
ITestOutputHelper testOutputHelper,
ThreadStore threadStore,
IChatClient? chatClient = null)
{
chatClient ??= new MockChatClient();
var chatClientAgent = new ChatClientAgent(chatClient);
var hostAgent = new AIHostAgent(chatClientAgent, threadStore);
var conversationId = NewConversationId();
return new(testOutputHelper, hostAgent, conversationId);
}
public Task<HostAgentRunResult> RunAgentAsync(string userMessage)
=> this.RunAgentAsync(new ChatMessage(ChatRole.User, userMessage));
public async Task<HostAgentRunResult> RunAgentAsync(ChatMessage userMessage)
{
if (userMessage.Contents.FirstOrDefault() is TextContent text)
{
text.Text = $"Request #{this._requestCounter++}: {text.Text}";
}
AgentThread thread = await this.HostAgent.GetOrCreateThreadAsync(this.ConversationId);
var response = await this.HostAgent.RunAsync(thread: thread, messages: [userMessage]);
await this.HostAgent.SaveThreadAsync(this.ConversationId, thread);
this._testOutputHelper.WriteLine($"Saved thread {this.ConversationId}");
var chatClientAgentThread = thread as ChatClientAgentThread;
Assert.NotNull(chatClientAgentThread);
Assert.NotNull(chatClientAgentThread.MessageStore);
var threadMessages = (await chatClientAgentThread.MessageStore.GetMessagesAsync()).ToList();
return new()
{
Response = response,
Thread = chatClientAgentThread,
ThreadMessages = threadMessages
};
}
private static string NewConversationId() => Guid.NewGuid().ToString();
}
internal struct HostAgentRunResult
{
public AgentRunResponse Response { get; init; }
public IList<ChatMessage> ResponseMessages => this.Response.Messages;
public ChatClientAgentThread Thread { get; init; }
public IList<ChatMessage> ThreadMessages { get; init; }
}