diff --git a/docs/decisions/0039-shared-agent-session-store.md b/docs/decisions/0039-shared-agent-session-store.md index a6616e5e2b4..d6d33f97fca 100644 --- a/docs/decisions/0039-shared-agent-session-store.md +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -41,7 +41,7 @@ Chosen option: **Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Ab - The abstraction and every public implementation start as experimental under diagnostic `MAAI001`. - `GetSessionAsync` returns `AgentSession?` and returns `null` when no session is stored. - `GetOrCreateSessionAsync` performs the explicit lookup or creation operation. -- `SaveSessionAsync` and both lookup methods receive an `AgentSessionStoreKey`. +- `SaveSessionAsync`, `DeleteSessionAsync`, and both lookup methods receive an `AgentSessionStoreKey`. - `AgentSessionStoreKey.SessionId` identifies the logical session. - `AgentSessionStoreKey.Partitions` holds zero or more named isolation dimensions. Every partition is part of identity and implementations cannot ignore unknown partitions. @@ -50,7 +50,9 @@ Chosen option: **Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Ab - Partition order does not affect identity. Physical encoding remains the responsibility of each store. - `GetService(Type, object?)` and `GetService(object?)` retain service discovery from conventional Hosting. Stores can expose themselves, underlying implementations, or additional capabilities. -- `DeleteSessionAsync` is not part of the shared contract. +- `DeleteSessionAsync` is abstract, like `GetSessionAsync` and `SaveSessionAsync`, so the store remains the + single owner of the whole session lifecycle. It removes the stored session and treats a missing session as + a no-op. Decorators forward it, applying the same key transformation used for lookup and save. The duplicate types in `Microsoft.Agents.AI.Hosting` and `Microsoft.Agents.AI.Foundry.Hosting` are removed. Both packages reference the shared type directly. @@ -86,7 +88,7 @@ Negative: - This is a source-breaking change for implementations of the preview Hosting contract. - Callers must construct an `AgentSessionStoreKey`; unpartitioned sessions use only `SessionId`. -- Consumers that need deletion must use a storage-specific API until a separate shared deletion capability is defined. +- Every store implementation must provide `DeleteSessionAsync`. ## More Information diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 746d3634bf9..fd98bef8be2 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -109,6 +109,11 @@ public abstract class AgentSessionStore AgentSessionStoreKey key, AgentSession session, CancellationToken cancellationToken = default); + + public abstract ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default); } ``` @@ -139,7 +144,8 @@ state without observing another branch's changes. The store performs no cross-ca application that needs concurrent runs against the same id to be serialized owns that coordination. `SaveSessionAsync(agent, key, session)` persists the post-run state, including under a newly minted `resp_*` id when the protocol creates a continuation id. No agent-side holder is needed because -the convenience method already performs lookup or creation. +the convenience method already performs lookup or creation. `DeleteSessionAsync(agent, key)` removes the +stored session and is a no-op when nothing is stored. `AgentSessionStoreKey` contains a session id plus arbitrary named partitions. Every partition contributes to identity, independent of dictionary order. Stores must not ignore unknown partitions. Physical key diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs index 3a72dde499b..d4f4a74d22d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs @@ -78,6 +78,21 @@ public virtual async ValueTask GetOrCreateSessionAsync( ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Deletes a stored agent session, if present. + /// + /// The agent that owns this session. + /// The key that identifies and partitions the session. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous delete operation. + /// + /// Implementations delete the session and treat a missing session as a no-op. + /// + public abstract ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default); + /// Asks the store for an object of the specified type. /// The type of object being requested. /// An optional key that identifies the requested service. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 4f67fa96c03..085b5279485 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -5,6 +5,7 @@ [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt index 4f67fa96c03..085b5279485 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -5,6 +5,7 @@ [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 4f67fa96c03..085b5279485 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -5,6 +5,7 @@ [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt index 4f67fa96c03..085b5279485 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -5,6 +5,7 @@ [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 4f67fa96c03..085b5279485 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -5,6 +5,7 @@ [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs index 8262225a68d..6d4c15a0bda 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -236,6 +237,27 @@ private string BuildNotWritableMessage(string sessionFilePath) => return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false); } + /// + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(agent); + _ = Throw.IfNull(key); + cancellationToken.ThrowIfCancellationRequested(); + + string path = this.GetSessionPath(agent, key); + + // No directory for this agent means nothing was ever stored. + if (Directory.Exists(Path.GetDirectoryName(path))) + { + File.Delete(path); + } + + return default; + } + private string GetSessionPath(AIAgent agent, AgentSessionStoreKey key) { // The stable key incorporates the session identifier and every partition without exposing diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs index ad18c128f76..77e04a9bf5a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs @@ -8,6 +8,8 @@ using System.Threading.Tasks; using Azure.AI.AgentServer.Core.Storage; using Azure.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -63,6 +65,7 @@ public sealed class FoundryAgentSessionStore : AgentSessionStore private const string KeyField = "key"; private readonly FoundryStateStoreBinding _binding; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -83,15 +86,18 @@ public sealed class FoundryAgentSessionStore : AgentSessionStore /// not. The value only takes effect when this store is created for the first time, because the /// platform fixes it at creation. /// + /// Creates the logger this store reports through. public FoundryAgentSessionStore( Uri? endpoint = null, TokenCredential? credential = null, string storeName = DefaultStoreName, - int itemTtlSeconds = FoundryStateStore.DefaultItemTtlSeconds) + int itemTtlSeconds = FoundryStateStore.DefaultItemTtlSeconds, + ILoggerFactory? loggerFactory = null) { _ = Throw.IfNullOrWhitespace(storeName); this.StoreName = storeName; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); this._binding = new(cancellationToken => FoundryStateStore.GetOrCreateAsync( storeName, credential, @@ -107,12 +113,17 @@ public FoundryAgentSessionStore( /// /// Resolves the bound state store on first use. /// The state-store name, for diagnostics. - internal FoundryAgentSessionStore(Func> storeFactory, string storeName = DefaultStoreName) + /// Creates the logger this store reports through. + internal FoundryAgentSessionStore( + Func> storeFactory, + string storeName = DefaultStoreName, + ILoggerFactory? loggerFactory = null) { _ = Throw.IfNull(storeFactory); this._binding = new(storeFactory); this.StoreName = storeName; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); } /// Gets the state-store name that holds the sessions. @@ -177,6 +188,40 @@ await store.SetItemAsync( return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false); } + /// + public override async ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(agent); + _ = Throw.IfNull(key); + + string logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey( + FoundryHostingAgent.GetSessionStorageIdentity(agent), + key); + FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); + + try + { + await store.DeleteItemAsync( + FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey), + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (FoundryStorageNotFoundException ex) + { + // Nothing stored for this key, so deletion remains idempotent. + if (this._logger.IsEnabled(LogLevel.Debug)) + { + this._logger.LogDebug( + ex, + "Session '{SessionId}' was not found in the Foundry state store '{StoreName}'. Nothing to delete.", + key.SessionId, + this.StoreName); + } + } + } + /// /// Resolves the bound state store, creating it on the platform the first time. See /// for the caching and failure behaviour. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs index 5b4df9e3da6..c980121ff93 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -65,6 +66,19 @@ public override async ValueTask SaveSessionAsync( return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false); } + /// + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(agent); + _ = Throw.IfNull(key); + + this._sessions.TryRemove(GetKey(agent, key), out _); + return default; + } + private static (string AgentIdentity, AgentSessionStoreKey Key) GetKey( AIAgent agent, AgentSessionStoreKey key) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs index ced2123f1f5..ea9c7be5050 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs @@ -4,6 +4,7 @@ using Azure.Storage.Blobs; using Microsoft.Agents.AI.Hosting.AzureStorage; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -77,7 +78,11 @@ public static IHostedAgentBuilder WithAzureBlobSessionStore( { BlobContainerClient containerClient = Throw.IfNull(createBlobContainerClient(serviceProvider, agentName)); - return new AzureBlobAgentSessionStore(containerClient, agentName, options); + return new AzureBlobAgentSessionStore( + containerClient, + agentName, + options, + serviceProvider.GetService()); }, lifetime, withIsolation); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs index 00bdad661e3..f2df00ab515 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs @@ -11,6 +11,8 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -48,6 +50,7 @@ public sealed class AzureBlobAgentSessionStore : AgentSessionStore private readonly string _agentKey; private readonly string? _blobNamePrefix; private readonly bool _createContainerIfNotExists; + private readonly ILogger _logger; private Task? _containerInitializationTask; /// @@ -56,16 +59,19 @@ public sealed class AzureBlobAgentSessionStore : AgentSessionStore /// The blob container client to use for storage operations. /// A stable name that identifies the agent across application restarts. /// Optional configuration options. If , default options will be used. + /// Creates the logger this store reports through. /// is . /// is empty or consists only of whitespace. /// specifies a Blob name prefix that exceeds the Azure Blob name limit. public AzureBlobAgentSessionStore( BlobContainerClient containerClient, string agentNamespace, - AzureBlobAgentSessionStoreOptions? options = null) + AzureBlobAgentSessionStoreOptions? options = null, + ILoggerFactory? loggerFactory = null) { this._containerClient = Throw.IfNull(containerClient); this._agentKey = ComputeAgentKey(Throw.IfNullOrWhitespace(agentNamespace)); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); options ??= new AzureBlobAgentSessionStoreOptions(); this._createContainerIfNotExists = options.CreateContainerIfNotExists; @@ -117,6 +123,31 @@ await blobClient.UploadAsync( cancellationToken).ConfigureAwait(false); } + /// + public override async ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(agent); + _ = Throw.IfNull(key); + + BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(key)); + + // DeleteIfExistsAsync reports both a missing blob and a missing container as false. + Response response = await blobClient.DeleteIfExistsAsync( + DeleteSnapshotsOption.IncludeSnapshots, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (!response.Value && this._logger.IsEnabled(LogLevel.Debug)) + { + this._logger.LogDebug( + "Session '{SessionId}' was not found in the Blob container '{ContainerName}'. Nothing to delete.", + key.SessionId, + this._containerClient.Name); + } + } + private async ValueTask TryGetSessionAsync( AIAgent agent, string blobName, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index 89b8778a65f..f3a2a58896d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -130,6 +130,16 @@ public override async ValueTask SaveSessionAsync( await this.InnerStore.SaveSessionAsync(agent, scopedKey, session, cancellationToken).ConfigureAwait(false); } + /// + public override async ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + await this.InnerStore.DeleteSessionAsync(agent, scopedKey, cancellationToken).ConfigureAwait(false); + } + private sealed class CapturedIsolationKeyScope : IDisposable { private readonly string? _previousIsolationKey; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs index 56dc7ee3bbf..b53380cbd9a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs @@ -72,6 +72,19 @@ public override async ValueTask SaveSessionAsync( : null; } + /// + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(agent); + _ = Throw.IfNull(key); + + this._sessions.TryRemove((GetStorageIdentity(agent), key), out _); + return default; + } + private static string GetStorageIdentity(AIAgent agent) { // Hosted execution can supply a logical storage identity that survives transient agent instances. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs index 8d847b54c70..7f2f9f374aa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs @@ -32,4 +32,13 @@ public override ValueTask SaveSessionAsync( { return new((AgentSession?)null); } + + /// + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + return default; + } } diff --git a/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs index 6af4bb84773..b0e843b3f22 100644 --- a/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs @@ -20,7 +20,7 @@ namespace Microsoft.Agents.AI; /// underlying store. /// /// -/// The default implementation forwards lookup and save operations to the inner store. The inherited +/// The default implementation forwards lookup, save, and delete operations to the inner store. The inherited /// lookup-or-create method calls the outer store's lookup override before creating a session when needed. /// Service queries check this instance before querying the inner store. /// @@ -35,7 +35,7 @@ public abstract class DelegatingAgentSessionStore : AgentSessionStore /// The underlying session store instance that will handle the core operations. /// is . /// - /// Lookup and save operations are forwarded to this store unless overridden by a derived class. + /// Lookup, save, and delete operations are forwarded to this store unless overridden by a derived class. /// protected DelegatingAgentSessionStore(AgentSessionStore innerStore) { @@ -76,4 +76,11 @@ public override ValueTask SaveSessionAsync( AgentSession session, CancellationToken cancellationToken = default) => this.InnerStore.SaveSessionAsync(agent, key, session, cancellationToken); + + /// + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + => this.InnerStore.DeleteSessionAsync(agent, key, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt index fada975c025..3bfac2c2f40 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt index fada975c025..3bfac2c2f40 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt index fada975c025..3bfac2c2f40 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt index fada975c025..3bfac2c2f40 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index fada975c025..3bfac2c2f40 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs index 492e6e2fb54..d113eb9110c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs @@ -139,6 +139,12 @@ public override ValueTask SaveSessionAsync( AgentSession session, CancellationToken cancellationToken = default) => default; + + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + => default; } private sealed class TestAgentSession : AgentSession; diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs index 7f46da4a85f..982709b2927 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs @@ -446,6 +446,12 @@ public override ValueTask SaveSessionAsync( AgentSessionStoreKey key, CancellationToken cancellationToken = default) => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) => + default; } private sealed class CountingSessionStore : AgentSessionStore @@ -469,6 +475,12 @@ public override ValueTask SaveSessionAsync( AgentSessionStoreKey key, CancellationToken cancellationToken = default) => new((AgentSession?)null); + + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) => + default; } private sealed class AlwaysLoadedSessionStore : AgentSessionStore @@ -485,6 +497,12 @@ public override ValueTask SaveSessionAsync( AgentSessionStoreKey key, CancellationToken cancellationToken = default) => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) => + default; } private sealed class SessionAdvancingAgent : AIAgent diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs index 589f6b0fe86..d29f4d37184 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs @@ -175,6 +175,33 @@ public async Task GetSessionAsync_DifferentPartition_DoesNotReadStoredSessionAsy Assert.Equal(0, agent.DeserializeCalls); } + [Fact] + public async Task DeleteSessionAsync_RemovesOnlyTheTargetedSessionFileAsync() + { + var store = new FileSystemAgentSessionStore(this._root); + var agent = new TestAgent(); + var aliceKey = new AgentSessionStoreKey("shared").WithPartition("user", "alice"); + var bobKey = new AgentSessionStoreKey("shared").WithPartition("user", "bob"); + await store.SaveSessionAsync(agent, aliceKey, NewSession()); + await store.SaveSessionAsync(agent, bobKey, NewSession()); + + await store.DeleteSessionAsync(agent, aliceKey); + + Assert.False(File.Exists(SessionPath(store, "name:test-agent", aliceKey))); + Assert.True(File.Exists(SessionPath(store, "name:test-agent", bobKey))); + Assert.Null(await store.GetSessionAsync(agent, aliceKey)); + } + + [Fact] + public async Task DeleteSessionAsync_MissingSession_DoesNotThrowAsync() + { + var store = new FileSystemAgentSessionStore(this._root); + + await store.DeleteSessionAsync(new TestAgent(), new AgentSessionStoreKey("missing")); + + Assert.False(Directory.Exists(this._root)); + } + [Fact] public async Task SaveSessionAsync_ConcurrentSavesOnSameKey_DoNotCollideOnTempFileAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs index 640b6733735..9b14e23926e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Azure.AI.AgentServer.Core.Storage; using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; @@ -192,6 +193,73 @@ public async Task GetSessionAsync_UnnamedAgent_ThrowsAsync() Assert.Contains(nameof(AIAgent.Name), exception.Message, StringComparison.Ordinal); } + [Fact] + public async Task DeleteSessionAsync_RemovesOnlyTheTargetedSessionAsync() + { + // Arrange + var backing = new FakeStateStore(); + var store = NewStore(backing); + var agent = new TestAgent(name: "Concierge"); + var aliceKey = Key("shared-conv", "user", "alice"); + var bobKey = Key("shared-conv", "user", "bob"); + await store.SaveSessionAsync(agent, aliceKey, new TestSession()); + await store.SaveSessionAsync(agent, bobKey, new TestSession()); + + // Act + await store.DeleteSessionAsync(agent, aliceKey); + + // Assert + Assert.Null(await store.GetSessionAsync(agent, aliceKey)); + Assert.NotNull(await store.GetSessionAsync(agent, bobKey)); + var item = Assert.Single(backing.Items); + Assert.Equal("\"a14:name:Concierge|s11:shared-conv|n4:user|v3:bob\"", item["key"].ToString()); + } + + [Fact] + public async Task DeleteSessionAsync_NothingStored_DoesNotThrowAsync() + { + // Arrange + var store = NewStore(new FakeStateStore()); + var agent = new TestAgent(name: "Concierge"); + + // Act and assert + await store.DeleteSessionAsync(agent, Key("conv-1")); + } + + [Fact] + public async Task DeleteSessionAsync_NothingStored_LogsTheIgnoredNotFoundAtDebugAsync() + { + // Arrange + var logs = new RecordingLoggerFactory(); + var store = NewStore(new FakeStateStore(), logs); + var agent = new TestAgent(name: "Concierge"); + + // Act + await store.DeleteSessionAsync(agent, Key("conv-1")); + + // Assert + var entry = Assert.Single(logs.Entries); + Assert.Equal(LogLevel.Debug, entry.Level); + Assert.Contains("conv-1", entry.Message, StringComparison.Ordinal); + Assert.IsType(entry.Exception); + } + + [Fact] + public async Task DeleteSessionAsync_StoredSession_DoesNotLogAsync() + { + // Arrange + var logs = new RecordingLoggerFactory(); + var store = NewStore(new FakeStateStore(), logs); + var agent = new TestAgent(name: "Concierge"); + await store.SaveSessionAsync(agent, Key("conv-1"), new TestSession()); + + // Act + await store.DeleteSessionAsync(agent, Key("conv-1")); + + // Assert + Assert.Empty(logs.Entries); + } + [Fact] public async Task GetStoreAsync_ResolvesTheStoreOnceAcrossManyCallsAsync() { @@ -397,8 +465,8 @@ public void Constructor_WithoutCredential_IsAllowedForTheSdkLocalFallback() Assert.Equal(FoundryAgentSessionStore.DefaultStoreName, store.StoreName); } - private static FoundryAgentSessionStore NewStore(FakeStateStore backing) - => new(_ => Task.FromResult(backing)); + private static FoundryAgentSessionStore NewStore(FakeStateStore backing, ILoggerFactory? loggerFactory = null) + => new(_ => Task.FromResult(backing), loggerFactory: loggerFactory); private static AgentSessionStoreKey Key( string sessionId, @@ -436,6 +504,16 @@ public override Task SetItemAsync( => Task.FromResult(this._items.TryGetValue(key, out var value) ? AzureAIAgentServerCoreStorageModelFactory.StateStoreItem(id: key, key: key, value: value, etag: "etag") : null); + + public override Task DeleteItemAsync(string key, string? ifMatch = null, CancellationToken cancellationToken = default) + { + if (!this._items.TryRemove(key, out _)) + { + throw new FoundryStorageNotFoundException("not found"); + } + + return Task.FromResult(AzureAIAgentServerCoreStorageModelFactory.DeletedStateStoreItem(id: key, deleted: true)); + } } private sealed class TestSession : AgentSession diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryJsonCheckpointStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryJsonCheckpointStoreTests.cs index 8ada8a76dcb..c6c46bff4b5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryJsonCheckpointStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryJsonCheckpointStoreTests.cs @@ -571,30 +571,4 @@ private StateStoreItemRef Write(string key, IDictionary valu return AzureAIAgentServerCoreStorageModelFactory.StateStoreItemRef(id: key, key: key, etag: etag); } } - - /// Captures what the store reported, so a test can assert on it. - private sealed class RecordingLoggerFactory : ILoggerFactory - { - public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = []; - - public ILogger CreateLogger(string categoryName) => new RecordingLogger(this.Entries); - - public void AddProvider(ILoggerProvider provider) - { - } - - public void Dispose() - { - } - - private sealed class RecordingLogger(List<(LogLevel Level, string Message, Exception? Exception)> entries) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - => entries.Add((logLevel, formatter(state, exception), exception)); - } - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs new file mode 100644 index 00000000000..7d3c252f627 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Foundry.Hosting; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public sealed class InMemoryAgentSessionStoreTests +{ + [Fact] + public async Task DeleteSessionAsync_RemovesOnlyTheTargetedSessionAsync() + { + // Arrange + var store = new InMemoryAgentSessionStore(); + var agent = new TestAgent(); + var aliceKey = new AgentSessionStoreKey("shared").WithPartition("user", "alice"); + var bobKey = new AgentSessionStoreKey("shared").WithPartition("user", "bob"); + await store.SaveSessionAsync(agent, aliceKey, new TestSession()); + await store.SaveSessionAsync(agent, bobKey, new TestSession()); + + // Act + await store.DeleteSessionAsync(agent, aliceKey); + + // Assert + Assert.Null(await store.GetSessionAsync(agent, aliceKey)); + Assert.NotNull(await store.GetSessionAsync(agent, bobKey)); + } + + [Fact] + public async Task DeleteSessionAsync_MissingSession_DoesNotThrowAsync() + { + // Arrange + var store = new InMemoryAgentSessionStore(); + var agent = new TestAgent(); + + // Act + await store.DeleteSessionAsync(agent, new AgentSessionStoreKey("missing")); + + // Assert + Assert.Null(await store.GetSessionAsync(agent, new AgentSessionStoreKey("missing"))); + } + + private sealed class TestSession : AgentSession; + + private sealed class TestAgent : AIAgent + { + public override string? Name => "test-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new TestSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + using var document = JsonDocument.Parse("{}"); + return new(document.RootElement.Clone()); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new TestSession()); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/RecordingLoggerFactory.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/RecordingLoggerFactory.cs new file mode 100644 index 00000000000..e7e36c545a2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/RecordingLoggerFactory.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// Captures what the system under test reported, so a test can assert on it. +internal sealed class RecordingLoggerFactory : ILoggerFactory +{ + public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = []; + + public ILogger CreateLogger(string categoryName) => new RecordingLogger(this.Entries); + + public void AddProvider(ILoggerProvider provider) + { + } + + public void Dispose() + { + } + + private sealed class RecordingLogger(List<(LogLevel Level, string Message, Exception? Exception)> entries) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => entries.Add((logLevel, formatter(state, exception), exception)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs index 98020b21e59..55906bded8c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs @@ -596,6 +596,15 @@ await inner.SaveSessionAsync( agent, key, cancellationToken); + + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) => + inner.DeleteSessionAsync( + agent, + key, + cancellationToken); } private sealed class RecoveryCoordinator diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs index a8ccb35eefd..001fb517579 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs @@ -7,6 +7,8 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; +using Microsoft.Agents.AI.Hosting.UnitTests; +using Microsoft.Extensions.Logging; #if NET8_0_OR_GREATER using Microsoft.Agents.AI.Hosting.AzureStorage.Tests; #endif @@ -221,6 +223,67 @@ public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync() Assert.Equal("second", restored.StateBag.GetValue("marker")); } + [Fact] + public async Task DeleteSessionAsync_RemovesStoredSessionAndIgnoresMissingSessionAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-to-delete").WithPartition("user", "user-1"); + var otherKey = new AgentSessionStoreKey("session-to-delete").WithPartition("user", "user-2"); + await store.SaveSessionAsync(agent, key, await agent.CreateSessionAsync()); + await store.SaveSessionAsync(agent, otherKey, await agent.CreateSessionAsync()); + + // Act + await store.DeleteSessionAsync(agent, key); + AgentSession? deleted = await store.GetSessionAsync(agent, key); + AgentSession? retained = await store.GetSessionAsync(agent, otherKey); + await store.DeleteSessionAsync(agent, key); + + // Assert + Assert.Null(deleted); + Assert.NotNull(retained); + } + + [Fact] + public async Task DeleteSessionAsync_MissingContainer_DoesNotThrowAndLogsAtDebugAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + BlobContainerClient missingContainer = s_blobServiceClient.GetBlobContainerClient($"missing-{Guid.NewGuid():N}"); + var logs = new CapturingLoggerFactory(); + var store = new AzureBlobAgentSessionStore( + missingContainer, + "assistant", + new AzureBlobAgentSessionStoreOptions { CreateContainerIfNotExists = false }, + logs); + + // Act + await store.DeleteSessionAsync(agent, new AgentSessionStoreKey("session-1")); + + // Assert + var entry = Assert.Single(logs.Entries); + Assert.Equal(LogLevel.Debug, entry.Level); + Assert.Contains("session-1", entry.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task DeleteSessionAsync_StoredSession_DoesNotLogAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var logs = new CapturingLoggerFactory(); + var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant", loggerFactory: logs); + var key = new AgentSessionStoreKey("session-1"); + await store.SaveSessionAsync(agent, key, await agent.CreateSessionAsync()); + + // Act + await store.DeleteSessionAsync(agent, key); + + // Assert + Assert.Empty(logs.Entries); + } + [Fact] public async Task GetSessionAsync_MissingContainerWithoutAutoCreatePropagatesErrorAsync() { 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 index bcb6bc3d7fb..8fc0dbc45fa 100644 --- 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 @@ -11,6 +11,10 @@ + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs index d68adfe50af..27ed0e8f84b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ResponseSessionRegressionTests.cs @@ -592,6 +592,15 @@ public override ValueTask SaveSessionAsync( return this._inner.SaveSessionAsync(agent, key, session, cancellationToken); } + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(this.IsDisposed, this); + return this._inner.DeleteSessionAsync(agent, key, cancellationToken); + } + public void Dispose() { this.IsDisposed = true; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs index 97e99833da2..1707d858389 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -111,6 +111,45 @@ public async Task GetSessionAsync_SharedStorageIdentity_RestoresAcrossDifferentA Assert.Equal("persisted", restored.StateBag.GetValue("marker")); } + [Fact] + public async Task DeleteSessionAsync_RemovesStoredSessionAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new InMemoryAgentSessionStore(); + var key = new AgentSessionStoreKey("s1").WithPartition("user", "user-1"); + var otherKey = new AgentSessionStoreKey("s1").WithPartition("user", "user-2"); + await store.SaveSessionAsync(agent, key, await agent.CreateSessionAsync()); + await store.SaveSessionAsync(agent, otherKey, await agent.CreateSessionAsync()); + + // Act + await store.DeleteSessionAsync(agent, key); + + // Assert + Assert.Null(await store.GetSessionAsync(agent, key)); + Assert.NotNull(await store.GetSessionAsync(agent, otherKey)); + } + + [Fact] + public async Task DeleteSessionAsync_MissingSession_DoesNotThrowAsync() + { + // Arrange + var store = new InMemoryAgentSessionStore(); + + // Act and assert + await store.DeleteSessionAsync(new Mock().Object, new AgentSessionStoreKey("missing")); + } + + [Fact] + public async Task DeleteSessionAsync_NoopStore_CompletesAsync() + { + // Arrange + var store = new NoopAgentSessionStore(); + + // Act and assert + await store.DeleteSessionAsync(new Mock().Object, new AgentSessionStoreKey("any")); + } + // A chat client that is never invoked: these tests only create, serialize, and deserialize sessions. private sealed class NotInvokedChatClient : IChatClient { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs index e8fc4c8a976..0af48e9a340 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -80,6 +80,47 @@ public async Task SaveSessionAsync_AddsIsolationPartitionAsync() this._innerStoreMock.VerifyAll(); } + [Fact] + public async Task DeleteSessionAsync_AddsIsolationPartitionAsync() + { + // Arrange + var key = new AgentSessionStoreKey("session-1"); + this._innerStoreMock + .Setup(x => x.DeleteSessionAsync( + this._agentMock.Object, + It.Is(actual => + actual.SessionId == "session-1" + && actual.Partitions != null + && actual.Partitions["isolation"] == TestIsolationKey), + It.IsAny())) + .Returns(ValueTask.CompletedTask); + var store = this.CreateStore(TestIsolationKey); + + // Act + await store.DeleteSessionAsync(this._agentMock.Object, key); + + // Assert + this._innerStoreMock.VerifyAll(); + } + + [Fact] + public async Task DeleteSessionAsync_StrictModeWithoutIsolationKey_ThrowsAsync() + { + // Arrange + var store = this.CreateStore( + isolationKey: null, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); + + // Act and assert + await Assert.ThrowsAsync( + () => store.DeleteSessionAsync( + this._agentMock.Object, + new AgentSessionStoreKey("session-1")).AsTask()); + this._innerStoreMock.Verify( + x => x.DeleteSessionAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task GetOrCreateSessionAsync_ForwardsScopedKeyToSpecializedInnerStoreAsync() { @@ -296,5 +337,14 @@ public override ValueTask SaveSessionAsync( this.Keys.Add(key); return ValueTask.CompletedTask; } + + public override ValueTask DeleteSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + this.Keys.Add(key); + return ValueTask.CompletedTask; + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs index 7f75fae4e74..48851277946 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -291,6 +291,38 @@ public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() await resultTask; } + /// + /// Verify that DeleteSessionAsync delegates to inner store with correct parameters. + /// + [Fact] + public async Task DeleteSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); + var expectedCancellationToken = new CancellationToken(); + + this._innerStoreMock + .Setup(x => x.DeleteSessionAsync( + this._agentMock.Object, + expectedKey, + expectedCancellationToken)) + .Returns(default(ValueTask)); + + // Act + await this._delegatingStore.DeleteSessionAsync( + this._agentMock.Object, + expectedKey, + expectedCancellationToken); + + // Assert + this._innerStoreMock.Verify( + x => x.DeleteSessionAsync( + this._agentMock.Object, + expectedKey, + expectedCancellationToken), + Times.Once); + } + #endregion #region Test Implementation