diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index a643a6cc89..12a8ba8019 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -145,5 +145,6 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
\ No newline at end of file
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index de9ab13e9b..f76dd944c8 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -279,6 +279,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs
index 2e14042dac..744f96616a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentThreadStore.cs
@@ -56,10 +56,8 @@ internal sealed class AzureBlobAgentThreadStore : AgentThreadStore
var blobClient = this._containerClient.GetBlobClient(blobName);
JsonElement serializedThread = thread.Serialize();
-#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task
- await using Stream stream = await blobClient.OpenWriteAsync(overwrite: true, s_uploadJsonOptions, cancellationToken).ConfigureAwait(false);
- await using Utf8JsonWriter writer = new(stream);
-#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task
+ 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);
@@ -118,7 +116,8 @@ internal sealed class AzureBlobAgentThreadStore : AgentThreadStore
///
/// Generates the blob name for a given agent and conversation.
///
- private string GetBlobName(string agentId, string conversationId)
+ // internal for testing
+ internal string GetBlobName(string agentId, string conversationId)
{
string sanitizedAgentId = this.SanitizeBlobNameSegment(agentId);
string sanitizedConversationId = this.SanitizeBlobNameSegment(conversationId);
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs
index 463bc47420..0f9ca9b3bd 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/HostedAgentBuilderExtensions.Blob.cs
@@ -3,7 +3,6 @@
using System;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI.Hosting.AzureStorage.Blob;
-using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting;
@@ -12,20 +11,6 @@ namespace Microsoft.Agents.AI.Hosting;
///
public static partial class HostedAgentBuilderExtensions
{
- ///
- /// Configures the host agent builder to use an Azure Blob thread store with dependency injection.
- /// Resolves from the service provider.
- ///
- /// The host agent builder to configure with the Azure blob thread store.
- /// Optional configuration options for the blob thread store.
- /// The same instance, configured to use Azure blob thread store.
- ///
- /// This overload requires a to be registered in the service collection.
- /// Use Azure.Extensions.AspNetCore.Configuration.Secrets or similar to register the client.
- ///
- public static IHostedAgentBuilder WithAzureBlobThreadStore(this IHostedAgentBuilder builder, AzureBlobAgentThreadStoreOptions? options = null)
- => WithAzureBlobThreadStore(builder, sp => sp.GetRequiredKeyedService(builder.Name), options);
-
///
/// Configures the host agent builder to use an Azure Blob thread store for agent thread management.
///
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
index d6a1548212..9ec1800dea 100644
--- 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
@@ -1,8 +1,8 @@
- $(ProjectsTargetFrameworks)
- net9.0
+ $(ProjectsCoreTargetFrameworks)
+ $(ProjectsDebugCoreTargetFrameworks)
preview
true
@@ -20,4 +20,8 @@
+
+
+
+
diff --git a/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/AzureBlobAgentThreadStoreTests.cs b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/AzureBlobAgentThreadStoreTests.cs
new file mode 100644
index 0000000000..8b589657ac
--- /dev/null
+++ b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/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 Hosting.AgentThreadStore.IntegrationTests;
+
+///
+/// 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/Hosting.AgentThreadStore.IntegrationTests/AzureStorageEmulatorAvailabilityHelper.cs b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/AzureStorageEmulatorAvailabilityHelper.cs
new file mode 100644
index 0000000000..e6eba54a87
--- /dev/null
+++ b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/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 Hosting.AgentThreadStore.IntegrationTests;
+
+///
+/// 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()
+ {
+ 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/Hosting.AgentThreadStore.IntegrationTests/Hosting.AgentThreadStore.IntegrationTests.csproj b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/Hosting.AgentThreadStore.IntegrationTests.csproj
new file mode 100644
index 0000000000..6b76739854
--- /dev/null
+++ b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/Hosting.AgentThreadStore.IntegrationTests.csproj
@@ -0,0 +1,17 @@
+
+
+
+ $(ProjectsCoreTargetFrameworks)
+ $(ProjectsDebugCoreTargetFrameworks)
+ True
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/Mock/MockChatClient.cs b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/Mock/MockChatClient.cs
new file mode 100644
index 0000000000..3c25eef6ee
--- /dev/null
+++ b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/Mock/MockChatClient.cs
@@ -0,0 +1,64 @@
+// 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 Hosting.AgentThreadStore.IntegrationTests.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();
+
+ List messageList = messages.ToList();
+ ChatMessage lastUserMessage = messageList.LastOrDefault(m => m.Role == ChatRole.User)
+ ?? new ChatMessage(ChatRole.User, "No user message");
+
+ 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();
+
+ List messageList = messages.ToList();
+ ChatMessage lastUserMessage = messageList.LastOrDefault(m => m.Role == ChatRole.User)
+ ?? new ChatMessage(ChatRole.User, "No user message");
+
+ string responseText = $"Mock response to: {lastUserMessage.Text}";
+
+ 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/Hosting.AgentThreadStore.IntegrationTests/TestRunner.cs b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/TestRunner.cs
new file mode 100644
index 0000000000..851e647f33
--- /dev/null
+++ b/dotnet/tests/Hosting.AgentThreadStore.IntegrationTests/TestRunner.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Hosting.AgentThreadStore.IntegrationTests.Mock;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting;
+using Microsoft.Extensions.AI;
+using Microsoft.VisualStudio.TestPlatform.Utilities;
+using Xunit.Abstractions;
+using Xunit.Sdk;
+using ThreadStore = Microsoft.Agents.AI.Hosting.AgentThreadStore;
+
+namespace Hosting.AgentThreadStore.IntegrationTests;
+
+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; }
+}