.NET: Add Azure Blob Storage session persistence (#1893)

* setup azurestorage proj

* setup for azure blob as agentthreadstore

* add azurite as dependency for dotnet tests

* use services

* rollback

* azurite as a step

* move and rename

* renames / fixes

* rename to unit tests

* copilot changes

* .NET: Modernize Azure Blob session storage

Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5

* .NET: Test hosted Blob session persistence

Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5

* .NET: Address Azure Blob storage review feedback

Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5

* .NET: Use default test target frameworks

Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5

---------

Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Copilot-Session: 35e63850-1a85-4f7c-ac80-2274534c13b5
This commit is contained in:
Korolev Dmitry
2026-08-20 18:30:35 +00:00
committed by GitHub
parent cabb21a292
commit e6617c407a
18 changed files with 1137 additions and 4 deletions
+1 -1
View File
@@ -113,6 +113,7 @@
/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hyperlight/ @chetantoshniwal @westey-m @SergeyMenshykh
/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ @chetantoshniwal @westey-m @SergeyMenshykh
@@ -127,4 +128,3 @@
/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/ @chetantoshniwal @peibekwe @rogerbarreto
/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/ @chetantoshniwal @peibekwe @rogerbarreto
/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ @chetantoshniwal @peibekwe @rogerbarreto
@@ -37,6 +37,7 @@ jobs:
outputs:
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
azureStorageChanges: ${{ steps.filter.outputs.azurestorage }}
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
coreChanged: ${{ steps.filter.outputs.core }}
steps:
@@ -49,6 +50,12 @@ jobs:
- 'dotnet/**'
cosmosdb:
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
azurestorage:
- 'dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/**'
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests/**'
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/**'
- 'dotnet/Directory.Packages.props'
- '.github/workflows/dotnet-build-and-test.yml'
# The Foundry hosted-agent IT is costly (builds a container, pushes to ACR,
# provisions live agents). Only run it when the project under test, its
# dependency chain, the test container, the test fixture, or their tooling
@@ -120,6 +127,7 @@ jobs:
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build dotnet solutions
shell: bash
run: |
@@ -200,6 +208,27 @@ jobs:
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Start Azurite Blob service
if: ${{ runner.os == 'Linux' && (needs.paths-filter.outputs.azureStorageChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
shell: bash
run: |
docker run --detach --rm \
--name azurite \
--publish 10000:10000 \
mcr.microsoft.com/azure-storage/azurite:3.35.0@sha256:647c63a91102a9d8e8000aab803436e1fc85fbb285e7ce830a82ee5d6661cf37 \
azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --skipApiVersionCheck
for attempt in {1..30}; do
if (echo > /dev/tcp/127.0.0.1/10000) > /dev/null 2>&1; then
echo "AZURITE_AVAILABLE=true" >> "$GITHUB_ENV"
exit 0
fi
sleep 1
done
docker logs azurite
exit 1
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
+1
View File
@@ -32,6 +32,7 @@
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.60.0" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.29.1" />
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
<!-- Google Gemini -->
+3 -1
View File
@@ -605,6 +605,7 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.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.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
@@ -636,6 +637,7 @@
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests.csproj" />
@@ -660,6 +662,7 @@
<Project Path="tests/Microsoft.Agents.AI.Harness.UnitTests/Microsoft.Agents.AI.Harness.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.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.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
@@ -677,4 +680,3 @@
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
</Folder>
</Solution>
+1
View File
@@ -20,6 +20,7 @@
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.AzureStorage\\Microsoft.Agents.AI.Hosting.AzureStorage.csproj",
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
"src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj",
@@ -12,6 +12,7 @@
<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" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
@@ -3,6 +3,7 @@
using AgentWebChat.AgentHost;
using AgentWebChat.AgentHost.Custom;
using AgentWebChat.AgentHost.Utilities;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Hosting;
@@ -43,8 +44,26 @@ var pirateAgentBuilder = builder.AddAIAgent(
description: "An agent that speaks like a pirate.",
chatClientServiceKey: "chat-model")
.WithAITool(new CustomAITool())
.WithAITool(new CustomFunctionTool())
.WithInMemorySessionStore();
.WithAITool(new CustomFunctionTool());
// Set both environment variables to replace development-only in-memory storage with Azure Blob Storage.
string? blobConnectionString = Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_CONNECTION_STRING");
if (string.IsNullOrWhiteSpace(blobConnectionString))
{
pirateAgentBuilder.WithInMemorySessionStore();
}
else
{
string? blobContainerName = Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_CONTAINER_NAME");
if (string.IsNullOrWhiteSpace(blobContainerName))
{
throw new InvalidOperationException(
"AZURE_STORAGE_BLOB_CONTAINER_NAME must be set when AZURE_STORAGE_BLOB_CONNECTION_STRING is configured.");
}
BlobContainerClient containerClient = new(blobConnectionString, blobContainerName);
pirateAgentBuilder.WithAzureBlobSessionStore(containerClient);
}
var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) =>
{
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI.Hosting.AzureStorage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides hosted agent registration extensions for Azure Blob Storage session persistence.
/// </summary>
public static class AzureBlobHostedAgentBuilderExtensions
{
/// <summary>
/// Configures a hosted agent to persist sessions in an Azure Blob Storage container.
/// </summary>
/// <param name="builder">The hosted agent builder to configure.</param>
/// <param name="containerClient">The Blob container client used to store sessions.</param>
/// <param name="options">Optional session store configuration.</param>
/// <param name="withIsolation">
/// Whether to scope session IDs with the configured <see cref="AgentIsolationKeyProvider"/>.
/// </param>
/// <returns>The supplied <paramref name="builder"/>.</returns>
public static IHostedAgentBuilder WithAzureBlobSessionStore(
this IHostedAgentBuilder builder,
BlobContainerClient containerClient,
AzureBlobAgentSessionStoreOptions? options = null,
bool withIsolation = true)
{
Throw.IfNull(builder);
Throw.IfNull(containerClient);
return builder.WithAzureBlobSessionStore(
(_, _) => containerClient,
options,
ServiceLifetime.Singleton,
withIsolation);
}
/// <summary>
/// Configures a hosted agent to persist sessions in an Azure Blob Storage container supplied by a factory.
/// </summary>
/// <param name="builder">The hosted agent builder to configure.</param>
/// <param name="createBlobContainerClient">
/// A factory that receives the service provider and stable hosted agent registration name.
/// </param>
/// <param name="options">Optional session store configuration.</param>
/// <param name="lifetime">The dependency injection lifetime of the registered session store.</param>
/// <param name="withIsolation">
/// Whether to scope session IDs with the configured <see cref="AgentIsolationKeyProvider"/>.
/// </param>
/// <returns>The supplied <paramref name="builder"/>.</returns>
public static IHostedAgentBuilder WithAzureBlobSessionStore(
this IHostedAgentBuilder builder,
Func<IServiceProvider, string, BlobContainerClient> createBlobContainerClient,
AzureBlobAgentSessionStoreOptions? options = null,
ServiceLifetime lifetime = ServiceLifetime.Singleton,
bool withIsolation = true)
{
Throw.IfNull(builder);
Throw.IfNull(createBlobContainerClient);
return builder.WithSessionStore(
(serviceProvider, agentName) =>
{
BlobContainerClient containerClient =
Throw.IfNull(createBlobContainerClient(serviceProvider, agentName));
return new AzureBlobAgentSessionStore(containerClient, agentName, options);
},
lifetime,
withIsolation);
}
}
@@ -0,0 +1,237 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Security.Cryptography;
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;
/// <summary>
/// Provides an Azure Blob Storage implementation of <see cref="AgentSessionStore"/>.
/// </summary>
/// <remarks>
/// <para>
/// The <c>agentNamespace</c> supplied to the constructor forms a stable storage partition and must remain the same
/// across application restarts. Hosted agent registration extensions supply the hosted agent registration name.
/// </para>
/// <para>
/// Serialized sessions can contain conversation content and personally identifiable information. Configure the
/// container with appropriate access controls, encryption, retention, and deletion policies. Hosts serving multiple
/// users should register this store through <c>WithAzureBlobSessionStore</c>, which enables isolation-key scoping by
/// default.
/// </para>
/// </remarks>
public sealed class AzureBlobAgentSessionStore : AgentSessionStore
{
private const int MaxBlobNameLength = 1024;
private const int BaseBlobNameLength = 137;
private static readonly BlobUploadOptions s_uploadOptions = new()
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" },
};
private readonly BlobContainerClient _containerClient;
private readonly object _containerInitializationLock = new();
private readonly string _agentKey;
private readonly string? _blobNamePrefix;
private readonly bool _createContainerIfNotExists;
private Task? _containerInitializationTask;
/// <summary>
/// Initializes a new instance of the <see cref="AzureBlobAgentSessionStore"/> class.
/// </summary>
/// <param name="containerClient">The blob container client to use for storage operations.</param>
/// <param name="agentNamespace">A stable name that identifies the agent across application restarts.</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>
/// <exception cref="ArgumentException"><paramref name="agentNamespace"/> is empty or consists only of whitespace.</exception>
/// <exception cref="ArgumentException"><paramref name="options"/> specifies a Blob name prefix that exceeds the Azure Blob name limit.</exception>
public AzureBlobAgentSessionStore(
BlobContainerClient containerClient,
string agentNamespace,
AzureBlobAgentSessionStoreOptions? options = null)
{
this._containerClient = Throw.IfNull(containerClient);
this._agentKey = ComputeKey(Throw.IfNullOrWhitespace(agentNamespace));
options ??= new AzureBlobAgentSessionStoreOptions();
this._createContainerIfNotExists = options.CreateContainerIfNotExists;
this._blobNamePrefix = NormalizePrefix(options.BlobNamePrefix);
if (this._blobNamePrefix is { Length: > MaxBlobNameLength - BaseBlobNameLength - 1 })
{
throw new ArgumentException(
$"The Blob name prefix must not exceed {MaxBlobNameLength - BaseBlobNameLength - 1} characters.",
nameof(options));
}
}
/// <inheritdoc />
public override async ValueTask SaveSessionAsync(
AIAgent agent,
string sessionStoreId,
AgentSession session,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(sessionStoreId);
Throw.IfNull(session);
await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false);
JsonElement serializedSession = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId));
await blobClient.UploadAsync(
BinaryData.FromString(serializedSession.GetRawText()),
s_uploadOptions,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public override async ValueTask<AgentSession> GetSessionAsync(
AIAgent agent,
string sessionStoreId,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(sessionStoreId);
await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false);
BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId));
try
{
Response<BlobDownloadResult> response = await blobClient.DownloadContentAsync(cancellationToken).ConfigureAwait(false);
using JsonDocument document = JsonDocument.Parse(response.Value.Content);
return await agent.DeserializeSessionAsync(document.RootElement, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound.ToString())
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public override async ValueTask DeleteSessionAsync(
AIAgent agent,
string sessionStoreId,
CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(sessionStoreId);
BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId));
try
{
await blobClient.DeleteIfExistsAsync(
DeleteSnapshotsOption.IncludeSnapshots,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.ContainerNotFound.ToString())
{
// A missing container cannot contain the requested session, so deletion remains idempotent.
}
}
private async Task EnsureContainerExistsAsync(CancellationToken cancellationToken)
{
if (!this._createContainerIfNotExists)
{
return;
}
Task initializationTask;
lock (this._containerInitializationLock)
{
initializationTask = this._containerInitializationTask ??= this.CreateContainerIfNotExistsAsync();
}
try
{
await WaitWithCancellationAsync(initializationTask, cancellationToken).ConfigureAwait(false);
}
catch when (initializationTask.IsFaulted || initializationTask.IsCanceled)
{
lock (this._containerInitializationLock)
{
if (ReferenceEquals(this._containerInitializationTask, initializationTask))
{
this._containerInitializationTask = null;
}
}
throw;
}
}
private async Task CreateContainerIfNotExistsAsync()
=> await this._containerClient.CreateIfNotExistsAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
private string GetBlobName(string sessionStoreId)
{
string sessionKey = ComputeKey(sessionStoreId);
string baseName = $"v1/{this._agentKey}/{sessionKey}.json";
return this._blobNamePrefix is null
? baseName
: $"{this._blobNamePrefix}/{baseName}";
}
private static async Task WaitWithCancellationAsync(Task task, CancellationToken cancellationToken)
{
if (task.IsCompleted || !cancellationToken.CanBeCanceled)
{
await task.ConfigureAwait(false);
return;
}
var cancellationTaskSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using (cancellationToken.Register(() => cancellationTaskSource.TrySetCanceled()))
{
Task completedTask = await Task.WhenAny(task, cancellationTaskSource.Task).ConfigureAwait(false);
await completedTask.ConfigureAwait(false);
}
}
private static string ComputeKey(string value)
{
byte[] input = Encoding.UTF8.GetBytes(value);
#if NET8_0_OR_GREATER
return Convert.ToHexString(SHA256.HashData(input));
#else
using SHA256 sha256 = SHA256.Create();
byte[] hash = sha256.ComputeHash(input);
char[] result = new char[hash.Length * 2];
for (int index = 0; index < hash.Length; index++)
{
byte valueByte = hash[index];
result[index * 2] = ToHexChar(valueByte >> 4);
result[(index * 2) + 1] = ToHexChar(valueByte & 0x0F);
}
return new string(result);
#endif
}
#if !NET8_0_OR_GREATER
private static char ToHexChar(int value)
=> (char)(value < 10 ? '0' + value : 'A' + value - 10);
#endif
private static string? NormalizePrefix(string? prefix)
{
string? normalized = prefix?.Trim('/');
return string.IsNullOrEmpty(normalized) ? null : normalized;
}
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hosting.AzureStorage;
/// <summary>
/// Configuration options for <see cref="AzureBlobAgentSessionStore"/>.
/// </summary>
public sealed class AzureBlobAgentSessionStoreOptions
{
/// <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"/>.
/// Set this to <see langword="false"/> when the supplied identity has data access but cannot create containers.
/// </remarks>
public bool CreateContainerIfNotExists { get; set; } = true;
/// <summary>
/// Gets or sets the blob name prefix to use for organizing sessions.
/// </summary>
/// <remarks>
/// This can be used to namespace sessions within a container.
/// For example, setting this to "prod/" will store all blobs under a "prod/" prefix.
/// The normalized prefix cannot exceed 886 characters.
/// </remarks>
public string? BlobNamePrefix { get; set; }
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<InjectSharedThrow>true</InjectSharedThrow>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Azure Blob Storage integration</Title>
<Description>Provides Azure Blob Storage session persistence for Microsoft Agent Framework hosting.</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>
</Project>
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
#if NET8_0_OR_GREATER
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Agents.AI.Hosting.AzureStorage.Tests;
using Shared.IntegrationTests;
#endif
namespace Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests;
public sealed class AzureBlobAgentSessionStoreIntegrationTests
{
#if NET8_0_OR_GREATER
[Fact(Skip = "Requires a provisioned Azure Storage account and data-plane permissions in CI.")]
public async Task HostedAgentThroughTestServer_PersistsSessionInLiveBlobStorageAsync()
{
// Arrange
string? endpoint = Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_ENDPOINT");
Assert.SkipWhen(string.IsNullOrWhiteSpace(endpoint), "AZURE_STORAGE_BLOB_ENDPOINT is not configured.");
BlobServiceClient serviceClient = new(
new Uri(endpoint),
TestAzureCliCredentials.CreateAzureCliCredential());
BlobContainerClient containerClient =
serviceClient.GetBlobContainerClient($"af-session-it-{Guid.NewGuid():N}");
try
{
// Act
await using FakeTestAgentHost host =
await FakeTestAgentHost.StartAsync(containerClient);
FakeTestAgentHost.FakeTestAgentRunResult result = await host.RunTwoTurnsAsync();
List<BlobItem> blobs = [];
await foreach (BlobItem blob in containerClient.GetBlobsAsync())
{
blobs.Add(blob);
}
BlobItem storedBlob = Assert.Single(blobs);
BlobClient storedBlobClient = containerClient.GetBlobClient(storedBlob.Name);
Response<BlobDownloadResult> download = await storedBlobClient.DownloadContentAsync();
string persistedSession = download.Value.Content.ToString();
// Assert
Assert.Contains("Turn 1", result.FirstResponse, StringComparison.Ordinal);
Assert.Contains("Turn 2", result.SecondResponse, StringComparison.Ordinal);
Assert.EndsWith(".json", storedBlob.Name, StringComparison.Ordinal);
Assert.Equal("application/json", storedBlob.Properties.ContentType);
Assert.Contains("turnCounter", persistedSession, StringComparison.Ordinal);
Assert.Contains("\"count\":2", persistedSession, StringComparison.Ordinal);
Assert.True((await storedBlobClient.ExistsAsync()).Value);
}
finally
{
await containerClient.DeleteIfExistsAsync();
}
}
#endif
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<PackageReference Include="AGUI.Abstractions" />
<PackageReference Include="AGUI.Client" />
<PackageReference Include="AGUI.Server" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Compile Include="..\Microsoft.Agents.AI.Hosting.AzureStorage.TestUtilities\*.cs" LinkBase="TestUtilities" />
</ItemGroup>
</Project>
@@ -0,0 +1,209 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using AGUI.Abstractions;
using AGUI.Client;
using Azure.Storage.Blobs;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.AzureStorage.Tests;
internal sealed class FakeTestAgentHost : IAsyncDisposable
{
private const string AgentName = "azure-blob-session-agent";
private readonly WebApplication _app;
private readonly HttpClient _client;
private FakeTestAgentHost(WebApplication app, HttpClient client)
{
this._app = app;
this._client = client;
}
public static async Task<FakeTestAgentHost> StartAsync(BlobContainerClient containerClient)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddAGUIServer();
builder.Services
.AddAIAgent(AgentName, (_, name) => new SessionCountingAgent(name))
.WithAzureBlobSessionStore(containerClient, withIsolation: false);
WebApplication app = builder.Build();
app.MapAGUIServer(AgentName, "/agent");
await app.StartAsync();
TestServer server = app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer was not registered.");
HttpClient client = server.CreateClient();
client.BaseAddress = new Uri("http://localhost/agent");
return new FakeTestAgentHost(app, client);
}
public async Task<FakeTestAgentRunResult> RunTwoTurnsAsync()
{
var chatClient = new AGUIChatClient(new(this._client, ""));
AIAgent clientAgent = chatClient.AsAIAgent(
instructions: null,
name: "client-agent",
description: "Client for the in-memory test agent host.",
tools: []);
AgentSession clientSession = await clientAgent.CreateSessionAsync();
ChatMessage firstMessage = new(ChatRole.User, "first turn");
List<AgentResponseUpdate> firstUpdates = [];
await foreach (AgentResponseUpdate update in clientAgent.RunStreamingAsync(
[firstMessage],
clientSession,
new AgentRunOptions(),
CancellationToken.None))
{
firstUpdates.Add(update);
}
RunStartedEvent runStarted = firstUpdates
.Select(update => update.AsChatResponseUpdate().RawRepresentation as RunStartedEvent)
.First(evt => evt is not null)!;
ChatMessage secondMessage = new(ChatRole.User, "second turn");
var continuationOptions = new ChatClientAgentRunOptions
{
ChatOptions = new ChatOptions
{
RawRepresentationFactory = _ => new RunAgentInput
{
ThreadId = runStarted.ThreadId,
ParentRunId = runStarted.RunId,
Messages = new[] { secondMessage }.AsAGUIMessages().ToList(),
},
},
};
List<AgentResponseUpdate> secondUpdates = [];
await foreach (AgentResponseUpdate update in clientAgent.RunStreamingAsync(
[secondMessage],
clientSession,
continuationOptions,
CancellationToken.None))
{
secondUpdates.Add(update);
}
return new(
firstUpdates.ToAgentResponse().Text,
secondUpdates.ToAgentResponse().Text);
}
public async ValueTask DisposeAsync()
{
this._client.Dispose();
await this._app.DisposeAsync();
}
internal sealed record FakeTestAgentRunResult(
string FirstResponse,
string SecondResponse);
private sealed class SessionCountingAgent(string name) : AIAgent
{
protected override string? IdCore => name;
public override string? Name => name;
public override string? Description => "A deterministic agent that stores its turn count in the session.";
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new SessionCountingAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions = null,
CancellationToken cancellationToken = default)
=> new(serializedState.Deserialize<SessionCountingAgentSession>(jsonSerializerOptions)!);
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions = null,
CancellationToken cancellationToken = default)
{
if (session is not SessionCountingAgentSession countingSession)
{
throw new InvalidOperationException(
$"The session type '{session.GetType().Name}' is not supported.");
}
return new(JsonSerializer.SerializeToElement(countingSession, jsonSerializerOptions));
}
protected override async Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in this.RunStreamingAsync(
messages,
session,
options,
cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
}
return updates.ToAgentResponse();
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
int turnCount = (session?.StateBag.GetValue<TurnCounter>("turnCounter")?.Count ?? 0) + 1;
session?.StateBag.SetValue("turnCounter", new TurnCounter { Count = turnCount });
yield return new AgentResponseUpdate
{
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Assistant,
Contents = [new TextContent($"Turn {turnCount}: session persisted")],
};
await Task.Yield();
}
private sealed class TurnCounter
{
public int Count { get; set; }
}
private sealed class SessionCountingAgentSession : AgentSession
{
public SessionCountingAgentSession()
{
}
[JsonConstructor]
public SessionCountingAgentSession(AgentSessionStateBag stateBag)
: base(stateBag)
{
}
}
}
}
@@ -0,0 +1,298 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
#if NET8_0_OR_GREATER
using Microsoft.Agents.AI.Hosting.AzureStorage.Tests;
#endif
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
/// <summary>
/// Verifies Azure Blob Storage session persistence.
/// </summary>
public sealed class AzureBlobAgentSessionStoreTests : IAsyncLifetime
{
private static readonly string s_connectionString =
Environment.GetEnvironmentVariable("AZURE_STORAGE_BLOB_CONNECTION_STRING") ?? "UseDevelopmentStorage=true";
private static readonly BlobClientOptions s_clientOptions = new()
{
Retry =
{
MaxRetries = 0,
NetworkTimeout = TimeSpan.FromSeconds(3),
},
};
private static readonly BlobServiceClient s_blobServiceClient = new(s_connectionString, s_clientOptions);
private static readonly Task<bool> s_azuriteAvailability = IsAzuriteAvailableAsync();
private readonly BlobContainerClient _containerClient;
private bool _azuriteAvailable;
/// <summary>
/// Initializes a new instance of the <see cref="AzureBlobAgentSessionStoreTests"/> class.
/// </summary>
public AzureBlobAgentSessionStoreTests()
{
this._containerClient = s_blobServiceClient.GetBlobContainerClient($"agent-sessions-{Guid.NewGuid():N}");
}
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
this._azuriteAvailable = await s_azuriteAvailability;
bool required = string.Equals(
Environment.GetEnvironmentVariable("AZURITE_AVAILABLE"),
bool.TrueString,
StringComparison.OrdinalIgnoreCase);
Assert.SkipWhen(!required && !this._azuriteAvailable, "Azurite is not available.");
Assert.True(this._azuriteAvailable, "Azurite was required but could not be reached.");
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (this._azuriteAvailable)
{
await this._containerClient.DeleteIfExistsAsync();
}
GC.SuppressFinalize(this);
}
[Fact]
public async Task SaveAndGetSessionAsync_PersistsAcrossStoreAndAgentInstancesAsync()
{
// Arrange
AIAgent savingAgent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
AIAgent loadingAgent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
AgentSession session = await savingAgent.CreateSessionAsync();
session.StateBag.SetValue("marker", "saved");
var savingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
var loadingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
// Act
await savingStore.SaveSessionAsync(savingAgent, "session-1", session);
AgentSession restored = await loadingStore.GetSessionAsync(loadingAgent, "session-1");
// Assert
Assert.Equal("saved", restored.StateBag.GetValue<string>("marker"));
}
[Fact]
public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync()
{
// Arrange
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
string commonPrefix = new('s', 2048);
string firstId = commonPrefix + "\0:first";
string secondId = commonPrefix + "\u0001/second";
AgentSession firstSession = await agent.CreateSessionAsync();
firstSession.StateBag.SetValue("marker", "first");
AgentSession secondSession = await agent.CreateSessionAsync();
secondSession.StateBag.SetValue("marker", "second");
// Act
await store.SaveSessionAsync(agent, firstId, firstSession);
await store.SaveSessionAsync(agent, secondId, secondSession);
AgentSession restoredFirst = await store.GetSessionAsync(agent, firstId);
AgentSession restoredSecond = await store.GetSessionAsync(agent, secondId);
List<string> blobNames = [];
await foreach (BlobItem blob in this._containerClient.GetBlobsAsync())
{
blobNames.Add(blob.Name);
}
// Assert
Assert.Equal("first", restoredFirst.StateBag.GetValue<string>("marker"));
Assert.Equal("second", restoredSecond.StateBag.GetValue<string>("marker"));
Assert.Equal(2, blobNames.Count);
Assert.All(blobNames, blobName => Assert.InRange(blobName.Length, 1, 1024));
}
[Fact]
public async Task DeleteSessionAsync_RemovesStoredSessionAndIgnoresMissingSessionAsync()
{
// Arrange
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
AgentSession session = await agent.CreateSessionAsync();
session.StateBag.SetValue("marker", "saved");
await store.SaveSessionAsync(agent, "session-to-delete", session);
// Act
await store.DeleteSessionAsync(agent, "session-to-delete");
AgentSession restored = await store.GetSessionAsync(agent, "session-to-delete");
await store.DeleteSessionAsync(agent, "session-to-delete");
// Assert
Assert.Null(restored.StateBag.GetValue<string>("marker"));
}
[Fact]
public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync()
{
// Arrange
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
AgentSession first = await agent.CreateSessionAsync();
first.StateBag.SetValue("marker", "first");
AgentSession second = await agent.CreateSessionAsync();
second.StateBag.SetValue("marker", "second");
// Act
await store.SaveSessionAsync(agent, "session-1", first);
await store.SaveSessionAsync(agent, "session-1", second);
AgentSession restored = await store.GetSessionAsync(agent, "session-1");
List<BlobItem> blobs = [];
await foreach (BlobItem blob in this._containerClient.GetBlobsAsync())
{
blobs.Add(blob);
}
// Assert
BlobItem storedBlob = Assert.Single(blobs);
Assert.Equal("application/json", storedBlob.Properties.ContentType);
Assert.Equal("second", restored.StateBag.GetValue<string>("marker"));
}
[Fact]
public async Task GetSessionAsync_MissingContainerWithoutAutoCreatePropagatesErrorAsync()
{
// Arrange
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
BlobContainerClient missingContainer = s_blobServiceClient.GetBlobContainerClient($"missing-{Guid.NewGuid():N}");
var store = new AzureBlobAgentSessionStore(
missingContainer,
"assistant",
new AzureBlobAgentSessionStoreOptions { CreateContainerIfNotExists = false });
// Act
RequestFailedException exception = await Assert.ThrowsAsync<RequestFailedException>(
() => store.GetSessionAsync(agent, "session-1").AsTask());
// Assert
Assert.Equal(BlobErrorCode.ContainerNotFound.ToString(), exception.ErrorCode);
}
[Fact]
public async Task GetSessionAsync_ReturnsIndependentSnapshotsAsync()
{
// Arrange
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
AgentSession original = await agent.CreateSessionAsync();
original.StateBag.SetValue("marker", "saved");
await store.SaveSessionAsync(agent, "session-1", original);
// Act
AgentSession first = await store.GetSessionAsync(agent, "session-1");
AgentSession second = await store.GetSessionAsync(agent, "session-1");
first.StateBag.SetValue("marker", "changed");
AgentSession third = await store.GetSessionAsync(agent, "session-1");
// Assert
Assert.NotSame(first, second);
Assert.Equal("saved", second.StateBag.GetValue<string>("marker"));
Assert.Equal("saved", third.StateBag.GetValue<string>("marker"));
}
[Fact]
public async Task SaveSessionAsync_ConcurrentFirstWritesCreateContainerSafelyAsync()
{
// Arrange
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant");
List<Task> writes = [];
for (int index = 0; index < 16; index++)
{
AgentSession session = await agent.CreateSessionAsync();
session.StateBag.SetValue("marker", index.ToString());
writes.Add(store.SaveSessionAsync(agent, $"session-{index}", session).AsTask());
}
// Act
await Task.WhenAll(writes);
List<BlobItem> blobs = [];
await foreach (BlobItem blob in this._containerClient.GetBlobsAsync())
{
blobs.Add(blob);
}
// Assert
Assert.Equal(16, blobs.Count);
}
#if NET8_0_OR_GREATER
[Fact]
public async Task HostedAgentThroughTestServer_PersistsSessionInAzuriteAsync()
{
// Arrange
await using FakeTestAgentHost host =
await FakeTestAgentHost.StartAsync(this._containerClient);
// Act
FakeTestAgentHost.FakeTestAgentRunResult result = await host.RunTwoTurnsAsync();
List<BlobItem> blobs = [];
await foreach (BlobItem blob in this._containerClient.GetBlobsAsync())
{
blobs.Add(blob);
}
BlobItem storedBlob = Assert.Single(blobs);
Response<BlobDownloadResult> download = await this._containerClient
.GetBlobClient(storedBlob.Name)
.DownloadContentAsync();
string persistedSession = download.Value.Content.ToString();
// Assert
Assert.Contains("Turn 1", result.FirstResponse, StringComparison.Ordinal);
Assert.Contains("Turn 2", result.SecondResponse, StringComparison.Ordinal);
Assert.Equal("application/json", storedBlob.Properties.ContentType);
Assert.Contains("turnCounter", persistedSession, StringComparison.Ordinal);
Assert.Contains("\"count\":2", persistedSession, StringComparison.Ordinal);
}
#endif
[Fact]
public void Constructor_BlobNamePrefixExceedsAzureLimit_Throws()
{
// Arrange
var options = new AzureBlobAgentSessionStoreOptions
{
BlobNamePrefix = new string('p', 887),
};
// Act & Assert
Assert.Throws<ArgumentException>(
() => new AzureBlobAgentSessionStore(this._containerClient, "assistant", options));
}
private static async Task<bool> IsAzuriteAvailableAsync()
{
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromSeconds(3));
try
{
await s_blobServiceClient.GetPropertiesAsync(cancellationToken: cancellationTokenSource.Token);
return true;
}
catch (RequestFailedException)
{
return false;
}
catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested)
{
return false;
}
}
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.Storage.Blobs;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
/// <summary>
/// Verifies Azure Blob Storage registration for hosted agents.
/// </summary>
public sealed class AzureBlobHostedAgentBuilderExtensionsTests
{
[Fact]
public void WithAzureBlobSessionStore_RegistersSingletonWithIsolation()
{
// Arrange
var services = new ServiceCollection();
IHostedAgentBuilder builder = services.AddAIAgent(
"assistant",
(_, key) => new ChatClientAgent(new NotInvokedChatClient(), name: key));
BlobContainerClient containerClient = new("UseDevelopmentStorage=true", "agent-sessions");
// Act
builder.WithAzureBlobSessionStore(containerClient);
using ServiceProvider serviceProvider = services.BuildServiceProvider();
AgentSessionStore store = serviceProvider.GetRequiredKeyedService<AgentSessionStore>("assistant");
// Assert
ServiceDescriptor descriptor = Assert.Single(
services,
service =>
service.ServiceType == typeof(AgentSessionStore) &&
service.ServiceKey as string == "assistant");
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
Assert.IsType<IsolationKeyScopedAgentSessionStore>(store);
Assert.NotNull(store.GetService<AzureBlobAgentSessionStore>());
}
[Fact]
public void WithAzureBlobSessionStoreFactory_UsesAgentNameLifetimeAndIsolationOptions()
{
// Arrange
var services = new ServiceCollection();
IHostedAgentBuilder builder = services.AddAIAgent(
"assistant",
(_, key) => new ChatClientAgent(new NotInvokedChatClient(), name: key));
BlobContainerClient containerClient = new("UseDevelopmentStorage=true", "agent-sessions");
string? receivedAgentName = null;
// Act
builder.WithAzureBlobSessionStore(
(_, agentName) =>
{
receivedAgentName = agentName;
return containerClient;
},
lifetime: ServiceLifetime.Scoped,
withIsolation: false);
using ServiceProvider serviceProvider = services.BuildServiceProvider();
AgentSessionStore store = serviceProvider.GetRequiredKeyedService<AgentSessionStore>("assistant");
// Assert
ServiceDescriptor descriptor = Assert.Single(
services,
service =>
service.ServiceType == typeof(AgentSessionStore) &&
service.ServiceKey as string == "assistant");
Assert.Equal("assistant", receivedAgentName);
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.IsType<AzureBlobAgentSessionStore>(store);
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<PackageReference Include="AGUI.Abstractions" />
<PackageReference Include="AGUI.Client" />
<PackageReference Include="AGUI.Server" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureStorage\Microsoft.Agents.AI.Hosting.AzureStorage.csproj" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Compile Include="..\Microsoft.Agents.AI.Hosting.AzureStorage.TestUtilities\*.cs" LinkBase="TestUtilities" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests;
internal sealed class NotInvokedChatClient : IChatClient
{
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
}