diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml
index 1a51f94119..b7a8ab453a 100644
--- a/.github/workflows/dotnet-build-and-test.yml
+++ b/.github/workflows/dotnet-build-and-test.yml
@@ -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!')
\ No newline at end of file
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 69d3e03d31..c19b0b152d 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -21,6 +21,7 @@
+
@@ -105,6 +106,7 @@
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index de8aef42fc..964030a36b 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -133,7 +133,7 @@
-
+
@@ -275,6 +275,7 @@
+
@@ -298,8 +299,9 @@
-
+
+
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj
index 802c864c1f..7347a642ae 100644
--- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj
@@ -8,6 +8,7 @@
+
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
index 571b07b1d5..81430070eb 100644
--- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
@@ -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 @@
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("chat-model");
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs
new file mode 100644
index 0000000000..744f96616a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs
@@ -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;
+
+ ///
+ /// Initializes a new instance of the class using a .
+ ///
+ /// The blob container client to use for storage operations.
+ /// Optional configuration options. If , default options will be used.
+ /// is .
+ public AzureBlobAgentThreadStore(BlobContainerClient containerClient, AzureBlobAgentThreadStoreOptions? options = null)
+ {
+ this._containerClient = containerClient ?? throw new ArgumentNullException(nameof(containerClient));
+ this._options = options ?? new AzureBlobAgentThreadStoreOptions();
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ public override async ValueTask 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();
+ }
+ }
+
+ ///
+ /// Ensures that the blob container exists, creating it if necessary.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Generates the blob name for a given agent and conversation.
+ ///
+ // 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}";
+ }
+
+ ///
+ /// Sanitizes a string to be safe for use in blob names.
+ ///
+ 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();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStoreOptions.cs
new file mode 100644
index 0000000000..d7b5cf8dd4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStoreOptions.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Hosting.AzureStorage.Blob;
+
+///
+/// Configuration options for .
+///
+public sealed class AzureBlobAgentThreadStoreOptions
+{
+ ///
+ /// Gets or sets a value indicating whether to automatically create the container if it doesn't exist.
+ ///
+ ///
+ /// Defaults to .
+ ///
+ public bool CreateContainerIfNotExists { get; set; } = true;
+
+ ///
+ /// Gets or sets the blob name prefix to use for organizing threads.
+ ///
+ ///
+ /// This can be used to namespace threads within a container.
+ /// For example, setting this to "prod/" will store all blobs under a "prod/" prefix.
+ ///
+ public string? BlobNamePrefix { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs
new file mode 100644
index 0000000000..0f9ca9b3bd
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs
@@ -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;
+
+///
+/// Provides extension methods for configuring .
+///
+public static partial class HostedAgentBuilderExtensions
+{
+ ///
+ /// Configures the host agent builder to use an Azure Blob thread store for agent thread management.
+ ///
+ /// The host agent builder to configure with the Azure blob thread store.
+ /// The blob container client to use for storage operations.
+ /// Optional configuration options for the blob thread store.
+ /// The same instance, configured to use Azure blob thread store.
+ public static IHostedAgentBuilder WithAzureBlobThreadStore(this IHostedAgentBuilder builder, BlobContainerClient containerClient, AzureBlobAgentThreadStoreOptions? options = null)
+ => WithAzureBlobThreadStore(builder, sp => containerClient, options);
+
+ ///
+ /// Configures the agent builder to use Azure Blob Storage as the thread store for agent state persistence.
+ ///
+ /// The agent builder to configure with Azure Blob thread store support.
+ /// A factory function that provides a configured BlobContainerClient instance for accessing the Azure Blob
+ /// container used to store thread data.
+ /// Optional settings for customizing the Azure Blob thread store behavior. If null, default options are used.
+ /// The same agent builder instance, configured to use Azure Blob Storage for thread persistence.
+ public static IHostedAgentBuilder WithAzureBlobThreadStore(
+ this IHostedAgentBuilder builder,
+ Func createBlobContainer,
+ AzureBlobAgentThreadStoreOptions? options = null)
+ => builder.WithThreadStore((sp, key) =>
+ {
+ var blobContainer = createBlobContainer(sp);
+ return new AzureBlobAgentThreadStore(blobContainer, options);
+ });
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj
new file mode 100644
index 0000000000..7944a6fb13
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj
@@ -0,0 +1,27 @@
+
+
+
+ $(ProjectsCoreTargetFrameworks)
+ $(ProjectsDebugCoreTargetFrameworks)
+ preview
+ true
+
+
+ Microsoft Agent Framework AzureStorage
+ Provides AzureStorage integration with Microsoft Agent Framework.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentThreadStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentThreadStoreTests.cs
new file mode 100644
index 0000000000..cda485e6dc
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentThreadStoreTests.cs
@@ -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;
+
+///
+/// Tests for .
+///
+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 chatMessages)
+ {
+ var texts = chatMessages.SelectMany(x => x.Contents).OfType().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);
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureStorageEmulatorAvailabilityHelper.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureStorageEmulatorAvailabilityHelper.cs
new file mode 100644
index 0000000000..4ebc607cc9
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureStorageEmulatorAvailabilityHelper.cs
@@ -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;
+
+///
+/// Helper class to check if Azurite (Azure Storage Emulator) is available and running.
+///
+internal static class AzureStorageEmulatorAvailabilityHelper
+{
+ private const string AzuriteConnectionString = "UseDevelopmentStorage=true";
+
+ ///
+ /// Checks if Azurite is running and accessible.
+ ///
+ /// if Azurite is available; otherwise, .
+ public static async Task 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.");
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj
new file mode 100644
index 0000000000..6b76739854
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests.csproj
@@ -0,0 +1,17 @@
+
+
+
+ $(ProjectsCoreTargetFrameworks)
+ $(ProjectsDebugCoreTargetFrameworks)
+ True
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Mock/MockChatClient.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Mock/MockChatClient.cs
new file mode 100644
index 0000000000..b99e0f5011
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/Mock/MockChatClient.cs
@@ -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 GetResponseAsync(
+ IEnumerable 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 GetStreamingResponseAsync(
+ IEnumerable 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(object? serviceKey = null)
+ {
+ return default;
+ }
+
+ public void Dispose()
+ {
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/TestRunner.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/TestRunner.cs
new file mode 100644
index 0000000000..8e13c20015
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/TestRunner.cs
@@ -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 RunAgentAsync(string userMessage)
+ => this.RunAgentAsync(new ChatMessage(ChatRole.User, userMessage));
+
+ public async Task 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 ResponseMessages => this.Response.Messages;
+
+ public ChatClientAgentThread Thread { get; init; }
+ public IList ThreadMessages { get; init; }
+}