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; } +}