diff --git a/docs/decisions/0031-hosted-per-user-session-storage-isolation.md b/docs/decisions/0031-hosted-per-user-session-storage-isolation.md index f7ca09a46e1..beb6942c489 100644 --- a/docs/decisions/0031-hosted-per-user-session-storage-isolation.md +++ b/docs/decisions/0031-hosted-per-user-session-storage-isolation.md @@ -101,6 +101,13 @@ Negative: - Encryption at rest and quota enforcement remain platform concerns. - Non-Foundry hosting layers can adopt an equivalent scheme independently. +## Update (2026-09-01): contract promoted to Abstractions + +[ADR-0039](0039-shared-agent-session-store.md) promotes this `AgentSessionStore` contract to +`Microsoft.Agents.AI.Abstractions` and makes it the common contract for Foundry Hosting and conventional +Hosting. It supersedes the required `userId` parameter with `AgentSessionStoreKey.Partitions`. Foundry +Hosting adds the resolved user identity as a named partition before loading or saving a session. + ## Update (2026-07-01): local runs no longer fail closed; sample dev provider removed Superseding the ADR-0026/0030 behavior where a `null` result from `HostedSessionIsolationKeyProvider` diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md index 907410e1cc3..bc3d36adf38 100644 --- a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -11,6 +11,9 @@ informed: [] Realizes the helper-first direction of [ADR-0027](0027-hosting-channels.md) for .NET. +> **Update (2026-09-01):** [ADR-0039](0039-shared-agent-session-store.md) supersedes the +> `AgentSessionStore` portion of this decision. The protocol helper and workflow decisions remain accepted. + ## Context and Problem Statement [ADR-0027](0027-hosting-channels.md) refocused the (Python) hosting design away from a channel diff --git a/docs/decisions/0039-shared-agent-session-store.md b/docs/decisions/0039-shared-agent-session-store.md new file mode 100644 index 00000000000..da3605871fe --- /dev/null +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -0,0 +1,88 @@ +--- +status: proposed +contact: rogerbarreto +date: 2026-09-01 +deciders: rogerbarreto +consulted: [] +informed: [] +--- + +# Shared AgentSessionStore abstraction + +## Context and Problem Statement + +.NET has two public `AgentSessionStore` abstract classes. `Microsoft.Agents.AI.Hosting` defines a store +whose lookup creates a session when no value exists. `Microsoft.Agents.AI.Foundry.Hosting` defines a store +whose lookup returns `null`, accepts an explicit user partition, and provides a separate convenience method +that creates a session when needed. The types cannot be used interchangeably, so storage integrations depend +on a specific hosting protocol package instead of the core agent abstractions. + +## Decision Drivers + +- One storage contract must work across all hosting packages. +- Storage implementations must depend only on `Microsoft.Agents.AI.Abstractions`. +- A lookup must distinguish a missing value from a stored value without creating state as a side effect. +- The contract must support any number of isolation dimensions without privileging user identity. +- Existing Foundry storage behavior and per-user isolation must remain unchanged. + +## Considered Options + +1. Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Abstractions`. +2. Promote the conventional Hosting contract and adapt Foundry Hosting to it. +3. Add a third contract and keep adapters for both existing contracts. +4. Represent session identity as an immutable key with arbitrary named partitions. + +## Decision Outcome + +Chosen option: **Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Abstractions`**. + +`AgentSessionStore` moves to the `Microsoft.Agents.AI` namespace and keeps the Foundry Hosting behavior: + +- 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`. +- `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. +- Partition order does not affect identity. Physical encoding remains the responsibility of each store. +- `DeleteSessionAsync` and service inspection are not part of the shared contract. + +The duplicate types in `Microsoft.Agents.AI.Hosting` and `Microsoft.Agents.AI.Foundry.Hosting` are removed. +Both packages reference the shared type directly. + +`DelegatingAgentSessionStore` lives in the `Microsoft.Agents.AI` package beside `ChatClientAgent`, providing +the common decorator base without requiring a hosting-protocol package. + +The conventional Hosting implementations adopt the same behavior. `IsolationKeyScopedAgentSessionStore` +adds the value from `AgentIsolationKeyProvider` under the `isolation` partition while preserving existing +partitions. Protocol-specific hosting can add named partitions such as `user`, `tenant`, or `chat` before +loading the session. `AIHostAgent` uses `GetOrCreateSessionAsync` when it needs a ready session. + +Azure Blob Storage, filesystem storage, and Foundry State Store each encode the session id and every +partition into their own collision-safe physical key. Version 1 Azure +Blob keys are not read because the package is still preview and the previous format cannot distinguish +all partition combinations safely. + +Provider-specific metadata does not belong in `AgentSessionStoreKey`. For example, Foundry item tags can +be exposed by an overload or options type on `FoundryAgentSessionStore` without adding tags to Abstractions. + +## Consequences + +Positive: + +- Storage implementations can be shared by Foundry Hosting, conventional Hosting, and future protocols. +- Missing session handling is explicit and consistent. +- Isolation dimensions are explicit, composable, and independent from any hosting protocol. +- `Microsoft.Agents.AI.Abstractions` owns the contract alongside `AIAgent` and `AgentSession`. + +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. + +## More Information + +- [ADR-0031](0031-hosted-per-user-session-storage-isolation.md) records the earlier Foundry-specific user partition. +- [ADR-0032](0032-dotnet-hosting-protocol-helpers.md) records the previous conventional Hosting contract. diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 68a3a4353f3..746d3634bf9 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -87,23 +87,34 @@ does (by default no request setting is mapped onto the run; unsupported settings converters (an internal `ToResponse` overload with an optional originating request is added so the facade can render without one). The streaming renderer's existing workflow-event support is preserved. -### `Microsoft.Agents.AI.Hosting` (execution state, protocol-neutral) +### `Microsoft.Agents.AI.Abstractions` (agent session persistence) ```csharp -namespace Microsoft.Agents.AI.Hosting; +namespace Microsoft.Agents.AI; public abstract class AgentSessionStore { - // ... existing members ... - - // New: the one missing store operation. Virtual (not abstract) with a default that throws - // NotSupportedException, so existing external stores (e.g. the Foundry hosting stores) keep - // compiling; the in-box Hosting stores override it. In-box overrides treat deleting a missing - // session as a no-op. - public virtual ValueTask DeleteSessionAsync( - AIAgent agent, string conversationId, CancellationToken cancellationToken = default); + public abstract ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default); + + public virtual ValueTask GetOrCreateSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default); + + public abstract ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default); } +``` + +### `Microsoft.Agents.AI.Hosting` (workflow execution state) +```csharp // Thin holder: pairs a workflow target with checkpointing + a per-session head cursor. public sealed class HostedWorkflowState { @@ -121,15 +132,19 @@ public sealed class HostedWorkflowState } ``` -For agents, the application uses `AgentSessionStore` directly: `GetSessionAsync(agent, id)` creates a -session on miss and returns an independent instance per call (so concurrent calls can fork the same -stored state — for example branching from a `previous_response_id` or managing several `conversation` -ids side by side — without one branch observing another's in-flight mutations). The store performs no -cross-call locking; an application that needs concurrent runs against the same id to be serialized owns -that coordination. `SaveSessionAsync(agent, id, session)` persists post-run, including under a newly -minted `resp_*` id when the protocol mints a new continuation id. `DeleteSessionAsync` uses the new -store method. No agent-side holder is needed: create-on-miss already lives in the store, so a -pass-through wrapper would only bind the `agent` argument. +For agents, the application uses `AgentSessionStore` directly. `GetSessionAsync(agent, key)` +returns `null` on a miss, while `GetOrCreateSessionAsync(agent, key)` returns a ready session. +Each successful lookup returns an independent instance, so concurrent calls can fork the same stored +state without observing another branch's changes. The store performs no cross-call locking. An +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. + +`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 +encoding belongs to each store implementation. Provider-specific +metadata such as Foundry tags is not part of the key or the Abstractions contract. `HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory `sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but @@ -171,8 +186,8 @@ parsing a structured payload into a typed record), without coupling the holder t - Authenticate the caller before using any `GetSessionId(...)` result. - Authorize and bind the candidate id to the authenticated principal/tenant before using it as an `AgentSessionStore` key or a workflow checkpoint session id. -- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via - `UseClaimsBasedAgentIsolation(...)`), so the session namespace is scoped per principal. +- Multi-user hosts must add a trusted identity partition, or wrap the store with + `IsolationKeyScopedAgentSessionStore` so `AgentIsolationKeyProvider` supplies one. - Persist session/checkpoint state only after the run or stream has completed. ## E2E Code Samples @@ -193,7 +208,8 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => string sessionId = Authorize(http.User, candidate) ?? OpenAIResponses.CreateResponseId(); var run = OpenAIResponses.ToAgentRunRequest(body); - var session = await sessionStore.GetSessionAsync(agent, sessionId, ct); + var key = new AgentSessionStoreKey(sessionId); + var session = await sessionStore.GetOrCreateSessionAsync(agent, key, ct); string responseId = OpenAIResponses.CreateResponseId(); @@ -206,12 +222,20 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => await http.Response.WriteAsync(frame, ct); await http.Response.Body.FlushAsync(ct); } - await sessionStore.SaveSessionAsync(agent, responseId, session, ct); + await sessionStore.SaveSessionAsync( + agent, + new AgentSessionStoreKey(responseId), + session, + ct); return Results.Empty; } var result = await agent.RunAsync(run.Messages, session, run.Options, ct); - await sessionStore.SaveSessionAsync(agent, responseId, session, ct); + await sessionStore.SaveSessionAsync( + agent, + new AgentSessionStoreKey(responseId), + session, + ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId)); }); ``` diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md index 5f6fb1bd6d4..4873bd3b202 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md @@ -54,4 +54,4 @@ The client defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_ `OpenAIResponses.GetSessionStoreId(...)` returns an untrusted candidate key. The server's `Authorize(...)` is a placeholder; a real application must authenticate the caller and authorize/bind the id to the authenticated principal before using it as a session key. For multi-user hosts, scope the store with -`IsolationKeyScopedAgentSessionStore`. +`IsolationKeyScopedAgentSessionStore`, or add trusted partition values to `AgentSessionStoreKey`. diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index b9a62c09dbc..71be67b9cc2 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -46,8 +46,8 @@ static string LookupWeather([Description("The city to look up weather for.")] st name: "WeatherAgent", tools: [AIFunctionFactory.Create(LookupWeather, name: "lookup_weather")]); -// The application owns session storage directly. The in-memory store's GetSessionAsync creates a session -// on first use and returns an independent instance per call; no shared holder is needed. A real app that +// The application owns session storage directly. GetOrCreateSessionAsync loads a saved session or creates +// one on first use and returns an independent instance per call. A real app that // runs concurrent turns against the same session id owns any coordination it needs. AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); @@ -65,8 +65,12 @@ static string LookupWeather([Description("The city to look up weather for.")] st // this key to the principal before using it. This sample simply falls back to a fresh id. string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run); string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId(); + var sessionKey = new AgentSessionStoreKey(sessionStoreId); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, cancellationToken).ConfigureAwait(false); + AgentSession session = await sessionStore.GetOrCreateSessionAsync( + agent, + sessionKey, + cancellationToken).ConfigureAwait(false); string responseId = OpenAIResponses.CreateResponseId(); // Choose where to persist the post-run session, which depends on how the caller continued the thread: @@ -92,7 +96,7 @@ static string LookupWeather([Description("The city to look up weather for.")] st } // Persist the post-run session under the selected continuation id (see saveId above). - await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(saveId), session, cancellationToken).ConfigureAwait(false); // The SSE body was already written straight to http.Response above, so return an empty result: // this returns from the handler (the non-streaming code below does not run) without writing a body. @@ -100,7 +104,7 @@ static string LookupWeather([Description("The city to look up weather for.")] st } AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, cancellationToken).ConfigureAwait(false); - await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(saveId), session, cancellationToken).ConfigureAwait(false); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md index 273e22b4e5c..e1faafb7278 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -11,9 +11,9 @@ Exposes an `AIAgent` over the OpenAI Responses protocol on a `POST /responses` r - `OpenAIResponses.WriteResponse(...)` / `WriteResponseStreamAsync(...)` render the agent output back to the Responses wire shape (non-streaming JSON and SSE). -Session continuity uses an in-memory `AgentSessionStore` directly. `GetSessionAsync(agent, id)` creates a -session on first use and returns an independent instance per call; the store does no internal locking, so a -route that runs concurrent turns against the same id owns any coordination it needs. +Session continuity uses an in-memory `AgentSessionStore` directly. `GetOrCreateSessionAsync` loads a stored +session or creates one on first use and returns an independent instance per call. The store does no internal +locking, so a route that runs concurrent turns against the same id owns any coordination it needs. The route persists each turn under a continuation id chosen by how the caller continued the thread: diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs new file mode 100644 index 00000000000..47b7ed4740a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Defines the contract for storing and retrieving agent conversation sessions. +/// +/// +/// Implementations enable persistent storage of conversation sessions, allowing conversations to be +/// resumed across HTTP requests, application restarts, or different service instances in hosted scenarios. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSessionStore +{ + /// + /// Saves an agent session to persistent storage. + /// + /// The agent that owns this session. + /// The key that identifies and partitions the session. + /// The session to save. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + public abstract ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default); + + /// + /// Retrieves an agent session from persistent storage, or when no session is stored + /// for the given identifiers. + /// + /// The agent that owns this session. + /// The key that identifies and partitions the session. + /// The to monitor for cancellation requests. + /// + /// A task whose result contains the restored session, or when nothing is stored for + /// the given identifiers. This method never creates a session. + /// + /// + /// Each successful lookup must return an independent instance. Callers may + /// mutate the returned session and may run concurrent branches from the same identifiers without those + /// branches observing one another's changes or modifying the stored state. Implementations that cache a + /// live session must return an independent copy rather than the shared instance. + /// + public abstract ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default); + + /// + /// Retrieves the stored session for the given identifiers, or creates a new one when none is stored. + /// + /// The agent that owns this session. + /// The key that identifies and partitions the session. + /// The to monitor for cancellation requests. + /// A task whose result is always a usable session. + /// + /// The default implementation calls and creates a session through + /// only when the lookup returns . + /// Implementations that override receive this behavior automatically. + /// + public virtual async ValueTask GetOrCreateSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(agent); + + return await this.GetSessionAsync(agent, key, cancellationToken).ConfigureAwait(false) + ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs new file mode 100644 index 00000000000..a3dc986a671 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Identifies an agent session in persistent storage. +/// +/// +/// +/// identifies the session while contains additional +/// named dimensions that isolate sessions sharing that identifier. Every partition is part of the +/// identity and must be honored by implementations. +/// +/// +/// Partition names are compared using ordinal, case-sensitive comparison. Partition ordering does not +/// affect identity. Names and values cannot be empty or whitespace. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSessionStoreKey : IEquatable +{ + private readonly int _hashCode; + + /// + /// Initializes a new instance of the class. + /// + /// The logical session identifier. + /// + /// Optional named partition values. Every partition contributes to identity. The collection is copied. + /// + public AgentSessionStoreKey( + string sessionId, + IReadOnlyDictionary? partitions = null) + { + this.SessionId = Throw.IfNullOrWhitespace(sessionId); + + var partitionCopy = new SortedDictionary(StringComparer.Ordinal); + if (partitions is not null) + { + foreach (KeyValuePair partition in partitions) + { + partitionCopy.Add( + Throw.IfNullOrWhitespace(partition.Key, nameof(partitions)), + Throw.IfNullOrWhitespace(partition.Value, nameof(partitions))); + } + } + + this.Partitions = new ReadOnlyDictionary(partitionCopy); + + this._hashCode = this.ComputeHashCode(); + } + + /// + /// Gets the logical session identifier. + /// + public string SessionId { get; } + + /// + /// Gets the named partition values that form part of the session identity. + /// + public IReadOnlyDictionary Partitions { get; } + + /// + /// Returns a new key containing the specified partition. + /// + /// The partition name. + /// The partition value. + /// + /// A new key with the partition added or replaced, or this instance when the partition already has + /// the specified value. + /// + public AgentSessionStoreKey WithPartition(string name, string value) + { + name = Throw.IfNullOrWhitespace(name); + value = Throw.IfNullOrWhitespace(value); + + if (this.Partitions.TryGetValue(name, out string? existingValue) + && string.Equals(existingValue, value, StringComparison.Ordinal)) + { + return this; + } + + var partitions = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair partition in this.Partitions) + { + partitions.Add(partition.Key, partition.Value); + } + partitions[name] = value; + + return new AgentSessionStoreKey(this.SessionId, partitions); + } + + /// + public bool Equals(AgentSessionStoreKey? other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other is null + || !string.Equals(this.SessionId, other.SessionId, StringComparison.Ordinal) + || this.Partitions.Count != other.Partitions.Count) + { + return false; + } + + foreach (KeyValuePair partition in this.Partitions) + { + if (!other.Partitions.TryGetValue(partition.Key, out string? value) + || !string.Equals(partition.Value, value, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object? obj) => this.Equals(obj as AgentSessionStoreKey); + + /// + public override int GetHashCode() => this._hashCode; + + private int ComputeHashCode() + { + unchecked + { + int hashCode = StringComparer.Ordinal.GetHashCode(this.SessionId); + foreach (KeyValuePair partition in this.Partitions) + { + hashCode = (hashCode * 31) + StringComparer.Ordinal.GetHashCode(partition.Key); + hashCode = (hashCode * 31) + StringComparer.Ordinal.GetHashCode(partition.Value); + } + + return hashCode; + } + } +} 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 ab058de62d4..55178675143 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 @@ -1 +1,14 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[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]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 +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int 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 ab058de62d4..55178675143 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 @@ -1 +1,14 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[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]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 +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int 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 ab058de62d4..55178675143 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 @@ -1 +1,14 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[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]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 +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int 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 ab058de62d4..55178675143 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 @@ -1 +1,14 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[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]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 +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int 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 ab058de62d4..55178675143 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 @@ -1 +1,14 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[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]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 +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 32248cf30b1..f6f68d9c4d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -36,6 +36,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; public class AgentFrameworkResponseHandler : ResponseHandler { private const string LatestWorkflowCheckpointIdMetadataKey = "_last_checkpoint_id"; + private const string UserPartitionName = "user"; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -130,8 +131,8 @@ public override async IAsyncEnumerable CreateAsync( // When resolvedHostedContext is null here the container is NOT hosted by Foundry (local // development: docker run / dotnet run outside the platform, so no x-agent-user-id header). - // Per-user isolation simply does not apply in that case: the request proceeds with a null user - // id (the session store treats null as "no user partition") and no hosted context is stamped or + // Per-user isolation simply does not apply in that case: no user partition is added to the + // session key and no hosted context is stamped or // validated. This lets contributors run the image locally without registering a fallback // provider, while production stays strict because FoundryEnvironment.IsHosted is true there. var resolvedUserId = resolvedHostedContext?.UserId; @@ -140,13 +141,20 @@ public override async IAsyncEnumerable CreateAsync( // Map the request to a stable MAF AgentSession key: conversation_id when present, else the // partition embedded in previous_response_id (chains converge), else the minted response id // (cold start). Container session id is intentionally not used — it spans many conversations. - // The session store partitions persisted state per user via resolvedUserId so one user can + // The session key partitions persisted state per user via resolvedUserId so one user can // never observe another user's session, even with a forged conversation id. Locally // (resolvedUserId is null) there is no user to partition on, so the session is unscoped/shared // by design — per-user isolation applies only when a user identity was resolved (hosted). var conversationId = request.GetConversationId(); var agentSessionId = HostedConversationKey.Resolve( conversationId, request.PreviousResponseId, context.ResponseId); + AgentSessionStoreKey? agentSessionKey = string.IsNullOrWhiteSpace(agentSessionId) + ? null + : new AgentSessionStoreKey(agentSessionId); + if (agentSessionKey is not null && resolvedUserId is not null) + { + agentSessionKey = agentSessionKey.WithPartition(UserPartitionName, resolvedUserId); + } var agentOptions = agent.GetService(); var hostingOptions = this._serviceProvider.GetService>()?.Value; @@ -157,7 +165,7 @@ public override async IAsyncEnumerable CreateAsync( // a session to run against. AgentSession? session; bool sessionRestoredFromStore = false; - if (string.IsNullOrWhiteSpace(agentSessionId)) + if (agentSessionKey is null) { session = await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } @@ -165,8 +173,7 @@ public override async IAsyncEnumerable CreateAsync( { session = await sessionStore.GetSessionAsync( agent, - agentSessionId, - resolvedUserId, + agentSessionKey, cancellationToken).ConfigureAwait(false); sessionRestoredFromStore = session is not null; @@ -469,7 +476,7 @@ await this._toolboxService if (!isResilientTurn || workflowCheckpointRecovery is null || session is null - || string.IsNullOrWhiteSpace(agentSessionId) + || agentSessionKey is null || (stream.InternalMetadata.TryGetValue(LatestWorkflowCheckpointIdMetadataKey, out string? lastCheckpointId) && string.Equals(lastCheckpointId, checkpoint.CheckpointId, StringComparison.Ordinal))) { @@ -480,9 +487,8 @@ await this._toolboxService { await sessionStore.SaveSessionAsync( agent, - agentSessionId, + agentSessionKey, session, - resolvedUserId, checkpointCancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) @@ -667,12 +673,12 @@ bool CheckNotAllowedStoreUsage() => && evt is ResponseOutputItemDoneEvent && workflowCheckpointRecovery is null && session is not null - && !string.IsNullOrWhiteSpace(agentSessionId) + && agentSessionKey is not null && !turnFailed) { try { - await sessionStore.SaveSessionAsync(agent, agentSessionId!, session, resolvedUserId, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, agentSessionKey, session, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -709,9 +715,8 @@ bool CheckNotAllowedStoreUsage() => { await sessionStore.SaveSessionAsync( agent, - agentSessionId!, + agentSessionKey!, session, - resolvedUserId, steeringDetected ? CancellationToken.None : cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs deleted file mode 100644 index d507db4d966..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI.Foundry.Hosting; - -/// -/// Defines the contract for storing and retrieving agent conversation sessions. -/// -/// -/// Implementations of this interface enable persistent storage of conversation sessions, -/// allowing conversations to be resumed across HTTP requests, application restarts, -/// or different service instances in hosted scenarios. -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public abstract class AgentSessionStore -{ - /// - /// Saves a serialized agent session to persistent storage. - /// - /// The agent that owns this session. - /// The unique identifier for the conversation/session. - /// The session to save. - /// - /// The platform-injected per-user partition key (x-agent-user-id) that scopes this session to the - /// end user who initiated the request. Pass only when there is genuinely no user - /// context (for example local development without the platform header, or a non-hosted direct caller). - /// The parameter is required (no default) so every caller consciously decides the scope: implementations - /// that persist to a shared medium partition by this value so one user can never observe another user's - /// sessions, and an accidental unscoped save cannot happen silently. - /// - /// The to monitor for cancellation requests. - /// A task that represents the asynchronous save operation. - public abstract ValueTask SaveSessionAsync( - AIAgent agent, - string conversationId, - AgentSession session, - string? userId, - CancellationToken cancellationToken = default); - - /// - /// Retrieves a serialized agent session from persistent storage, or when - /// no session is stored for the given identifiers. - /// - /// The agent that owns this session. - /// The unique identifier for the conversation/session to retrieve. - /// - /// The platform-injected per-user partition key (x-agent-user-id) that scopes this session to the - /// end user who initiated the request. Pass only when there is genuinely no user - /// context (for example local development without the platform header, or a non-hosted direct caller). - /// The parameter is required (no default); it must match the value used when the session was saved, - /// otherwise a different (or new) session is returned. - /// - /// The to monitor for cancellation requests. - /// - /// A task that represents the asynchronous retrieval operation. The task result contains the restored - /// session, or when nothing is stored for the given identifiers. This is a plain - /// lookup: it never creates a session. Use to get a ready-to-use - /// session (loading an existing one or creating a new one), and use this method when the caller needs to - /// distinguish a resumed session from a fresh one (a non-null result means a prior turn established it). - /// - public abstract ValueTask GetSessionAsync( - AIAgent agent, - string conversationId, - string? userId, - CancellationToken cancellationToken = default); - - /// - /// Retrieves the stored session for the given identifiers, or creates a new one via - /// when none is stored. - /// - /// The agent that owns this session. - /// The unique identifier for the conversation/session to retrieve. - /// The per-user partition key; see for its meaning. - /// The to monitor for cancellation requests. - /// A task whose result is always a usable session, never . - /// - /// This is the convenience path for callers that only need a session to work with and do not care whether - /// it was loaded or freshly created. It is implemented in terms of , so a - /// store overriding that method gets this behavior for free. - /// - public virtual async ValueTask GetOrCreateSessionAsync( - AIAgent agent, - string conversationId, - string? userId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(agent); - - return await this.GetSessionAsync(agent, conversationId, userId, cancellationToken).ConfigureAwait(false) - ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs index c7c3b7292d4..8262225a68d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs @@ -1,10 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Buffers; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -14,7 +12,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// Provides a file-system backed implementation of that persists -/// the agent-framework's serialized state for each (agent, conversation) +/// the agent-framework's serialized state for each agent and session key /// pair to disk. This complements Foundry storage (which owns conversation messages, agent /// definitions, and threads) — it is not a replacement for it. /// @@ -33,7 +31,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// read-only and paths outside $HOME may be cleared between requests. Locally, /// sessions fall under {cwd}/.checkpoints. The session JSON produced when the agent /// serializes the session already contains the workflow's in-memory checkpoint manager -/// state, so a single file per (agent, conversation) pair is sufficient to resume +/// state, so a single file per agent and session key is sufficient to resume /// long-running workflows across process restarts. /// /// @@ -147,15 +145,19 @@ private static bool IsUsableHostedHomeDirectory(string? homeDirectory) } /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(agent); - ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(session); JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); - string path = this.GetSessionPath(agent, conversationId, userId); + string path = this.GetSessionPath(agent, key); // Each save writes to its own temp file before atomically renaming over the // destination. Last writer wins for the final file, but no reader can observe @@ -208,12 +210,15 @@ private string BuildNotWritableMessage(string sessionFilePath) => $"(for example {nameof(InMemoryAgentSessionStore)}) via AddFoundryResponses(agent, agentSessionStore)."; /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(agent); - ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(key); - string path = this.GetSessionPath(agent, conversationId, userId); + string path = this.GetSessionPath(agent, key); if (!File.Exists(path)) { return null; @@ -231,40 +236,22 @@ private string BuildNotWritableMessage(string sessionFilePath) => return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false); } - private string GetSessionPath(AIAgent agent, string conversationId, string? userId) + private string GetSessionPath(AIAgent agent, AgentSessionStoreKey key) { - // Path layout uses self-describing, prefixed segments so every layer is unambiguous and a - // collapsed layout can never be confused with a different layer (e.g. a user id can never - // masquerade as an agent name): + // The stable key incorporates the session identifier and every partition without exposing + // those values in the filesystem: // - // {root}/a-{agent}/u-{userId}/c-{conversationId}.json + // {root}/a-{agent}/k-{stable-key}.json // - // - a-{agent} buckets per hosted agent, because a single container hosts multiple keyed - // agents that must not collide on the same conversationId. (agent.Id is NOT - // used: it is regenerated on every startup for in-memory-defined agents.) - // - u-{userId} partitions per end user (x-agent-user-id) for multi-tenant isolation. Present - // only when a user id was resolved; absent for local runs with no platform header. - // - c-{conv} the conversation/context key. {conversationId} is HostedConversationKey.Resolve's - // output (conversation_id, else the partition of previous_response_id / response id). - // - // The prefixes are constant literals applied AFTER sanitizing/validating each untrusted value, - // so they can never themselves introduce path traversal. - string dir = this.RootDirectory; - - if (!string.IsNullOrEmpty(agent.Name)) - { - dir = Path.Combine(dir, "a-" + Sanitize(agent.Name!)); - } - - if (!string.IsNullOrWhiteSpace(userId)) - { - // The user id is the platform-injected, untrusted partition key. Reject (do not sanitize) - // anything that is not a single safe path component so a forged value cannot escape the root. - ValidatePathSegment(userId!, "user id"); - dir = Path.Combine(dir, "u-" + Sanitize(userId!)); - } + // Persistent storage requires the stable name or keyed registration carried by the hosted + // wrapper. Hashing it avoids case-insensitive and platform-specific directory collisions. + string agentIdentity = FoundryHostingAgent.GetSessionStorageIdentity(agent); + string agentKey = FoundryAgentSessionKeyEncoder.BuildAgentStorageKey(agentIdentity); + string sessionKey = FoundryAgentSessionKeyEncoder.BuildStorageKey( + FoundryAgentSessionKeyEncoder.BuildLogicalKey(agentIdentity, key)); + string dir = Path.Combine(this.RootDirectory, "a-" + agentKey); - string path = Path.Combine(dir, "c-" + Sanitize(conversationId) + ".json"); + string path = Path.Combine(dir, "k-" + sessionKey + ".json"); // Defense in depth: regardless of per-segment handling, the fully-resolved path must remain // under the storage root. Reject anything that escapes (CWE-22). @@ -281,127 +268,4 @@ private string GetSessionPath(AIAgent agent, string conversationId, string? user return path; } - - /// - /// Validates that is a single safe path component (CWE-22). - /// - /// - /// The value originates from caller-controlled or platform-injected fields (such as the - /// x-agent-user-id partition key). It must be treated as an untrusted single path segment: - /// path separators, drive letters, parent references and similar would otherwise let the resulting - /// directory escape the configured storage root. We deliberately do not URL-decode the value (the - /// hosting layer never decodes these ids before joining them, so forms such as %2e%2e are - /// accepted as literal directory names), and we do not "sanitize" by stripping characters because - /// that can introduce collisions between distinct ids — a non-conforming value is rejected outright. - /// - private static void ValidatePathSegment(string segment, string kind) - { - // Reject any value that is not a single safe path component. This covers POSIX/Windows - // separators, NUL bytes, drive letters, rooted paths, and all-dot segments (".", "..", "..."). - if (segment.IndexOf('/') >= 0 - || segment.IndexOf('\\') >= 0 - || segment.IndexOf('\0') >= 0 - || segment.Trim('.').Length == 0 - || Path.IsPathRooted(segment) - || !string.IsNullOrEmpty(Path.GetPathRoot(segment))) - { - throw new InvalidOperationException($"Invalid {kind}: '{segment}'."); - } - } - - private static string Sanitize(string value) - { - // Percent-encode every character that is invalid in a filename, plus '%' itself - // so the encoding is unambiguous. This is reversible and avoids the collision - // hazard of a lossy character substitution (e.g. "foo/bar" and "foo_bar" sharing - // a sanitized name). - char[] invalid = Path.GetInvalidFileNameChars(); - - int encodedLength = ComputeEncodedLength(value, invalid); - - // stackalloc is bounded so an externally-controlled length cannot crash the - // hosting process with StackOverflowException. - const int StackLimit = 512; - string sanitized; - if (encodedLength <= StackLimit) - { - Span buffer = stackalloc char[encodedLength]; - SanitizeCore(value, invalid, buffer); - sanitized = new string(buffer); - } - else - { - char[] rented = ArrayPool.Shared.Rent(encodedLength); - try - { - Span buffer = rented.AsSpan(0, encodedLength); - SanitizeCore(value, invalid, buffer); - sanitized = new string(buffer); - } - finally - { - ArrayPool.Shared.Return(rented); - } - } - - // '.' and '..' are valid filename characters but resolve to current/parent - // directory when used as a bare path component. Windows additionally strips - // trailing dots from filenames, so a segment like "..." would survive on disk - // as "" and a partial-encode like "%2E.." would survive as "%2E". Encode every - // dot in any all-dot segment so the result has no special meaning to the OS. - if (sanitized.Length > 0 && IsAllDots(sanitized)) - { - return string.Concat(Enumerable.Repeat("%2E", sanitized.Length)); - } - - return sanitized; - } - - private static int ComputeEncodedLength(string value, char[] invalid) - { - int extra = 0; - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (c == '%' || Array.IndexOf(invalid, c) >= 0) - { - extra += 2; // 1 char ('%' or invalid) becomes 3 chars ("%XX") - } - } - return value.Length + extra; - } - - private static bool IsAllDots(string value) - { - for (int i = 0; i < value.Length; i++) - { - if (value[i] != '.') - { - return false; - } - } - - return true; - } - - private static void SanitizeCore(string value, char[] invalid, Span buffer) - { - int j = 0; - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (c == '%' || Array.IndexOf(invalid, c) >= 0) - { - buffer[j++] = '%'; - buffer[j++] = HexChar((c >> 4) & 0xF); - buffer[j++] = HexChar(c & 0xF); - } - else - { - buffer[j++] = c; - } - } - } - - private static char HexChar(int n) => (char)(n < 10 ? '0' + n : 'A' + n - 10); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs new file mode 100644 index 00000000000..7c84f666db9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Encodes agent session identities for Foundry-backed and filesystem storage implementations. +/// +internal static class FoundryAgentSessionKeyEncoder +{ + private static readonly UTF8Encoding s_strictUtf8 = + new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + internal static string BuildLogicalKey(string agentIdentity, AgentSessionStoreKey key) + { + _ = Throw.IfNull(key); + + StringBuilder builder = new(); + AppendComponent(builder, 'a', Throw.IfNullOrWhitespace(agentIdentity)); + AppendComponent(builder, 's', key.SessionId); + foreach (KeyValuePair partition in key.Partitions) + { + AppendComponent(builder, 'n', partition.Key); + AppendComponent(builder, 'v', partition.Value); + } + builder.Length--; + return builder.ToString(); + } + + internal static string BuildStorageKey(string logicalKey) + { + byte[] hash; + try + { + hash = SHA256.HashData(s_strictUtf8.GetBytes(logicalKey)); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException( + "Session keys and agent identities must contain valid UTF-16 text.", + nameof(logicalKey), + exception); + } + + return $"s-{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; + } + + internal static string BuildAgentStorageKey(string agentIdentity) + { + _ = Throw.IfNullOrWhitespace(agentIdentity); + return BuildStorageKey($"a{agentIdentity.Length}:{agentIdentity}"); + } + + private static void AppendComponent(StringBuilder builder, char prefix, string value) + => builder.Append(prefix).Append(value.Length).Append(':').Append(value).Append('|'); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs index 195a7971673..ad18c128f76 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs @@ -3,8 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -28,17 +26,16 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// /// Layout. All sessions live in one state store, named unless -/// overridden, and each (agent, user, conversation) triple is one item in it. The item key is a -/// hash of an unambiguous, length-prefixed encoding of the hosted registration identity, user id, -/// and conversation id. Hashing is required because the platform limits an item key to 128 +/// overridden, and each agent and pair is one item in it. The item +/// key is a hash of an unambiguous encoding of the hosted registration identity and session key. +/// Hashing is required because the platform limits an item key to 128 /// characters. The readable encoding is stored alongside the session so an item can still be traced /// back to its partition. /// /// -/// Per-user isolation is expressed through the item key rather than through the state store's own -/// userIsolation option. That option is fixed when the store is created and resolves the -/// user from the calling identity, whereas the user id handled here arrives per request and the -/// container always calls the storage API with its own identity. +/// Logical isolation is expressed through rather than +/// through the state store's own userIsolation option. That option is fixed when the store is +/// created, while session partitions may vary per request. /// /// /// The bound state store is resolved once, on first use, and reused for the lifetime of this @@ -124,24 +121,23 @@ internal FoundryAgentSessionStore(Func public override async ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { _ = Throw.IfNull(agent); - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); _ = Throw.IfNull(session); - string agentIdentity = ResolveAgentIdentity(agent); + string agentIdentity = FoundryHostingAgent.GetSessionStorageIdentity(agent); JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); BinaryData sessionData = ToBinaryData(serialized); - string logicalKey = BuildLogicalKey(agentIdentity, conversationId, userId); + string logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey(agentIdentity, key); FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); await store.SetItemAsync( - BuildItemKey(logicalKey), + FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey), new Dictionary { [SessionField] = sessionData, @@ -153,19 +149,22 @@ await store.SetItemAsync( /// public override async ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { _ = Throw.IfNull(agent); - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); - string logicalKey = BuildLogicalKey(ResolveAgentIdentity(agent), conversationId, userId); + string logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey( + FoundryHostingAgent.GetSessionStorageIdentity(agent), + key); FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); // GetItemAsync already answers null for an item that is not there, which is exactly the // "nothing stored" result this method contracts to return. - StateStoreItem? item = await store.GetItemAsync(BuildItemKey(logicalKey), cancellationToken).ConfigureAwait(false); + StateStoreItem? item = await store.GetItemAsync( + FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey), + cancellationToken).ConfigureAwait(false); if (!FoundryStateStoreJson.TryGetField(item, SessionField, out BinaryData? sessionData)) { return null; @@ -185,62 +184,6 @@ await store.SetItemAsync( private ValueTask GetStoreAsync(CancellationToken cancellationToken) => this._binding.GetAsync(cancellationToken); - /// - /// Builds an unambiguous readable partition key from the hosted agent identity, end user, and - /// conversation. Each component carries its length so delimiters inside values cannot collide. - /// - internal static string BuildLogicalKey(string agentIdentity, string conversationId, string? userId) - { - StringBuilder builder = new(); - AppendComponent(builder, 'a', Throw.IfNullOrWhitespace(agentIdentity)); - AppendComponent(builder, 'u', string.IsNullOrWhiteSpace(userId) ? null : userId); - AppendComponent(builder, 'c', Throw.IfNullOrWhitespace(conversationId)); - builder.Length--; - return builder.ToString(); - } - - private static string ResolveAgentIdentity(AIAgent agent) - { - _ = Throw.IfNull(agent); - - if (agent.GetService() is { } hostingAgent) - { - return hostingAgent.SessionStorageIdentity; - } - - if (string.IsNullOrWhiteSpace(agent.Name)) - { - throw new InvalidOperationException( - $"Direct use of {nameof(FoundryAgentSessionStore)} requires an agent with a stable {nameof(AIAgent.Name)}. " + - "Foundry hosting supplies the keyed or default registration identity separately."); - } - - return $"name:{agent.Name}"; - } - - private static void AppendComponent(StringBuilder builder, char prefix, string? value) - { - builder.Append(prefix).Append(value?.Length ?? -1).Append(':'); - if (value is not null) - { - builder.Append(value); - } - - builder.Append('|'); - } - - /// - /// Reduces a logical key to a fixed-length item key. The platform limits an item key to 128 - /// characters, which an agent name plus a user id plus a conversation id can exceed, so the - /// logical key is hashed rather than truncated: truncation would let two different conversations - /// share a key and therefore overwrite each other's session. - /// - internal static string BuildItemKey(string logicalKey) - { - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(logicalKey)); - return $"s-{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; - } - private static BinaryData ToBinaryData(JsonElement element) => FoundryStateStoreJson.ToBinaryData(element); private static BinaryData ToJsonString(string value) => FoundryStateStoreJson.ToJsonString(value); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs index cc17a707dab..6b4f5d04f1c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -21,6 +22,37 @@ internal FoundryHostingAgent(AIAgent innerAgent, string sessionStorageIdentity) internal string SessionStorageIdentity { get; } + /// + /// Gets the session storage identity carried by a hosting wrapper, or derives one for direct use. + /// + /// The agent whose storage identity is required. + /// + /// Whether an unnamed direct agent may use its process-local . + /// + /// The resolved session storage identity. + internal static string GetSessionStorageIdentity(AIAgent agent, bool allowInstanceId = false) + { + _ = Throw.IfNull(agent); + + if (agent.GetService() is { } hostingAgent) + { + return hostingAgent.SessionStorageIdentity; + } + + if (!string.IsNullOrWhiteSpace(agent.Name)) + { + return $"name:{agent.Name}"; + } + + if (allowInstanceId) + { + return $"id:{agent.Id}"; + } + + throw new InvalidOperationException( + $"Persistent session storage requires a stable {nameof(AIAgent.Name)} or Foundry hosting registration."); + } + /// /// Resolves the stable identity used to partition session storage for the resolved agent. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs index 579240432b6..5b4df9e3da6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Text.Json; @@ -30,20 +31,33 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class InMemoryAgentSessionStore : AgentSessionStore { - private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary<(string AgentIdentity, AgentSessionStoreKey Key), JsonElement> _sessions = new(); /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { - var key = GetKey(agent, conversationId, userId); - this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(session); + + var storageKey = GetKey(agent, key); + this._sessions[storageKey] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - var key = GetKey(agent, conversationId, userId); - if (!this._sessions.TryGetValue(key, out var existingSession)) + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(key); + + if (!this._sessions.TryGetValue(GetKey(agent, key), out var existingSession)) { return null; } @@ -51,25 +65,8 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false); } - // Keyed with the same a-/u-/c- prefix scheme as FileSystemAgentSessionStore so the in-memory store - // partitions per agent and per user identically. Like FileSystemAgentSessionStore, the agent segment - // uses agent.Name (a stable identity) and is omitted when no name is set; agent.Id is intentionally - // NOT used because it is regenerated on every startup for in-memory-defined agents, which would break - // session continuity for a transient or recreated agent. The user segment is omitted when no user id - // is supplied. - private static string GetKey(AIAgent agent, string conversationId, string? userId) - { - string key = string.Empty; - if (!string.IsNullOrEmpty(agent.Name)) - { - key += $"a-{agent.Name}:"; - } - - if (!string.IsNullOrWhiteSpace(userId)) - { - key += $"u-{userId}:"; - } - - return key + $"c-{conversationId}"; - } + private static (string AgentIdentity, AgentSessionStoreKey Key) GetKey( + AIAgent agent, + AgentSessionStoreKey key) + => (FoundryHostingAgent.GetSessionStorageIdentity(agent, allowInstanceId: true), key); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index 556a0d931a3..8a40a29a410 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -32,12 +32,10 @@ public static class A2AServerServiceCollectionExtensions /// /// Trust model. The A2A contextId and taskId arrive /// from the wire and are treated as chain-resume identifiers — not as - /// authorization tokens. Both the and - /// contracts carry no principal/owner dimension by default, - /// so when a persistent store is registered any caller who knows or guesses another - /// caller's contextId or taskId can access that other caller's data. - /// Hosts that serve more than one user must compose a principal dimension into the - /// lookup key — typically by calling UseClaimsBasedAgentIsolation(...) from + /// authorization tokens. accepts an explicit user partition, + /// while has no principal or owner dimension. + /// Hosts that serve more than one user must supply both dimensions from a trusted identity, + /// typically by calling UseClaimsBasedAgentIsolation(...) from /// Microsoft.Agents.AI.Hosting.AspNetCore (or by registering a custom /// ). When an /// is registered, both the session store and the task store are automatically wrapped @@ -68,7 +66,7 @@ public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBui /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must - /// compose a principal dimension via UseClaimsBasedAgentIsolation(...) or + /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). /// public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action? configureOptions = null) @@ -94,7 +92,7 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must - /// compose a principal dimension via UseClaimsBasedAgentIsolation(...) or + /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). /// public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action? configureOptions = null) @@ -119,7 +117,7 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must - /// compose a principal dimension via UseClaimsBasedAgentIsolation(...) or + /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). /// public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null) @@ -157,7 +155,7 @@ public static IServiceCollection AddA2AServer(this IServiceCollection services, /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must - /// compose a principal dimension via UseClaimsBasedAgentIsolation(...) or + /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). /// public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action? configureOptions = null) @@ -189,7 +187,7 @@ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAge var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground; // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. - if (agentSessionStore?.GetService() is null) + if (agentSessionStore is not IsolationKeyScopedAgentSessionStore) { agentSessionStore ??= new NoopAgentSessionStore(); agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj index 3c805ee7a4d..2db2d6b6f6a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj @@ -4,6 +4,7 @@ $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.A2A preview + $(NoWarn);MAAI001 Microsoft Agent Framework Hosting A2A Provides Microsoft Agent Framework support for hosting A2A agents. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index 64568c1a72e..c768ea98748 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -83,23 +83,20 @@ public static IEndpointConventionBuilder MapAGUIServer( /// /// /// Trust model. The AG-UI RunAgentInput.ThreadId arrives - /// from the wire and is treated as a chain-resume identifier — not as an - /// authorization token. The contract carries no - /// principal/owner dimension, so when a persistent store is registered any caller - /// who knows or guesses another caller's ThreadId can resume that other - /// caller's persisted thread. Hosts that serve more than one user must compose a - /// principal dimension into the lookup key. The recommended way is to wrap the + /// from the wire and is treated as a chain-resume identifier, not as an authorization + /// token. The contract accepts a userId partition, + /// which must come from a trusted identity rather than from the wire ThreadId. + /// The recommended way to supply it is to wrap the /// keyed in /// , typically by calling /// UseClaimsBasedAgentIsolation(...) from /// Microsoft.Agents.AI.Hosting.AspNetCore (or by registering a custom /// ) and registering the store via the /// WithSessionStore(...) / WithInMemorySessionStore(...) helpers on - /// so that the wrapper is applied. When no - /// isolation provider is registered, behavior is unchanged — the bare - /// ThreadId is used as the conversation identifier, which is appropriate - /// for first-run / single-user / prototyping scenarios but unsafe for - /// multi-user hosts. + /// so that the wrapper is applied. When no isolation + /// provider is registered, userId is and all callers share + /// one partition. This is appropriate for single-user applications and prototyping, + /// but unsafe for multi-user hosts. /// /// public static IEndpointConventionBuilder MapAGUIServer( @@ -114,7 +111,7 @@ public static IEndpointConventionBuilder MapAGUIServer( // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. var isolationKeyProvider = endpoints.ServiceProvider.GetService(); - if (agentSessionStore?.GetService() is null) + if (agentSessionStore is not IsolationKeyScopedAgentSessionStore) { agentSessionStore ??= new NoopAgentSessionStore(); agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index 3a46871daad..ac9b03f75a4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -4,6 +4,7 @@ $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.AGUI.AspNetCore preview + $(NoWarn);MAAI001 $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentityAgentIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentityAgentIsolationKeyProvider.cs index 39f54a7c046..a8ffd77bd50 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentityAgentIsolationKeyProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentityAgentIsolationKeyProvider.cs @@ -34,7 +34,7 @@ namespace Microsoft.Agents.AI.Hosting; /// /// /// If the is unavailable, the user is not authenticated, or the specified claim -/// is missing, the provider returns . Consuming stores then enforce strict or +/// is missing or blank, the provider returns . Consuming stores then enforce strict or /// pass-through behavior based on their configuration. /// /// @@ -73,7 +73,7 @@ public ClaimsIdentityAgentIsolationKeyProvider( /// /// A task that represents the asynchronous operation. The task result contains the value of the /// configured claim type from the current user's identity, or if the HTTP - /// context is unavailable, the user is not authenticated, or the claim is not present. + /// context is unavailable, the user is not authenticated, or the claim is missing or blank. /// /// /// This method only reads claims from an authenticated principal: if the current request has no @@ -89,8 +89,7 @@ public ClaimsIdentityAgentIsolationKeyProvider( return new ValueTask((string?)null); } - Claim? claim = user?.Claims.FirstOrDefault(c => c.Type == this._claimType); - - return new ValueTask(claim?.Value); + string? value = user.Claims.FirstOrDefault(c => c.Type == this._claimType)?.Value; + return new ValueTask(string.IsNullOrWhiteSpace(value) ? null : value); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs index 2833ed991a4..d06116a6717 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs @@ -20,7 +20,7 @@ public static class AzureBlobHostedAgentBuilderExtensions /// The Blob container client used to store sessions. /// Optional session store configuration. /// - /// Whether to scope session IDs with the configured . + /// Whether to add an isolation partition from the configured . /// /// The supplied . public static IHostedAgentBuilder WithAzureBlobSessionStore( @@ -49,7 +49,7 @@ public static IHostedAgentBuilder WithAzureBlobSessionStore( /// Optional session store configuration. /// The dependency injection lifetime of the registered session store. /// - /// Whether to scope session IDs with the configured . + /// Whether to add an isolation partition from the configured . /// /// The supplied . public static IHostedAgentBuilder WithAzureBlobSessionStore( 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 a3192963a35..ec1ce4fa9dd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -9,6 +11,7 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting.AzureStorage; @@ -28,6 +31,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureStorage; /// default. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AzureBlobAgentSessionStore : AgentSessionStore { private const int MaxBlobNameLength = 1024; @@ -37,6 +41,7 @@ public sealed class AzureBlobAgentSessionStore : AgentSessionStore { HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" }, }; + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); private readonly BlobContainerClient _containerClient; private readonly object _containerInitializationLock = new(); @@ -60,7 +65,7 @@ public AzureBlobAgentSessionStore( AzureBlobAgentSessionStoreOptions? options = null) { this._containerClient = Throw.IfNull(containerClient); - this._agentKey = ComputeKey(Throw.IfNullOrWhitespace(agentNamespace)); + this._agentKey = ComputeAgentKey(Throw.IfNullOrWhitespace(agentNamespace)); options ??= new AzureBlobAgentSessionStoreOptions(); this._createContainerIfNotExists = options.CreateContainerIfNotExists; @@ -77,18 +82,18 @@ public AzureBlobAgentSessionStore( /// public override async ValueTask SaveSessionAsync( AIAgent agent, - string sessionStoreId, + AgentSessionStoreKey key, AgentSession session, CancellationToken cancellationToken = default) { Throw.IfNull(agent); - Throw.IfNull(sessionStoreId); + Throw.IfNull(key); 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)); + BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(key)); await blobClient.UploadAsync( BinaryData.FromString(serializedSession.GetRawText()), s_uploadOptions, @@ -96,18 +101,28 @@ await blobClient.UploadAsync( } /// - public override async ValueTask GetSessionAsync( + public override async ValueTask GetSessionAsync( AIAgent agent, - string sessionStoreId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { Throw.IfNull(agent); - Throw.IfNull(sessionStoreId); + Throw.IfNull(key); await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false); - BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId)); + return await this.TryGetSessionAsync( + agent, + this.GetBlobName(key), + cancellationToken).ConfigureAwait(false); + } + private async ValueTask TryGetSessionAsync( + AIAgent agent, + string blobName, + CancellationToken cancellationToken) + { + BlobClient blobClient = this._containerClient.GetBlobClient(blobName); try { Response response = await blobClient.DownloadContentAsync(cancellationToken).ConfigureAwait(false); @@ -116,30 +131,7 @@ public override async ValueTask GetSessionAsync( } catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound.ToString()) { - return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - } - } - - /// - 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. + return null; } } @@ -177,16 +169,31 @@ private async Task EnsureContainerExistsAsync(CancellationToken cancellationToke private async Task CreateContainerIfNotExistsAsync() => await this._containerClient.CreateIfNotExistsAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); - private string GetBlobName(string sessionStoreId) + private string GetBlobName(AgentSessionStoreKey key) { - string sessionKey = ComputeKey(sessionStoreId); - string baseName = $"v1/{this._agentKey}/{sessionKey}.json"; + string baseName = $"v2/{this._agentKey}/{ComputeSessionKey(key)}.json"; return this._blobNamePrefix is null ? baseName : $"{this._blobNamePrefix}/{baseName}"; } + private static string ComputeSessionKey(AgentSessionStoreKey key) + { + StringBuilder builder = new(); + AppendComponent(builder, 's', key.SessionId); + foreach (KeyValuePair partition in key.Partitions) + { + AppendComponent(builder, 'n', partition.Key); + AppendComponent(builder, 'v', partition.Value); + } + + return ComputeKey(builder.ToString()); + } + + private static void AppendComponent(StringBuilder builder, char prefix, string value) + => builder.Append(prefix).Append(value.Length).Append(':').Append(value).Append('|'); + private static async Task WaitWithCancellationAsync(Task task, CancellationToken cancellationToken) { if (task.IsCompleted || !cancellationToken.CanBeCanceled) @@ -205,7 +212,7 @@ private static async Task WaitWithCancellationAsync(Task task, CancellationToken private static string ComputeKey(string value) { - byte[] input = Encoding.UTF8.GetBytes(value); + byte[] input = s_strictUtf8.GetBytes(value); #if NET8_0_OR_GREATER return Convert.ToHexString(SHA256.HashData(input)); #else @@ -224,6 +231,21 @@ private static string ComputeKey(string value) #endif } + private static string ComputeAgentKey(string agentNamespace) + { + try + { + return ComputeKey(agentNamespace); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException( + "The agent namespace must contain valid UTF-16 text.", + nameof(agentNamespace), + exception); + } + } + #if !NET8_0_OR_GREATER private static char ToHexChar(int value) => (char)(value < 10 ? '0' + value : 'A' + value - 10); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj index d2652c12a7e..bd442cd24c4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj @@ -3,6 +3,9 @@ preview true + true + true + $(NoWarn);MAAI001 Microsoft Agent Framework Azure Blob Storage integration @@ -18,4 +21,5 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs index ac54968cdce..a0396feeccf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs @@ -51,11 +51,25 @@ public AIHostAgent(AIAgent innerAgent, AgentSessionStore sessionStore) /// A task that represents the asynchronous operation. The task result contains the agent session associated with the /// specified conversation. If no session exists, a new session is created and returned. public ValueTask GetOrCreateSessionAsync(string conversationId, CancellationToken cancellationToken = default) + => this.GetOrCreateSessionAsync(new AgentSessionStoreKey(conversationId), cancellationToken); + + /// + /// Gets an existing agent session for the specified storage key, or creates a new one if none exists. + /// + /// The key that identifies and partitions the session. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task whose result contains the stored or newly created agent session. + public ValueTask GetOrCreateSessionAsync( + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); MarkFeatureUsed(); - return this._sessionStore.GetSessionAsync(this.InnerAgent, conversationId, cancellationToken); + return this._sessionStore.GetOrCreateSessionAsync( + this.InnerAgent, + key, + cancellationToken); } /// @@ -68,12 +82,29 @@ public ValueTask GetOrCreateSessionAsync(string conversationId, Ca /// is null or whitespace. /// is . public ValueTask SaveSessionAsync(string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => this.SaveSessionAsync(new AgentSessionStoreKey(conversationId), session, cancellationToken); + + /// + /// Persists a session under the specified storage key. + /// + /// The key that identifies and partitions the session. + /// The session to persist. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + public ValueTask SaveSessionAsync( + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); _ = Throw.IfNull(session); MarkFeatureUsed(); - return this._sessionStore.SaveSessionAsync(this.InnerAgent, conversationId, session, cancellationToken); + return this._sessionStore.SaveSessionAsync( + this.InnerAgent, + key, + session, + cancellationToken); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs deleted file mode 100644 index 85e3985ab8b..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting; - -/// -/// Defines the contract for storing and retrieving agent conversation threads. -/// -/// -/// -/// Implementations of this interface enable persistent storage of conversation threads, -/// allowing conversations to be resumed across HTTP requests, application restarts, -/// or different service instances in hosted scenarios. -/// -/// -/// Trust model. The sessionStoreId passed to -/// and is the id under which the session is -/// stored. It typically originates from the wire (for example, an AG-UI RunAgentInput.ThreadId or an -/// A2A contextId). It is a chain-resume identifier, not an authorization -/// token, and the (agent, sessionStoreId) tuple carries no principal/owner -/// dimension. Hosts that serve more than one user from the same registered store must -/// therefore compose a principal dimension into the lookup key, otherwise any caller -/// who knows or guesses another caller's sessionStoreId can resume -/// that other caller's persisted thread. The framework provides -/// as a decorator that rewrites -/// sessionStoreId to include an isolation key resolved from an -/// (for example, the ASP.NET Core -/// ClaimsIdentityAgentIsolationKeyProvider wired up via -/// UseClaimsBasedAgentIsolation(...)). When no provider is registered, the -/// store behaves as a single-namespace persistence layer — appropriate for -/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts. -/// -/// -/// Implementer guidance. Implementations should treat -/// sessionStoreId as opaque: do not parse it, do not impose length -/// or character-set constraints on it, and do not assume it round-trips to the value -/// the caller originally supplied (decorators such as -/// may rewrite it before forwarding). -/// Be aware that any logging, telemetry, or audit sink that surfaces -/// sessionStoreId will also surface the isolation prefix when a -/// scoping decorator is in the chain. -/// -/// -public abstract class AgentSessionStore -{ - /// - /// Saves a serialized agent session to persistent storage. - /// - /// The agent that owns this session. - /// The id under which the session is stored. - /// The session to save. - /// The to monitor for cancellation requests. - /// A task that represents the asynchronous save operation. - public abstract ValueTask SaveSessionAsync( - AIAgent agent, - string sessionStoreId, - AgentSession session, - CancellationToken cancellationToken = default); - - /// - /// Retrieves a serialized agent session from persistent storage. - /// - /// The agent that owns this session. - /// The id under which the session is stored. - /// The to monitor for cancellation requests. - /// - /// A task that represents the asynchronous retrieval operation. The task result contains the - /// restored , or a newly created session when nothing is stored for the id. - /// - /// - /// Isolation. Each call must return an independent - /// instance. Callers may mutate the returned session, and may run several concurrent branches from the - /// same (for example forking from an OpenAI Responses - /// previous_response_id), without those branches observing one another's mutations or altering the - /// stored state. The in-box stores satisfy this by returning a fresh instance rehydrated from a serialized - /// snapshot on every call; implementations that cache a live must return an - /// independent copy (for example by round-tripping through - /// - /// and ) - /// rather than handing back the shared instance. - /// - public abstract ValueTask GetSessionAsync( - AIAgent agent, - string sessionStoreId, - CancellationToken cancellationToken = default); - - /// - /// Deletes a stored agent session, if present. - /// - /// The agent that owns this session. - /// The id under which the session is stored. - /// The to monitor for cancellation requests. - /// A task that represents the asynchronous delete operation. - /// - /// Implementations that support removal delete the session and treat a missing session as a no-op. - /// Implementations that genuinely cannot support deletion should throw . - /// - /// The store does not support deletion. - public abstract ValueTask DeleteSessionAsync( - AIAgent agent, - string sessionStoreId, - CancellationToken cancellationToken = default); - - /// Asks the for an object of the specified type . - /// The type of object being requested. - /// An optional key that can be used to help identify the target service. - /// The found object, otherwise . - /// is . - /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the , - /// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains - /// to verify that specific store implementations are present. - /// - public virtual object? GetService(Type serviceType, object? serviceKey = null) - { - _ = Throw.IfNull(serviceType); - - return serviceKey is null && serviceType.IsInstanceOfType(this) - ? this - : null; - } - - /// Asks the for an object of type . - /// The type of the object to be retrieved. - /// An optional key that can be used to help identify the target service. - /// The found object, otherwise . - /// - /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , - /// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains - /// to verify that specific store implementations are present. - /// - public TService? GetService(object? serviceKey = null) - => this.GetService(typeof(TService), serviceKey) is TService service ? service : default; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index a13eab90384..f190d0a65b3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -18,7 +18,7 @@ public static class HostedAgentBuilderExtensions /// /// The host agent builder to configure with the in-memory session store. /// When , wraps the session store with an - /// to provide isolation-key-based scoping for sessions. Defaults to . + /// that adds a partition from . Defaults to . /// The same instance, configured to use an in-memory session store. public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true) => builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation); @@ -30,7 +30,7 @@ public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuil /// The host agent builder to configure with the session store. Cannot be null. /// The agent session store instance to register. Cannot be null. /// When , wraps the session store with an - /// to provide isolation-key-based scoping for sessions. Defaults to . + /// that adds a partition from . Defaults to . /// The same host agent builder instance, allowing for method chaining. public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true) => builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation); @@ -44,7 +44,7 @@ public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder buil /// The DI service lifetime for the session store registration. Defaults to /// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime. /// When , wraps the session store with an - /// to provide isolation-key-based scoping for sessions. Defaults to . + /// that adds a partition from . Defaults to . /// The same host agent builder instance, enabling further configuration. public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true) { @@ -57,7 +57,7 @@ public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder buil AgentSessionStore store = createAgentSessionStore(sp, keyString) ?? throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'."); - if (withIsolation && store.GetService() is null) + if (withIsolation && store is not IsolationKeyScopedAgentSessionStore) { var isolationKeyProvider = sp.GetService(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index 55935530f50..c6772d5bba3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -1,18 +1,23 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; /// -/// A delegating that scopes session keys by an isolation key -/// provided by an , ensuring that sessions are isolated -/// per logical partition (e.g., user, tenant, or composite key). +/// A delegating that adds an isolation partition from an +/// . /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore { + private const string IsolationPartitionName = "isolation"; + private readonly AgentIsolationKeyProvider? _keyProvider; private readonly bool _strict; @@ -54,63 +59,60 @@ public IsolationKeyScopedAgentSessionStore( ? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false) : null; - if (this._strict && key == null) + if (string.IsNullOrWhiteSpace(key)) { - throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider."); + if (this._strict) + { + throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider."); + } + + return null; } return key; } /// - /// Escapes special characters in the isolation key to ensure unambiguous scoped session store IDs. - /// - /// The raw isolation key. - /// The escaped isolation key. - /// - /// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:). - /// This ensures the scoped session store ID format {key}::{sessionStoreId} can be parsed correctly. - /// - private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:"); - - /// - /// Constructs a scoped session store ID by prefixing the bare session store ID with the escaped isolation key. + /// Adds the isolation value from the current hosting context to the session key. /// - /// The original session store ID. - /// The cancellation token. - /// - /// The scoped session store ID in the format {escapedKey}::{sessionStoreId}, or the bare session store ID - /// if no isolation key is available and non-strict mode is enabled. - /// - private async ValueTask GetScopedSessionStoreIdAsync(string bareSessionStoreId, CancellationToken cancellationToken) + private async ValueTask GetScopedKeyAsync( + AgentSessionStoreKey key, + CancellationToken cancellationToken) { - string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); - if (key == null) - { - return bareSessionStoreId; - } + _ = Throw.IfNull(key); - return $"{EscapeIsolationKey(key)}::{bareSessionStoreId}"; + string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + return isolationKey is null ? key : key.WithPartition(IsolationPartitionName, isolationKey); } /// - public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - return await this.InnerStore.GetSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false); + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetSessionAsync(agent, scopedKey, cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + public override async ValueTask GetOrCreateSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - await this.InnerStore.SaveSessionAsync(agent, scopedSessionStoreId, session, cancellationToken).ConfigureAwait(false); + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetOrCreateSessionAsync(agent, scopedKey, cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { - string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - await this.InnerStore.DeleteSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false); + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + await this.InnerStore.SaveSessionAsync(agent, scopedKey, session, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs index 773ee96206e..662225ee5ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs @@ -16,9 +16,9 @@ public class IsolationKeyScopedAgentSessionStoreOptions /// when returns . /// /// - /// If , the conversation ID is passed through unmodified when the isolation key is absent, - /// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios - /// or mixed environments where not all requests have isolation keys. + /// If , the original is passed through without + /// an isolation partition when the provider returns . This mode is suitable + /// for development scenarios or environments where not all requests have isolation keys. /// /// public bool Strict { get; set; } = true; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs index 448d20f473f..084c9eaabce 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -25,50 +28,47 @@ namespace Microsoft.Agents.AI.Hosting; /// such as Redis, SQL Server, or Azure Cosmos DB. /// /// -/// Multi-user warning. This store keys threads by -/// (agent.Id, sessionStoreId) only — it has no principal/owner dimension. When -/// the session store id originates from the wire (for example, an AG-UI -/// RunAgentInput.ThreadId or an A2A contextId), any caller who knows -/// or guesses another caller's identifier can resume that other caller's persisted -/// thread. Multi-user hosts must wrap this store in +/// Multi-user warning. This store partitions sessions by the userId supplied +/// to and . +/// Multi-user hosts must supply a trusted user identifier, either directly or by wrapping this store in /// (typically by calling /// UseClaimsBasedAgentIsolation(...) from /// Microsoft.Agents.AI.Hosting.AspNetCore or by registering a custom -/// ) so that the conversation namespace is -/// scoped per principal. See the trust-model remarks on -/// for the full background. +/// ). Passing uses a shared, unscoped +/// partition that is only appropriate for single-user applications and local development. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class InMemoryAgentSessionStore : AgentSessionStore { - private readonly ConcurrentDictionary _threads = new(); + private readonly ConcurrentDictionary<(string AgentId, AgentSessionStoreKey Key), JsonElement> _sessions = new(); /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { - var key = GetKey(sessionStoreId, agent.Id); - this._threads[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); - } - - /// - public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - { - var key = GetKey(sessionStoreId, agent.Id); - JsonElement? sessionContent = this._threads.TryGetValue(key, out var existingSession) ? existingSession : null; + _ = Throw.IfNull(agent); + _ = Throw.IfNull(key); + _ = Throw.IfNull(session); - return sessionContent switch - { - null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false), - _ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false), - }; + var storageKey = (agent.Id, key); + this._sessions[storageKey] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - this._threads.TryRemove(GetKey(sessionStoreId, agent.Id), out _); - return default; - } + _ = Throw.IfNull(agent); + _ = Throw.IfNull(key); - private static string GetKey(string sessionStoreId, string agentId) => $"{agentId}:{sessionStoreId}"; + return this._sessions.TryGetValue((agent.Id, key), out JsonElement existingSession) + ? await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false) + : null; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj index 70c690bfdf5..abbba8eb92e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj @@ -2,11 +2,14 @@ preview + $(NoWarn);MAAI001 true + true true + true true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs index a156285a856..8d847b54c70 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs @@ -1,31 +1,35 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Hosting; /// /// This store implementation does not have any store under the hood and therefore does not store sessions. -/// always returns a new session. +/// always returns . /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class NoopAgentSessionStore : AgentSessionStore { /// - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { return default; } /// - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - return agent.CreateSessionAsync(cancellationToken); - } - - /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - { - return default; + return new((AgentSession?)null); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs similarity index 61% rename from dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs rename to dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs index f340cec8af5..023415c3250 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs @@ -1,11 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.Hosting; +namespace Microsoft.Agents.AI; /// /// Provides an abstract base class for agent session stores that delegate operations to an inner store @@ -23,6 +25,7 @@ namespace Microsoft.Agents.AI.Hosting; /// interface. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public abstract class DelegatingAgentSessionStore : AgentSessionStore { /// @@ -53,33 +56,17 @@ protected DelegatingAgentSessionStore(AgentSessionStore innerStore) protected AgentSessionStore InnerStore { get; } /// - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => this.InnerStore.GetSessionAsync(agent, sessionStoreId, cancellationToken); + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + => this.InnerStore.GetSessionAsync(agent, key, cancellationToken); /// - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => this.InnerStore.SaveSessionAsync(agent, sessionStoreId, session, cancellationToken); - - /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => this.InnerStore.DeleteSessionAsync(agent, sessionStoreId, cancellationToken); - - /// - /// - /// This implementation first checks if this instance satisfies the service request. - /// If not, it chains the request to the inner store, allowing services to be retrieved - /// from any store in the delegation chain. - /// - public override object? GetService(Type serviceType, object? serviceKey = null) - { - // First, check if this instance satisfies the request - object? service = base.GetService(serviceType, serviceKey); - if (service is not null) - { - return service; - } - - // Chain to the inner store - return this.InnerStore.GetService(serviceType, serviceKey); - } + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) + => this.InnerStore.SaveSessionAsync(agent, key, session, 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 cc15ea45814..515a8cd92e7 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 @@ -1,3 +1,8 @@ #nullable enable +[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.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 [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void 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 cc15ea45814..515a8cd92e7 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,3 +1,8 @@ #nullable enable +[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.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 [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void 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 cc15ea45814..515a8cd92e7 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 @@ -1,3 +1,8 @@ #nullable enable +[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.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 [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void 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 cc15ea45814..515a8cd92e7 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 @@ -1,3 +1,8 @@ #nullable enable +[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.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 [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void 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 cc15ea45814..515a8cd92e7 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 @@ -1,3 +1,8 @@ #nullable enable +[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.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 [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs new file mode 100644 index 00000000000..bfd014a8445 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class AgentSessionStoreKeyTests +{ + [Fact] + public void Constructor_CopiesAndSortsPartitions() + { + // Arrange + var partitions = new Dictionary + { + ["user"] = "user-1", + ["tenant"] = "tenant-1", + }; + + // Act + var key = new AgentSessionStoreKey("session-1", partitions); + partitions["user"] = "changed"; + + // Assert + Assert.Equal("session-1", key.SessionId); + Assert.Equal(["tenant", "user"], key.Partitions.Keys); + Assert.Equal("user-1", key.Partitions["user"]); + } + + [Fact] + public void Equality_IgnoresPartitionInsertionOrder() + { + // Arrange + var first = new AgentSessionStoreKey( + "session-1", + new Dictionary + { + ["tenant"] = "tenant-1", + ["user"] = "user-1", + }); + var second = new AgentSessionStoreKey( + "session-1", + new Dictionary + { + ["user"] = "user-1", + ["tenant"] = "tenant-1", + }); + + // Act and assert + Assert.Equal(first, second); + Assert.Equal(first.GetHashCode(), second.GetHashCode()); + } + + [Fact] + public void Equality_DistinguishesPartitionNamesValuesAndMissingPartitions() + { + // Arrange + var unpartitioned = new AgentSessionStoreKey("tenant::session"); + var tenantPartition = new AgentSessionStoreKey("session").WithPartition("tenant", "tenant"); + var userPartition = new AgentSessionStoreKey("session").WithPartition("user", "tenant"); + + // Act and assert + Assert.NotEqual(unpartitioned, tenantPartition); + Assert.NotEqual(tenantPartition, userPartition); + } + + [Fact] + public void WithPartition_ReturnsNewKeyAndPreservesOriginal() + { + // Arrange + var original = new AgentSessionStoreKey("session-1"); + + // Act + AgentSessionStoreKey partitioned = original.WithPartition("tenant", "tenant-1"); + + // Assert + Assert.Empty(original.Partitions); + Assert.Equal("tenant-1", partitioned.Partitions["tenant"]); + } + + [Fact] + public void WithPartition_SameValue_ReturnsSameInstance() + { + // Arrange + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); + + // Act + AgentSessionStoreKey result = key.WithPartition("tenant", "tenant-1"); + + // Assert + Assert.Same(key, result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_BlankSessionId_Throws(string sessionId) + { + // Act and assert + Assert.Throws(() => new AgentSessionStoreKey(sessionId)); + } + + [Theory] + [InlineData("", "value")] + [InlineData(" ", "value")] + [InlineData("name", "")] + [InlineData("name", " ")] + public void Constructor_BlankPartition_Throws(string name, string value) + { + // Act and assert + Assert.Throws( + () => new AgentSessionStoreKey( + "session-1", + new Dictionary { [name] = value })); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs new file mode 100644 index 00000000000..6c046dadad6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class AgentSessionStoreTests +{ + [Fact] + public async Task GetOrCreateSessionAsync_StoredSession_ReturnsStoredSessionAsync() + { + // Arrange + var storedSession = new TestAgentSession(); + var store = new TestAgentSessionStore(storedSession); + var agent = new Mock(); + var key = new AgentSessionStoreKey("conversation-1").WithPartition("user", "user-1"); + + // Act + AgentSession session = await store.GetOrCreateSessionAsync(agent.Object, key); + + // Assert + Assert.Same(storedSession, session); + Assert.Same(key, store.LastKey); + agent.Protected().Verify( + "CreateSessionCoreAsync", + Times.Never(), + ItExpr.IsAny()); + } + + [Fact] + public async Task GetOrCreateSessionAsync_MissingSession_CreatesSessionAsync() + { + // Arrange + var createdSession = new TestAgentSession(); + var store = new TestAgentSessionStore(session: null); + var agent = new Mock(); + var key = new AgentSessionStoreKey("conversation-1"); + agent.Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(createdSession); + + // Act + AgentSession session = await store.GetOrCreateSessionAsync(agent.Object, key); + + // Assert + Assert.Same(createdSession, session); + agent.Protected().Verify( + "CreateSessionCoreAsync", + Times.Once(), + ItExpr.IsAny()); + } + + [Fact] + public async Task GetOrCreateSessionAsync_NullAgent_ThrowsAsync() + { + // Arrange + var store = new TestAgentSessionStore(session: null); + + // Act and assert + await Assert.ThrowsAsync( + () => store.GetOrCreateSessionAsync(null!, new AgentSessionStoreKey("conversation-1")).AsTask()); + } + + private sealed class TestAgentSessionStore(AgentSession? session) : AgentSessionStore + { + public AgentSessionStoreKey? LastKey { get; private set; } + + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + this.LastKey = key; + return new(session); + } + + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + 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 6b2567244ef..7f46da4a85f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs @@ -426,7 +426,11 @@ private sealed class ThrowOnceSessionStore : AgentSessionStore public int SaveAttempts => this._saveAttempts; - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { var attempt = Interlocked.Increment(ref this._saveAttempts); if (attempt == 1) @@ -437,7 +441,10 @@ public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, return default; } - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) => + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } @@ -449,9 +456,8 @@ private sealed class CountingSessionStore : AgentSessionStore public override ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { Interlocked.Increment(ref this._saveAttempts); @@ -460,8 +466,7 @@ public override ValueTask SaveSessionAsync( public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => new((AgentSession?)null); } @@ -470,16 +475,14 @@ private sealed class AlwaysLoadedSessionStore : AgentSessionStore { public override ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) => default; public override async ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs index 2b7ae5b0061..fe0014cc1a8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -1391,7 +1391,12 @@ private static ResponseContext NewContextServing(string responseId, IReadOnlyLis private static async Task SerializedSessionOfAsync(AIAgent agent, InMemoryAgentSessionStore store, string responseId) { var sessionKey = HostedConversationKey.Resolve(conversationId: null, previousResponseId: null, responseId); - var session = await store.GetSessionAsync(agent, sessionKey!, FakeHostedSessionIsolationKeyProvider.DefaultUserId, CancellationToken.None); + AIAgent storageAgent = new FoundryHostingAgent( + agent, + FoundryHostingAgent.ResolveSessionStorageIdentity(agent, registrationKey: null, defaultAgent: agent)); + var storageKey = new AgentSessionStoreKey(sessionKey!) + .WithPartition("user", FakeHostedSessionIsolationKeyProvider.DefaultUserId); + var session = await store.GetSessionAsync(storageAgent, storageKey, CancellationToken.None); // The handler persists the session at the end of every turn, so a missing one means the turn did // not get that far and the assertions below would otherwise pass without proving anything. @@ -1792,11 +1797,10 @@ public async Task CreateAsync_AfterStreamCompletes_DoesNotLeakCallIdToCallerCont // These drive the hosted-agent handler (the in-process "hosted instance") against a REAL // FileSystemAgentSessionStore and the REAL PlatformHostedSessionIsolationKeyProvider (no fake), so the // user id is genuinely captured from the request's x-agent-user-id (ResponseContext.PlatformContext). - // They assert the on-disk layout {root}/a-{agent}/u-{userId}/c-{conv}.json for combinations of agent - // name and user. + // They assert distinct stable-key files for combinations of agent name and user. [Fact] - public async Task CreateAsync_MultipleUsersSameAgent_WritePerUserDirectoriesAsync() + public async Task CreateAsync_MultipleUsersSameAgent_WriteDistinctPartitionedFilesAsync() { var root = NewIsolationTempRoot(); try @@ -1811,10 +1815,11 @@ public async Task CreateAsync_MultipleUsersSameAgent_WritePerUserDirectoriesAsyn var (bobReq, bobCtx) = BuildUserRequest("concierge", "trip", userId: "bob"); await DrainEventsAsync(handler.CreateAsync(bobReq, bobCtx.Object, CancellationToken.None)); - // Assert: each user's session is persisted under its own u-{userId} directory beneath the - // shared a-{agent} directory; neither can reach the other's path. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-alice", "c-trip.json"))); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-bob", "c-trip.json"))); + // Assert + Assert.Equal(2, Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories).Length); } finally { @@ -1840,8 +1845,11 @@ public async Task CreateAsync_MultipleAgentsSameUser_WritePerAgentDirectoriesAsy // Assert: each agent buckets the user's session under its own a-{agent} directory, so two // agents in the same container cannot collide on a shared conversation id. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-alice", "c-trip.json"))); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-scheduler", "u-alice", "c-trip.json"))); + Assert.Equal(2, Directory.GetDirectories(store.RootDirectory).Length); + Assert.Equal(2, Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories).Length); } finally { @@ -1873,9 +1881,11 @@ public async Task CreateAsync_SecondUserSameConversation_GetsFreshSessionNoLeakA // (Alice turn 1, Bob turn 1) and one restore (Alice turn 2). Assert.Equal(2, agent.CreateCount); Assert.Equal(1, agent.DeserializeCount); - // And the files live in distinct per-user directories. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-alice", "c-trip.json"))); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-bob", "c-trip.json"))); + // And the users produce distinct partitioned keys. + Assert.Equal(2, Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories).Length); } finally { @@ -1892,7 +1902,7 @@ public async Task CreateAsync_NoUserIdCaptured_NotHosted_SucceedsUnscopedAsync() // Arrange: a non-hosted (local) request whose x-agent-user-id was not captured (PlatformContext // is null). Under unit tests FoundryEnvironment.IsHosted is false, so the container is treated as // local: per-user isolation is simply not triggered and the request succeeds instead of 500ing. - // The session is persisted without a u-{userId} segment (unscoped). The hosted-but-missing-user + // The session key contains no user partition. The hosted-but-missing-user // branch (which still rejects) cannot be unit-tested because FoundryEnvironment.IsHosted is a // process-cached static; it is exercised by the investigation repro app's "hosted" scenario. var store = new FileSystemAgentSessionStore(root); @@ -1902,10 +1912,11 @@ public async Task CreateAsync_NoUserIdCaptured_NotHosted_SucceedsUnscopedAsync() // Act: the request drains without throwing. await DrainEventsAsync(handler.CreateAsync(req, ctx.Object, CancellationToken.None)); - // Assert: the session is written under the agent bucket with NO per-user (u-*) segment. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "c-trip.json"))); - var agentDir = Path.Combine(store.RootDirectory, "a-concierge"); - Assert.Empty(Directory.GetDirectories(agentDir, "u-*")); + // Assert: the session is written under the agent bucket using an unpartitioned key. + Assert.Single(Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories)); } finally { 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 01552edcd15..589f6b0fe86 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs @@ -12,12 +12,8 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; public sealed class FileSystemAgentSessionStoreTests : IDisposable { - private readonly string _root; - - public FileSystemAgentSessionStoreTests() - { - this._root = Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N")); - } + private readonly string _root = + Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N")); public void Dispose() { @@ -30,7 +26,7 @@ public void Dispose() } catch { - // best-effort cleanup + // Best-effort cleanup. } } @@ -55,11 +51,12 @@ public async Task GetSessionAsync_NoFileOnDisk_ReturnsNullAsync() var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent(); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + AgentSession? session = await store.GetSessionAsync(agent, new AgentSessionStoreKey("session-1")); Assert.Null(session); Assert.Equal(0, agent.CreateCalls); Assert.Equal(0, agent.DeserializeCalls); + Assert.False(Directory.Exists(this._root)); } [Fact] @@ -68,7 +65,7 @@ public async Task GetOrCreateSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAg var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent(); - var session = await store.GetOrCreateSessionAsync(agent, "conv-1", userId: null); + AgentSession session = await store.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey("session-1")); Assert.NotNull(session); Assert.Equal(1, agent.CreateCalls); @@ -79,29 +76,28 @@ public async Task GetOrCreateSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAg public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsNullAsync() { var store = new FileSystemAgentSessionStore(this._root); - Directory.CreateDirectory(store.RootDirectory); - File.WriteAllText(Path.Combine(store.RootDirectory, "conv-empty.json"), string.Empty); - + var key = new AgentSessionStoreKey("empty"); var agent = new TestAgent(); - var session = await store.GetSessionAsync(agent, "conv-empty", userId: null); + string agentDirectory = AgentDirectory(store, "name:test-agent"); + Directory.CreateDirectory(agentDirectory); + File.WriteAllText(SessionPath(store, "name:test-agent", key), string.Empty); + + AgentSession? session = await store.GetSessionAsync(agent, key); Assert.Null(session); - Assert.Equal(0, agent.CreateCalls); Assert.Equal(0, agent.DeserializeCalls); } [Fact] - public async Task SaveSessionAsync_CreatesRootDirectoryIfMissingAsync() + public async Task SaveSessionAsync_CreatesRootDirectoryAndStableKeyFileAsync() { var nested = Path.Combine(this._root, "nested", "deeper"); var store = new FileSystemAgentSessionStore(nested); - Assert.False(Directory.Exists(nested)); + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); - var agent = new TestAgent("{\"workflow\":\"x\"}"); - await store.SaveSessionAsync(agent, "conv-2", NewSession(), userId: null); + await store.SaveSessionAsync(new TestAgent("{\"workflow\":\"x\"}"), key, NewSession()); - Assert.True(Directory.Exists(nested)); - Assert.True(File.Exists(Path.Combine(nested, "c-conv-2.json"))); + Assert.True(File.Exists(SessionPath(store, "name:test-agent", key))); } [Fact] @@ -109,100 +105,92 @@ public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsViaAgentSeriali { var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent("{\"foo\":42}"); + var key = new AgentSessionStoreKey("round-trip").WithPartition("tenant", "tenant-1"); - await store.SaveSessionAsync(agent, "round-trip", NewSession(), userId: null); - await store.GetSessionAsync(agent, "round-trip", userId: null); + await store.SaveSessionAsync(agent, key, NewSession()); + await store.GetSessionAsync(agent, key); Assert.Equal(1, agent.SerializeCalls); Assert.Equal(1, agent.DeserializeCalls); - Assert.NotNull(agent.LastDeserialized); - Assert.Equal(JsonValueKind.Object, agent.LastDeserialized!.Value.ValueKind); Assert.Equal(42, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32()); } [Fact] - public async Task SaveSessionAsync_TwoAgentsSameConversationId_DoNotCollideAsync() + public async Task SaveSessionAsync_TwoAgentsSameKey_DoNotCollideAsync() { var store = new FileSystemAgentSessionStore(this._root); + var key = new AgentSessionStoreKey("shared"); var agentA = new TestAgent("{\"who\":\"a\"}", name: "AgentA"); var agentB = new TestAgent("{\"who\":\"b\"}", name: "AgentB"); - await store.SaveSessionAsync(agentA, "shared-conv", NewSession(), userId: null); - await store.SaveSessionAsync(agentB, "shared-conv", NewSession(), userId: null); + await store.SaveSessionAsync(agentA, key, NewSession()); + await store.SaveSessionAsync(agentB, key, NewSession()); - // Agents with distinct Names get distinct subdirectories so neither overwrites the other. - var pathA = Path.Combine(store.RootDirectory, "a-AgentA", "c-shared-conv.json"); - var pathB = Path.Combine(store.RootDirectory, "a-AgentB", "c-shared-conv.json"); - Assert.True(File.Exists(pathA)); - Assert.True(File.Exists(pathB)); + string pathA = SessionPath(store, "name:AgentA", key); + string pathB = SessionPath(store, "name:AgentB", key); Assert.Contains("\"a\"", File.ReadAllText(pathA), StringComparison.Ordinal); Assert.Contains("\"b\"", File.ReadAllText(pathB), StringComparison.Ordinal); } [Fact] - public async Task SaveSessionAsync_LongConversationId_DoesNotStackOverflowAsync() + public async Task SaveSessionAsync_ArbitraryIdentifiersDoNotBecomePathSegmentsAsync() { - // Keep the value < typical OS file-name limits (~255 chars) so the file write - // succeeds, but long enough to force Sanitize past its small-input fast path. var store = new FileSystemAgentSessionStore(this._root); - var conversationId = new string('a', 200); - var agent = new TestAgent(); + var key = new AgentSessionStoreKey("../../session\0") + .WithPartition("../tenant", "/rooted/value"); - await store.SaveSessionAsync(agent, conversationId, NewSession(), userId: null); + await store.SaveSessionAsync(new TestAgent(), key, NewSession()); - var files = Directory.GetFiles(store.RootDirectory, "*.json"); - Assert.Single(files); + string file = Assert.Single(Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories)); + Assert.Equal(Path.GetFileName(SessionPath(store, "name:test-agent", key)), Path.GetFileName(file)); + Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, Path.GetFullPath(file), StringComparison.Ordinal); } [Fact] - public async Task SaveSessionAsync_SanitizesInvalidPathCharactersAsync() + public async Task SaveSessionAsync_DifferentPartitionsProduceDistinctFilesAsync() { 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"); - // Pick an invalid filename char for the current OS. The set differs by platform - // (e.g. '?' is invalid on Windows but not on Linux), so we must select dynamically. - var invalidChars = Path.GetInvalidFileNameChars(); - Assert.NotEmpty(invalidChars); - char invalid = invalidChars[0]; - // Avoid NUL specifically because some shells/loggers handle it oddly; prefer - // the next character if available. - if (invalid == '\0' && invalidChars.Length > 1) - { - invalid = invalidChars[1]; - } + await store.SaveSessionAsync(agent, aliceKey, NewSession()); + await store.SaveSessionAsync(agent, bobKey, NewSession()); - var conversationId = $"id-with{invalid}invalid-chars"; + Assert.Equal(2, Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories).Length); + } - await store.SaveSessionAsync(agent, conversationId, NewSession(), userId: null); + [Fact] + public async Task GetSessionAsync_DifferentPartition_DoesNotReadStoredSessionAsync() + { + var store = new FileSystemAgentSessionStore(this._root); + var agent = new TestAgent("{\"secret\":\"alice-only\"}"); + var aliceKey = new AgentSessionStoreKey("shared").WithPartition("user", "alice"); + var bobKey = new AgentSessionStoreKey("shared").WithPartition("user", "bob"); + await store.SaveSessionAsync(agent, aliceKey, NewSession()); + + AgentSession? bobSession = await store.GetSessionAsync(agent, bobKey); - var files = Directory.GetFiles(store.RootDirectory, "*.json"); - Assert.Single(files); - var fileName = Path.GetFileName(files[0]); - Assert.DoesNotContain(invalid.ToString(), fileName, StringComparison.Ordinal); - Assert.Contains("id-with", fileName, StringComparison.Ordinal); - Assert.Contains("invalid-chars", fileName, StringComparison.Ordinal); + Assert.Null(bobSession); + Assert.Equal(0, agent.DeserializeCalls); } [Fact] - public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollideOnTempFileAsync() + public async Task SaveSessionAsync_ConcurrentSavesOnSameKey_DoNotCollideOnTempFileAsync() { var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent("{\"x\":1}"); - - // Fan out N concurrent saves; with a fixed temp filename ("path.tmp") this would - // race on FileMode.Create / Move. Verify they all complete successfully. + var key = new AgentSessionStoreKey("concurrent"); var tasks = new List(); - for (int i = 0; i < 16; i++) + for (int index = 0; index < 16; index++) { - tasks.Add(store.SaveSessionAsync(agent, "concurrent", NewSession(), userId: null).AsTask()); + tasks.Add(store.SaveSessionAsync(agent, key, NewSession()).AsTask()); } await Task.WhenAll(tasks); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "c-concurrent.json"))); - var leftoverTempFiles = Directory.GetFiles(store.RootDirectory, "*.tmp"); - Assert.Empty(leftoverTempFiles); + Assert.True(File.Exists(SessionPath(store, "name:test-agent", key))); + Assert.Empty(Directory.GetFiles(store.RootDirectory, "*.tmp", SearchOption.AllDirectories)); } [Theory] @@ -212,272 +200,128 @@ public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollid public async Task SaveSessionAsync_AgentNameIsDotSegment_DoesNotEscapeRootAsync(string agentName) { var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: agentName); - await store.SaveSessionAsync(agent, "conv-dots", NewSession(), userId: null); + await store.SaveSessionAsync( + new TestAgent(name: agentName), + new AgentSessionStoreKey("session-1"), + NewSession()); - // The session file must land inside RootDirectory, not in (or above) it as a sibling. - var allFiles = Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories); - Assert.Single(allFiles); - var fullPath = Path.GetFullPath(allFiles[0]); + string fullPath = Path.GetFullPath(Assert.Single( + Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories))); Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, fullPath, StringComparison.Ordinal); - - // The bucket directory name must not be a navigable dot-segment. After - // percent-encoding every dot in an all-dot segment, names like ".", "..", and - // "..." become "%2E", "%2E%2E", "%2E%2E%2E" — distinct, OS-neutral filenames. - var bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!); - Assert.NotEmpty(bucketName); - Assert.NotEqual(".", bucketName); - Assert.NotEqual("..", bucketName); - Assert.DoesNotContain(bucketName, c => c == '.'); + string bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!); + Assert.DoesNotContain(bucketName, value => value == '.'); } [Fact] - public async Task SaveSessionAsync_DistinctNamesWithInvalidChars_ProduceDistinctFilesAsync() + public async Task SaveSessionAsync_DistinctAgentNamesWithInvalidCharacters_DoNotCollideAsync() { - // Percent-encoding must keep otherwise-colliding inputs distinct: under the - // earlier underscore-substitution scheme, "foo/bar" and "foo_bar" both sanitized - // to "foo_bar" and would have shared a session bucket on disk. var store = new FileSystemAgentSessionStore(this._root); - var agentSlash = new TestAgent(name: "foo/bar"); - var agentUnderscore = new TestAgent(name: "foo_bar"); + var key = new AgentSessionStoreKey("session-1"); - await store.SaveSessionAsync(agentSlash, "conv-1", NewSession(), userId: null); - await store.SaveSessionAsync(agentUnderscore, "conv-1", NewSession(), userId: null); + await store.SaveSessionAsync(new TestAgent(name: "foo/bar"), key, NewSession()); + await store.SaveSessionAsync(new TestAgent(name: "foo_bar"), key, NewSession()); - var bucketDirs = Directory.GetDirectories(store.RootDirectory); - Assert.Equal(2, bucketDirs.Length); + Assert.Equal(2, Directory.GetDirectories(store.RootDirectory).Length); } [Fact] - public async Task GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync() + public async Task SaveSessionAsync_UnnamedDirectAgent_ThrowsAsync() { - // Read operations must not have side effects on the file system. var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "agent-with-bucket"); - var session = await store.GetSessionAsync(agent, "missing-id", userId: null); + await Assert.ThrowsAsync( + () => store.SaveSessionAsync( + new TestAgent(name: null), + new AgentSessionStoreKey("session-1"), + NewSession()).AsTask()); + } - Assert.Null(session); - Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory."); + [Fact] + public async Task SaveSessionAsync_NonWritableDirectory_ThrowsClearActionableIOExceptionAsync() + { + Directory.CreateDirectory(this._root); + string blockingFile = Path.Combine(this._root, "blocking-file"); + File.WriteAllText(blockingFile, "x"); + var store = new FileSystemAgentSessionStore(Path.Combine(blockingFile, ".checkpoints")); + + IOException exception = await Assert.ThrowsAsync( + () => store.SaveSessionAsync( + new TestAgent(), + new AgentSessionStoreKey("session-1"), + NewSession()).AsTask()); + + Assert.Contains("could not be created or written to", exception.Message, StringComparison.Ordinal); + Assert.Contains(FileSystemAgentSessionStore.SessionDataDirectoryEnvironmentVariable, exception.Message, StringComparison.Ordinal); + Assert.Contains(FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, exception.Message, StringComparison.Ordinal); + Assert.NotNull(exception.InnerException); } [Fact] public void ResolveDefaultRootDirectory_Hosted_RootsUnderHome() { - // Arrange / Act - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( + string root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( isHosted: true, homeDirectory: "/home/session", currentDirectory: "/some/cwd"); - // Assert - Assert.Equal( - Path.Combine("/home/session", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); + Assert.Equal(Path.Combine("/home/session", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), root); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void ResolveDefaultRootDirectory_HostedWithoutHome_UsesDefaultSessionDataDirectory(string? home) + [InlineData("/")] + public void ResolveDefaultRootDirectory_HostedWithUnusableHome_UsesDefault(string? home) { - // Arrange / Act - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( + string root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( isHosted: true, homeDirectory: home, currentDirectory: "/some/cwd"); - // Assert: falls back to the spec default ("/home/session"), never the filesystem root. Assert.Equal( Path.Combine( FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, FileSystemAgentSessionStore.LocalCheckpointDirectoryName), root); - Assert.NotEqual("/.checkpoints", root); } [Fact] public void ResolveDefaultRootDirectory_NotHosted_UsesCurrentDirectory() { - // Arrange / Act - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( + string root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( isHosted: false, homeDirectory: "/home/session", currentDirectory: "/some/cwd"); - // Assert - Assert.Equal( - Path.Combine("/some/cwd", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); - } - - [Theory] - [InlineData("/")] - public void ResolveDefaultRootDirectory_HostedWithFilesystemRootHome_FallsBackToDefault(string home) - { - // Arrange / Act: a filesystem-root HOME (e.g. "/") must NOT root the store at - // "/.checkpoints", which is read-only in the container and caused issue #6231. - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( - isHosted: true, - homeDirectory: home, - currentDirectory: "/some/cwd"); - - // Assert: falls back to the default session-data directory, never the filesystem root. - Assert.Equal( - Path.Combine( - FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, - FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); - Assert.NotEqual( - Path.Combine(Path.GetPathRoot(Path.GetFullPath(home))!, FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); - } - - [Fact] - public async Task SaveSessionAsync_NonWritableDirectory_ThrowsClearActionableIOExceptionAsync() - { - // Arrange: place a file where the store's root directory needs to be created. Creating - // a directory under an existing file fails with IOException on every OS, standing in for - // the read-only root filesystem of a Foundry hosted container (issue #6231). - Directory.CreateDirectory(this._root); - var blockingFile = Path.Combine(this._root, "blocking-file"); - File.WriteAllText(blockingFile, "x"); - - var store = new FileSystemAgentSessionStore(Path.Combine(blockingFile, ".checkpoints")); - var agent = new TestAgent(); - - // Act - var ex = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-fatal", NewSession(), userId: null)); - - // Assert: failure stays fatal but the message is clear and actionable, and the original - // IO error is preserved as the inner exception. - Assert.Contains("could not be created or written to", ex.Message, StringComparison.Ordinal); - Assert.Contains(FileSystemAgentSessionStore.SessionDataDirectoryEnvironmentVariable, ex.Message, StringComparison.Ordinal); - Assert.Contains(FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, ex.Message, StringComparison.Ordinal); - Assert.Contains(store.RootDirectory, ex.Message, StringComparison.Ordinal); - Assert.NotNull(ex.InnerException); + Assert.Equal(Path.Combine("/some/cwd", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), root); } - [Fact] - public async Task SaveSessionAsync_WithUserId_NestsUnderPrefixedAgentAndUserAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent("{\"who\":\"alice\"}", name: "Concierge"); - - await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: "alice"); - - // Layout: {root}/a-{agent}/u-{userId}/c-{conv}.json - var expected = Path.Combine(store.RootDirectory, "a-Concierge", "u-alice", "c-conv-1.json"); - Assert.True(File.Exists(expected), $"expected session at {expected}"); - } - - [Fact] - public async Task SaveSessionAsync_NoUserId_OmitsUserSegmentAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "Concierge"); - - await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: null); - - // No user id -> the u- layer collapses: {root}/a-{agent}/c-{conv}.json - var expected = Path.Combine(store.RootDirectory, "a-Concierge", "c-conv-1.json"); - Assert.True(File.Exists(expected), $"expected session at {expected}"); - Assert.Empty(Directory.GetDirectories(Path.Combine(store.RootDirectory, "a-Concierge"))); - } - - [Fact] - public async Task GetSessionAsync_DifferentUser_DoesNotReadAnotherUsersSessionAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent("{\"secret\":\"alice-only\"}", name: "Concierge"); - - // Alice saves under the same conversationId Bob will guess/forge. - await store.SaveSessionAsync(agent, "shared-conv", NewSession(), userId: "alice"); - - // Bob requests the same conversationId. The per-user partition means Bob's path is distinct, - // so the store returns null (no leak), not Alice's persisted state. - var bobSession = await store.GetSessionAsync(agent, "shared-conv", userId: "bob"); - - Assert.Null(bobSession); // no session for Bob under his partition - Assert.Equal(0, agent.CreateCalls); // a plain lookup never creates - Assert.Equal(0, agent.DeserializeCalls); // Alice's file never deserialized for Bob - } - - [Fact] - public async Task SaveSessionAsync_UserIdEqualToAgentName_StaysDistinctViaPrefixesAsync() - { - // Without prefixes, agent "x" + no user could collide with no-agent + user "x". The a-/u- - // prefixes keep the layers unambiguous. - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "x"); - - await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: "x"); - - var expected = Path.Combine(store.RootDirectory, "a-x", "u-x", "c-conv-1.json"); - Assert.True(File.Exists(expected), $"expected session at {expected}"); - } - - [Theory] - [InlineData("../../escape")] - [InlineData("..")] - [InlineData("user/../../escape")] - [InlineData("a/b")] - [InlineData("a\\b")] - [InlineData(".")] - public async Task SaveSessionAsync_TraversalUserId_IsRejectedAsync(string userId) - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "Concierge"); - - // A forged user id that is not a single safe path segment is rejected outright (CWE-22), - // not sanitized — so it can never escape the storage root. - await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: userId)); - - // Nothing was written outside (or inside) the root. - Assert.False(Directory.Exists(this._root) && Directory.GetFiles(this._root, "*.json", SearchOption.AllDirectories).Length > 0); - } - - [Fact] - public async Task SaveSessionAsync_AbsoluteOrRootedUserId_IsRejectedAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "Concierge"); - - var rooted = Path.IsPathRooted("/etc") ? "/etc" : Path.GetFullPath("/etc"); - await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: rooted)); - } - - [Fact] - public async Task SaveSessionAsync_ThenGetSessionAsync_WithUserId_RoundTripsAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent("{\"foo\":7}", name: "Concierge"); - - await store.SaveSessionAsync(agent, "round-trip", NewSession(), userId: "alice"); - await store.GetSessionAsync(agent, "round-trip", userId: "alice"); + private static TestSession NewSession() => new(); - Assert.Equal(1, agent.SerializeCalls); - Assert.Equal(1, agent.DeserializeCalls); - Assert.Equal(7, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32()); - } + private static string AgentDirectory(FileSystemAgentSessionStore store, string identity) + => Path.Combine( + store.RootDirectory, + "a-" + FoundryAgentSessionKeyEncoder.BuildAgentStorageKey(identity)); - private static TestSession NewSession() => new(); + private static string SessionPath( + FileSystemAgentSessionStore store, + string identity, + AgentSessionStoreKey key) + => Path.Combine( + AgentDirectory(store, identity), + "k-" + FoundryAgentSessionKeyEncoder.BuildStorageKey( + FoundryAgentSessionKeyEncoder.BuildLogicalKey(identity, key)) + ".json"); - private sealed class TestSession : AgentSession - { - } + private sealed class TestSession : AgentSession; private sealed class TestAgent : AIAgent { private readonly string _serializedJson; private readonly string? _name; - public TestAgent(string serializedJson = "{}", string? name = null) + public TestAgent(string serializedJson = "{}", string? name = "test-agent") { this._serializedJson = serializedJson; this._name = name; @@ -486,8 +330,11 @@ public TestAgent(string serializedJson = "{}", string? name = null) public override string? Name => this._name; public int CreateCalls { get; private set; } + public int SerializeCalls { get; private set; } + public int DeserializeCalls { get; private set; } + public JsonElement? LastDeserialized { get; private set; } protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) @@ -496,24 +343,38 @@ protected override ValueTask CreateSessionCoreAsync(CancellationTo return new ValueTask(NewSession()); } - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) { this.SerializeCalls++; - using var doc = JsonDocument.Parse(this._serializedJson); - return new ValueTask(doc.RootElement.Clone()); + using var document = JsonDocument.Parse(this._serializedJson); + return new ValueTask(document.RootElement.Clone()); } - protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) { this.DeserializeCalls++; this.LastDeserialized = serializedState.Clone(); return new ValueTask(NewSession()); } - protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + 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) + 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/FoundryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs index 3fb3da21ddb..963bce0de07 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs @@ -20,10 +20,11 @@ public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsAsync() var backing = new FakeStateStore(); var store = NewStore(backing); var agent = new TestAgent("{\"foo\":7}", name: "Concierge"); + var key = Key("round-trip", "user", "alice"); // Act - await store.SaveSessionAsync(agent, "round-trip", new TestSession(), userId: "alice"); - var session = await store.GetSessionAsync(agent, "round-trip", userId: "alice"); + await store.SaveSessionAsync(agent, key, new TestSession()); + var session = await store.GetSessionAsync(agent, key); // Assert Assert.NotNull(session); @@ -39,13 +40,14 @@ public async Task SaveSessionAsync_StoresReadableLogicalKeyAlongsideTheSessionAs var backing = new FakeStateStore(); var store = NewStore(backing); var agent = new TestAgent(name: "Concierge"); + var key = Key("conv-1", "user", "alice"); // Act - await store.SaveSessionAsync(agent, "conv-1", new TestSession(), userId: "alice"); + await store.SaveSessionAsync(agent, key, new TestSession()); // Assert: the item body keeps the readable key so a stored item can be traced back. var item = Assert.Single(backing.Items); - Assert.Equal("\"a14:name:Concierge|u5:alice|c6:conv-1\"", item["key"].ToString()); + Assert.Equal("\"a14:name:Concierge|s6:conv-1|n4:user|v5:alice\"", item["key"].ToString()); } [Fact] @@ -56,7 +58,7 @@ public async Task GetSessionAsync_NothingStored_ReturnsNullAsync() var agent = new TestAgent(name: "Concierge"); // Act - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -72,7 +74,7 @@ public async Task GetOrCreateSessionAsync_NothingStored_ReturnsFreshSessionFromA var agent = new TestAgent(name: "Concierge"); // Act - var session = await store.GetOrCreateSessionAsync(agent, "conv-1", userId: null); + var session = await store.GetOrCreateSessionAsync(agent, Key("conv-1")); // Assert Assert.NotNull(session); @@ -86,10 +88,10 @@ public async Task GetSessionAsync_DifferentUser_DoesNotReadAnotherUsersSessionAs // Arrange: Alice saves under the conversation id Bob will forge. var store = NewStore(new FakeStateStore()); var agent = new TestAgent("{\"secret\":\"alice-only\"}", name: "Concierge"); - await store.SaveSessionAsync(agent, "shared-conv", new TestSession(), userId: "alice"); + await store.SaveSessionAsync(agent, Key("shared-conv", "user", "alice"), new TestSession()); // Act - var bobSession = await store.GetSessionAsync(agent, "shared-conv", userId: "bob"); + var bobSession = await store.GetSessionAsync(agent, Key("shared-conv", "user", "bob")); // Assert Assert.Null(bobSession); @@ -104,10 +106,11 @@ public async Task GetSessionAsync_DifferentAgent_DoesNotReadAnotherAgentsSession var store = NewStore(backing); var concierge = new TestAgent("{\"owner\":\"concierge\"}", name: "Concierge"); var researcher = new TestAgent(name: "Researcher"); - await store.SaveSessionAsync(concierge, "shared-conv", new TestSession(), userId: "alice"); + var key = Key("shared-conv", "user", "alice"); + await store.SaveSessionAsync(concierge, key, new TestSession()); // Act - var otherSession = await store.GetSessionAsync(researcher, "shared-conv", userId: "alice"); + var otherSession = await store.GetSessionAsync(researcher, key); // Assert Assert.Null(otherSession); @@ -125,10 +128,11 @@ public async Task GetSessionAsync_DifferentKeyedRegistration_DoesNotReadAnotherA var support = new TestAgent(); AIAgent billing = new FoundryHostingAgent(billingLeaf, "key:billing"); AIAgent hostedSupport = new FoundryHostingAgent(support, "key:support"); - await store.SaveSessionAsync(billing, "shared-conv", new TestSession(), userId: "alice"); + var key = Key("shared-conv", "user", "alice"); + await store.SaveSessionAsync(billing, key, new TestSession()); // Act - var supportSession = await store.GetSessionAsync(hostedSupport, "shared-conv", userId: "alice"); + var supportSession = await store.GetSessionAsync(hostedSupport, key); // Assert Assert.Null(supportSession); @@ -144,7 +148,7 @@ public async Task SaveSessionAsync_UnnamedAgent_ThrowsAsync() // Act var exception = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-1", new TestSession(), userId: "alice")); + async () => await store.SaveSessionAsync(agent, Key("conv-1", "user", "alice"), new TestSession())); // Assert Assert.Contains(nameof(AIAgent.Name), exception.Message, StringComparison.Ordinal); @@ -159,7 +163,7 @@ public async Task GetSessionAsync_UnnamedAgent_ThrowsAsync() // Act var exception = await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: "alice")); + async () => await store.GetSessionAsync(agent, Key("conv-1", "user", "alice"))); // Assert Assert.Contains(nameof(AIAgent.Name), exception.Message, StringComparison.Ordinal); @@ -179,9 +183,9 @@ public async Task GetStoreAsync_ResolvesTheStoreOnceAcrossManyCallsAsync() var agent = new TestAgent(name: "Concierge"); // Act - await store.SaveSessionAsync(agent, "conv-1", new TestSession(), userId: null); - await store.GetSessionAsync(agent, "conv-1", userId: null); - await store.GetSessionAsync(agent, "conv-2", userId: null); + await store.SaveSessionAsync(agent, Key("conv-1"), new TestSession()); + await store.GetSessionAsync(agent, Key("conv-1")); + await store.GetSessionAsync(agent, Key("conv-2")); // Assert Assert.Equal(1, bindCount); @@ -204,8 +208,8 @@ public async Task GetStoreAsync_FailedBinding_IsRetriedOnTheNextCallAsync() // Act await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null)); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + async () => await store.GetSessionAsync(agent, Key("conv-1"))); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -230,8 +234,8 @@ public async Task GetStoreAsync_CanceledBinding_IsRetriedOnTheNextCallAsync() // Act await Assert.ThrowsAnyAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null)); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + async () => await store.GetSessionAsync(agent, Key("conv-1"))); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -256,8 +260,8 @@ public async Task GetStoreAsync_BindingFaultedWithCancellation_IsRetriedOnTheNex // Act await Assert.ThrowsAnyAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null)); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + async () => await store.GetSessionAsync(agent, Key("conv-1"))); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -282,9 +286,9 @@ public async Task GetStoreAsync_CallerCancellation_DoesNotDiscardTheSharedBindin // Act await Assert.ThrowsAnyAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null, cancellation.Token)); + async () => await store.GetSessionAsync(agent, Key("conv-1"), cancellation.Token)); binding.SetResult(backing); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -292,15 +296,15 @@ await Assert.ThrowsAnyAsync( } [Theory] - [InlineData("name:Concierge", "alice", "conv-1", "a14:name:Concierge|u5:alice|c6:conv-1")] - [InlineData("name:Concierge", null, "conv-1", "a14:name:Concierge|u-1:|c6:conv-1")] - [InlineData("default", "alice", "conv-1", "a7:default|u5:alice|c6:conv-1")] - [InlineData("default", null, "conv-1", "a7:default|u-1:|c6:conv-1")] - [InlineData("name:x", "x", "conv-1", "a6:name:x|u1:x|c6:conv-1")] - public void BuildLogicalKey_UsesLengthPrefixedComponents(string agentIdentity, string? userId, string conversationId, string expected) + [InlineData("name:Concierge", "conv-1", "a14:name:Concierge|s6:conv-1")] + [InlineData("default", "conv-1", "a7:default|s6:conv-1")] + public void BuildLogicalKey_UsesLengthPrefixedComponents( + string agentIdentity, + string sessionId, + string expected) { // Act - var key = FoundryAgentSessionStore.BuildLogicalKey(agentIdentity, conversationId, userId); + string key = FoundryAgentSessionKeyEncoder.BuildLogicalKey(agentIdentity, Key(sessionId)); // Assert Assert.Equal(expected, key); @@ -309,29 +313,31 @@ public void BuildLogicalKey_UsesLengthPrefixedComponents(string agentIdentity, s [Fact] public void BuildLogicalKey_DelimitersInsideComponents_DoNotCollide() { - // Act: these tuples produced the same delimiter-joined string before components carried - // their lengths. - string first = FoundryAgentSessionStore.BuildLogicalKey("name:Concierge", "x:c-y", "alice"); - string second = FoundryAgentSessionStore.BuildLogicalKey("name:Concierge", "y", "alice:c-x"); + // Act + string first = FoundryAgentSessionKeyEncoder.BuildLogicalKey( + "name:Concierge", + Key("x:c-y", "user", "alice")); + string second = FoundryAgentSessionKeyEncoder.BuildLogicalKey( + "name:Concierge", + Key("y", "user", "alice:c-x")); // Assert Assert.NotEqual(first, second); Assert.NotEqual( - FoundryAgentSessionStore.BuildItemKey(first), - FoundryAgentSessionStore.BuildItemKey(second)); + FoundryAgentSessionKeyEncoder.BuildStorageKey(first), + FoundryAgentSessionKeyEncoder.BuildStorageKey(second)); } [Fact] public void BuildItemKey_StaysWithinThePlatformKeyLimitForAnyInput() { - // Arrange: an agent name plus a user id plus a conversation id can easily pass 128 chars. - var logicalKey = FoundryAgentSessionStore.BuildLogicalKey( + // Arrange + var logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey( $"name:{new string('a', 200)}", - new string('c', 200), - new string('u', 200)); + Key(new string('s', 200), "user", new string('u', 200))); // Act - var itemKey = FoundryAgentSessionStore.BuildItemKey(logicalKey); + var itemKey = FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey); // Assert Assert.InRange(itemKey.Length, 1, 128); @@ -341,9 +347,9 @@ public void BuildItemKey_StaysWithinThePlatformKeyLimitForAnyInput() public void BuildItemKey_IsStableAndDistinctPerLogicalKey() { // Arrange / Act - var first = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|u5:alice|c6:conv-1"); - var same = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|u5:alice|c6:conv-1"); - var other = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|u3:bob|c6:conv-1"); + var first = FoundryAgentSessionKeyEncoder.BuildStorageKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); + var same = FoundryAgentSessionKeyEncoder.BuildStorageKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); + var other = FoundryAgentSessionKeyEncoder.BuildStorageKey("a14:name:Concierge|s6:conv-1|n4:user|v3:bob"); // Assert Assert.Equal(first, same); @@ -371,6 +377,14 @@ public void Constructor_WithoutCredential_IsAllowedForTheSdkLocalFallback() private static FoundryAgentSessionStore NewStore(FakeStateStore backing) => new(_ => Task.FromResult(backing)); + private static AgentSessionStoreKey Key( + string sessionId, + string? partitionName = null, + string? partitionValue = null) + => partitionName is null + ? new AgentSessionStoreKey(sessionId) + : new AgentSessionStoreKey(sessionId).WithPartition(partitionName, partitionValue!); + /// /// An in-memory stand-in for the platform state store. exposes a /// protected constructor and virtual members precisely so it can be substituted like this. diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs index 68e7fb17984..296ee32defd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs @@ -38,8 +38,9 @@ public async Task StoresWithoutCredential_RoundTripThroughTheSdkLocalFallbackAsy var checkpointStore = new FoundryJsonCheckpointStore(); // Act - await sessionStore.SaveSessionAsync(agent, "conversation-1", new TestSession(), userId: "user-1"); - AgentSession? session = await sessionStore.GetSessionAsync(agent, "conversation-1", userId: "user-1"); + var key = new AgentSessionStoreKey("conversation-1").WithPartition("user", "user-1"); + await sessionStore.SaveSessionAsync(agent, key, new TestSession()); + AgentSession? session = await sessionStore.GetSessionAsync(agent, key); using JsonDocument document = JsonDocument.Parse("""{"step":1}"""); CheckpointInfo checkpointInfo = await checkpointStore.CreateCheckpointAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs index 51f0e978746..63e1530c17d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs @@ -123,7 +123,12 @@ public async Task Handler_ResumeSession_MatchingKeys_PassesAsync() // when it has a conversation id; here we plant it directly so we can drive a resume request). // The session is scoped to the same user ("alice") that will resume it. const string ConversationId = "resume-chat-id"; - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession, "alice", CancellationToken.None); + AIAgent storageAgent = new FoundryHostingAgent(capturingAgent, "default"); + await sessionStore.SaveSessionAsync( + storageAgent, + new AgentSessionStoreKey(ConversationId).WithPartition("user", "alice"), + capturingAgent.LastSession, + CancellationToken.None); // Step 3: drive a resume request with the same isolation keys. var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId); @@ -151,12 +156,17 @@ public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async() var (freshRequest, freshContext) = BuildFreshRequest(); await DrainAsync(aliceHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None)); const string ConversationId = "resume-chat-id"; + AIAgent storageAgent = new FoundryHostingAgent(capturingAgent, "default"); // Plant Alice's stamped session UNDER BOB'S partition to simulate a session that reached Bob's // key despite the per-user path partitioning (e.g. a non-partitioning custom store, or in-process // tampering). The 403 identity check is the defense-in-depth layer that must still reject it even // when the physical partition was bypassed. - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, "bob", CancellationToken.None); + await sessionStore.SaveSessionAsync( + storageAgent, + new AgentSessionStoreKey(ConversationId).WithPartition("user", "bob"), + capturingAgent.LastSession!, + CancellationToken.None); // Bob attempts to resume Alice's conversation. var bobProvider = new FakeHostedSessionIsolationKeyProvider("bob"); @@ -181,7 +191,12 @@ public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync() var sessionStore = new InMemoryAgentSessionStore(); const string ConversationId = "untagged-chat-id"; var untagged = await capturingAgent.CreateSessionAsync(CancellationToken.None); - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, untagged, "alice", CancellationToken.None); + AIAgent storageAgent = new FoundryHostingAgent(capturingAgent, "default"); + await sessionStore.SaveSessionAsync( + storageAgent, + new AgentSessionStoreKey(ConversationId).WithPartition("user", "alice"), + untagged, + CancellationToken.None); var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice"); var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore); 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 6c1bf39e809..98020b21e59 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs @@ -561,9 +561,8 @@ private sealed class PhaseObservingSessionStore( { public override async ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { JsonElement state = await agent.SerializeSessionAsync( @@ -572,9 +571,8 @@ public override async ValueTask SaveSessionAsync( coordinator.SerializedStates.Add(state.GetRawText()); await inner.SaveSessionAsync( agent, - conversationId, + key, session, - userId, cancellationToken); JsonProperty? phaseProperty = state @@ -592,13 +590,11 @@ await inner.SaveSessionAsync( public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => inner.GetSessionAsync( agent, - conversationId, - userId, + key, cancellationToken); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs index 8cc381a53bf..684ce6b6b50 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -1585,17 +1585,17 @@ public async Task ExecuteAsync_Streaming_WithNullAdditionalProperties_ReturnsMes public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1619,7 +1619,7 @@ public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-stream"), + It.Is(key => key.SessionId == "ctx-stream"), It.IsAny(), It.IsAny()), Times.Once); @@ -1633,17 +1633,17 @@ public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSavesSessionAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1664,7 +1664,7 @@ public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSave mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx"), + It.Is(key => key.SessionId == "ctx"), It.IsAny(), It.IsAny()), Times.Once); @@ -1755,17 +1755,17 @@ public async Task Handler_WithNullSessionStore_UsesInMemorySessionStoreAndExecut public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1791,13 +1791,13 @@ public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() mockSessionStore.Verify( x => x.GetSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-1"), + It.Is(key => key.SessionId == "ctx-1"), It.IsAny()), Times.Once); mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-1"), + It.Is(key => key.SessionId == "ctx-1"), It.IsAny(), It.IsAny()), Times.Once); @@ -1960,12 +1960,19 @@ public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync() public async Task ExecuteAsync_NonStreaming_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); Mock agentMock = new() { CallBase = true }; @@ -2000,7 +2007,7 @@ await Assert.ThrowsAsync(() => mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx"), + It.Is(key => key.SessionId == "ctx"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2014,12 +2021,19 @@ await Assert.ThrowsAsync(() => public async Task ExecuteAsync_Streaming_WhenRunStreamingAsyncThrows_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); Mock agentMock = new() { CallBase = true }; @@ -2054,7 +2068,7 @@ await Assert.ThrowsAsync(() => mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-stream"), + It.Is(key => key.SessionId == "ctx-stream"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2068,12 +2082,19 @@ await Assert.ThrowsAsync(() => public async Task ExecuteAsync_OnContinuation_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); Mock agentMock = new() { CallBase = true }; @@ -2114,7 +2135,7 @@ await Assert.ThrowsAsync(() => mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-cont"), + It.Is(key => key.SessionId == "ctx-cont"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2128,12 +2149,19 @@ await Assert.ThrowsAsync(() => public async Task ExecuteAsync_NonStreaming_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); @@ -2157,7 +2185,7 @@ await handler.ExecuteAsync( mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx"), + It.Is(key => key.SessionId == "ctx"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2171,12 +2199,19 @@ await handler.ExecuteAsync( public async Task ExecuteAsync_Streaming_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); AgentResponseUpdate[] updates = [new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }]; @@ -2200,7 +2235,7 @@ await handler.ExecuteAsync( mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-stream"), + It.Is(key => key.SessionId == "ctx-stream"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2214,12 +2249,19 @@ await handler.ExecuteAsync( public async Task ExecuteAsync_OnContinuation_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); @@ -2248,7 +2290,7 @@ await handler.ExecuteAsync( mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-cont"), + It.Is(key => key.SessionId == "ctx-cont"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs index 24294f5c9ca..9145d82cd5d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -196,7 +196,7 @@ public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyA var services = new ServiceCollection(); services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; services.AddKeyedSingleton(AgentName, mockSessionStore.Object); // Act @@ -423,17 +423,17 @@ public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUs var services = new ServiceCollection(); services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -452,7 +452,7 @@ public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUs mockSessionStore.Verify( x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase); 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 72972103607..a8ccb35eefd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs @@ -76,12 +76,14 @@ public async Task SaveAndGetSessionAsync_PersistsAcrossStoreAndAgentInstancesAsy var savingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); var loadingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-1").WithPartition("user", "user-1"); // Act - await savingStore.SaveSessionAsync(savingAgent, "session-1", session); - AgentSession restored = await loadingStore.GetSessionAsync(loadingAgent, "session-1"); + await savingStore.SaveSessionAsync(savingAgent, key, session); + AgentSession? restored = await loadingStore.GetSessionAsync(loadingAgent, key); // Assert + Assert.NotNull(restored); Assert.Equal("saved", restored.StateBag.GetValue("marker")); } @@ -94,6 +96,8 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() string commonPrefix = new('s', 2048); string firstId = commonPrefix + "\0:first"; string secondId = commonPrefix + "\u0001/second"; + var firstKey = new AgentSessionStoreKey(firstId); + var secondKey = new AgentSessionStoreKey(secondId); AgentSession firstSession = await agent.CreateSessionAsync(); firstSession.StateBag.SetValue("marker", "first"); @@ -101,10 +105,10 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() 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); + await store.SaveSessionAsync(agent, firstKey, firstSession); + await store.SaveSessionAsync(agent, secondKey, secondSession); + AgentSession? restoredFirst = await store.GetSessionAsync(agent, firstKey); + AgentSession? restoredSecond = await store.GetSessionAsync(agent, secondKey); List blobNames = []; await foreach (BlobItem blob in this._containerClient.GetBlobsAsync()) { @@ -112,6 +116,8 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() } // Assert + Assert.NotNull(restoredFirst); + Assert.NotNull(restoredSecond); Assert.Equal("first", restoredFirst.StateBag.GetValue("marker")); Assert.Equal("second", restoredSecond.StateBag.GetValue("marker")); Assert.Equal(2, blobNames.Count); @@ -119,22 +125,71 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() } [Fact] - public async Task DeleteSessionAsync_RemovesStoredSessionAndIgnoresMissingSessionAsync() + public async Task GetSessionAsync_MissingSession_ReturnsNullAsync() { // 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"); + AgentSession? restored = await store.GetSessionAsync( + agent, + new AgentSessionStoreKey("missing").WithPartition("user", "user-1")); + + // Assert + Assert.Null(restored); + } + + [Fact] + public async Task SaveAndGetSessionAsync_IsolatesPartitionsAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var user1Key = new AgentSessionStoreKey("session-1").WithPartition("user", "user-1"); + var user2Key = new AgentSessionStoreKey("session-1").WithPartition("user", "user-2"); + 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, user1Key, first); + await store.SaveSessionAsync(agent, user2Key, second); + AgentSession? restoredFirst = await store.GetSessionAsync(agent, user1Key); + AgentSession? restoredSecond = await store.GetSessionAsync(agent, user2Key); + + // Assert + Assert.NotNull(restoredFirst); + Assert.NotNull(restoredSecond); + Assert.Equal("first", restoredFirst.StateBag.GetValue("marker")); + Assert.Equal("second", restoredSecond.StateBag.GetValue("marker")); + } + + [Fact] + public async Task SaveAndGetSessionAsync_ScopedAndUnscopedIdentifiersDoNotCollideAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var partitionedKey = new AgentSessionStoreKey("conversation").WithPartition("tenant", "tenant"); + var unpartitionedKey = new AgentSessionStoreKey("tenant::conversation"); + AgentSession scoped = await agent.CreateSessionAsync(); + scoped.StateBag.SetValue("marker", "scoped"); + AgentSession unscoped = await agent.CreateSessionAsync(); + unscoped.StateBag.SetValue("marker", "unscoped"); + + // Act + await store.SaveSessionAsync(agent, partitionedKey, scoped); + await store.SaveSessionAsync(agent, unpartitionedKey, unscoped); + AgentSession? restoredScoped = await store.GetSessionAsync(agent, partitionedKey); + AgentSession? restoredUnscoped = await store.GetSessionAsync(agent, unpartitionedKey); // Assert - Assert.Null(restored.StateBag.GetValue("marker")); + Assert.NotNull(restoredScoped); + Assert.NotNull(restoredUnscoped); + Assert.Equal("scoped", restoredScoped.StateBag.GetValue("marker")); + Assert.Equal("unscoped", restoredUnscoped.StateBag.GetValue("marker")); } [Fact] @@ -143,15 +198,16 @@ public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync() // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-1"); 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"); + await store.SaveSessionAsync(agent, key, first); + await store.SaveSessionAsync(agent, key, second); + AgentSession? restored = await store.GetSessionAsync(agent, key); List blobs = []; await foreach (BlobItem blob in this._containerClient.GetBlobsAsync()) { @@ -159,6 +215,7 @@ public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync() } // Assert + Assert.NotNull(restored); BlobItem storedBlob = Assert.Single(blobs); Assert.Equal("application/json", storedBlob.Properties.ContentType); Assert.Equal("second", restored.StateBag.GetValue("marker")); @@ -177,7 +234,7 @@ public async Task GetSessionAsync_MissingContainerWithoutAutoCreatePropagatesErr // Act RequestFailedException exception = await Assert.ThrowsAsync( - () => store.GetSessionAsync(agent, "session-1").AsTask()); + () => store.GetSessionAsync(agent, new AgentSessionStoreKey("session-1")).AsTask()); // Assert Assert.Equal(BlobErrorCode.ContainerNotFound.ToString(), exception.ErrorCode); @@ -189,17 +246,21 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshotsAsync() // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-1"); AgentSession original = await agent.CreateSessionAsync(); original.StateBag.SetValue("marker", "saved"); - await store.SaveSessionAsync(agent, "session-1", original); + await store.SaveSessionAsync(agent, key, original); // Act - AgentSession first = await store.GetSessionAsync(agent, "session-1"); - AgentSession second = await store.GetSessionAsync(agent, "session-1"); + AgentSession? first = await store.GetSessionAsync(agent, key); + AgentSession? second = await store.GetSessionAsync(agent, key); + Assert.NotNull(first); + Assert.NotNull(second); first.StateBag.SetValue("marker", "changed"); - AgentSession third = await store.GetSessionAsync(agent, "session-1"); + AgentSession? third = await store.GetSessionAsync(agent, key); // Assert + Assert.NotNull(third); Assert.NotSame(first, second); Assert.Equal("saved", second.StateBag.GetValue("marker")); Assert.Equal("saved", third.StateBag.GetValue("marker")); @@ -217,7 +278,10 @@ public async Task SaveSessionAsync_ConcurrentFirstWritesCreateContainerSafelyAsy { AgentSession session = await agent.CreateSessionAsync(); session.StateBag.SetValue("marker", index.ToString()); - writes.Add(store.SaveSessionAsync(agent, $"session-{index}", session).AsTask()); + writes.Add(store.SaveSessionAsync( + agent, + new AgentSessionStoreKey($"session-{index}"), + session).AsTask()); } // Act @@ -277,6 +341,17 @@ public void Constructor_BlobNamePrefixExceedsAzureLimit_Throws() () => new AzureBlobAgentSessionStore(this._containerClient, "assistant", options)); } + [Fact] + public void Constructor_InvalidUtf16AgentNamespace_Throws() + { + // Arrange + string invalid = new((char)0xD800, 1); + + // Act and assert + Assert.Throws( + () => new AzureBlobAgentSessionStore(this._containerClient, invalid)); + } + private static async Task IsAzuriteAvailableAsync() { using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromSeconds(3)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobHostedAgentBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobHostedAgentBuilderExtensionsTests.cs index 4d57d7c79ec..115d25994cd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobHostedAgentBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobHostedAgentBuilderExtensionsTests.cs @@ -33,7 +33,6 @@ public void WithAzureBlobSessionStore_RegistersSingletonWithIsolation() service.ServiceKey as string == "assistant"); Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); Assert.IsType(store); - Assert.NotNull(store.GetService()); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs index 5c8cf78df3b..6c9ba0428d7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs @@ -38,7 +38,7 @@ public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() // Act OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); @@ -62,7 +62,7 @@ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!; - AgentSession session = await sessionStore.GetSessionAsync(agent, secondSessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(secondSessionStoreId)); AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); // Assert: continuation succeeded and the model produced a textual answer. @@ -75,10 +75,10 @@ private static async Task RunTurnAsync(AIAgent agent, AgentSessionStore JsonElement body = ParseBody(bodyJson); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); - await sessionStore.SaveSessionAsync(agent, responseId, session); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(responseId), session); return responseId; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs index 1aa58c177dc..8452802ae93 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs @@ -38,7 +38,7 @@ public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() // Act OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); @@ -62,7 +62,7 @@ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!; - AgentSession session = await sessionStore.GetSessionAsync(agent, secondSessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(secondSessionStoreId)); AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); // Assert: continuation succeeded and the model produced a textual answer. @@ -75,10 +75,10 @@ private static async Task RunTurnAsync(AIAgent agent, AgentSessionStore JsonElement body = ParseBody(bodyJson); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); - await sessionStore.SaveSessionAsync(agent, responseId, session); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(responseId), session); return responseId; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs index 8baa60598c2..8e23b52ea87 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs @@ -216,7 +216,10 @@ private async Task StartAgentHostAsync(IChatClient chatClient) } string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, ct); + AgentSession session = await sessionStore.GetOrCreateSessionAsync( + agent, + new AgentSessionStoreKey(sessionStoreId), + ct); string responseId = OpenAIResponses.CreateResponseId(); // A stable conversation id is a mutable head (write back under the same id); a previous_response_id @@ -234,12 +237,20 @@ private async Task StartAgentHostAsync(IChatClient chatClient) await http.Response.WriteAsync(frame, ct); } - await sessionStore.SaveSessionAsync(agent, saveId, session, ct); + await sessionStore.SaveSessionAsync( + agent, + new AgentSessionStoreKey(saveId), + session, + ct); return Results.Empty; } AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, ct); - await sessionStore.SaveSessionAsync(agent, saveId, session, ct); + await sessionStore.SaveSessionAsync( + agent, + new AgentSessionStoreKey(saveId), + session, + ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentityAgentIsolationKeyProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentityAgentIsolationKeyProviderTests.cs index f62c62e7bd4..d4fb214506c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentityAgentIsolationKeyProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentityAgentIsolationKeyProviderTests.cs @@ -233,10 +233,10 @@ public async Task GetIsolationKeyAsyncReturnsFirstMatchingClaimAsync() } /// - /// Verify that GetIsolationKeyAsync handles empty claim values. + /// Verify that GetIsolationKeyAsync rejects empty claim values. /// [Fact] - public async Task GetIsolationKeyAsyncHandlesEmptyClaimValueAsync() + public async Task GetIsolationKeyAsyncReturnsNullForEmptyClaimValueAsync() { // Arrange this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, string.Empty); @@ -246,7 +246,7 @@ public async Task GetIsolationKeyAsyncHandlesEmptyClaimValueAsync() string? result = await provider.GetIsolationKeyAsync(); // Assert - Assert.Equal(string.Empty, result); + Assert.Null(result); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs deleted file mode 100644 index e5f452aa795..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs +++ /dev/null @@ -1,403 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Moq; - -namespace Microsoft.Agents.AI.Hosting.UnitTests; - -/// -/// Unit tests for the class. -/// -public class DelegatingAgentSessionStoreTests -{ - private readonly Mock _innerStoreMock; - private readonly Mock _agentMock; - private readonly TestDelegatingAgentSessionStore _delegatingStore; - private readonly AgentSession _testSession; - - /// - /// Initializes a new instance of the class. - /// - public DelegatingAgentSessionStoreTests() - { - this._innerStoreMock = new Mock(); - this._agentMock = new Mock(); - this._testSession = new TestAgentSession(); - - // Setup inner store mock - this._innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(this._testSession); - - this._innerStoreMock - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(ValueTask.CompletedTask); - - this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); - } - - #region Constructor Tests - - /// - /// Verify that constructor throws ArgumentNullException when innerStore is null. - /// - [Fact] - public void RequiresInnerStore() => - // Act & Assert - Assert.Throws("innerStore", () => new TestDelegatingAgentSessionStore(null!)); - - /// - /// Verify that constructor sets the inner store correctly. - /// - [Fact] - public void Constructor_WithValidInnerStore_SetsInnerStore() - { - // Act - var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); - - // Assert - Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore); - } - - #endregion - - #region Method Delegation Tests - - /// - /// Verify that GetSessionAsync delegates to inner store with correct parameters. - /// - [Fact] - public async Task GetSessionAsyncDelegatesToInnerStoreAsync() - { - // Arrange - const string ExpectedConversationId = "test-conversation-id"; - var expectedCancellationToken = new CancellationToken(); - - this._innerStoreMock - .Setup(x => x.GetSessionAsync( - It.Is(a => a == this._agentMock.Object), - It.Is(c => c == ExpectedConversationId), - It.Is(ct => ct == expectedCancellationToken))) - .ReturnsAsync(this._testSession); - - // Act - var session = await this._delegatingStore.GetSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - expectedCancellationToken); - - // Assert - Assert.Same(this._testSession, session); - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - expectedCancellationToken), - Times.Once); - } - - /// - /// Verify that SaveSessionAsync delegates to inner store with correct parameters. - /// - [Fact] - public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() - { - // Arrange - const string ExpectedConversationId = "test-conversation-id"; - var expectedCancellationToken = new CancellationToken(); - var expectedSession = new TestAgentSession(); - - this._innerStoreMock - .Setup(x => x.SaveSessionAsync( - It.Is(a => a == this._agentMock.Object), - It.Is(c => c == ExpectedConversationId), - It.Is(s => s == expectedSession), - It.Is(ct => ct == expectedCancellationToken))) - .Returns(ValueTask.CompletedTask); - - // Act - await this._delegatingStore.SaveSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - expectedSession, - expectedCancellationToken); - - // Assert - this._innerStoreMock.Verify( - x => x.SaveSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - expectedSession, - expectedCancellationToken), - Times.Once); - } - - /// - /// Verify that GetSessionAsync awaits the inner store's result before returning. - /// - [Fact] - public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() - { - // Arrange - const string ExpectedConversationId = "test-conversation-id"; - var taskCompletionSource = new TaskCompletionSource(); - - var innerStoreMock = new Mock(); - innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new ValueTask(taskCompletionSource.Task)); - - var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); - - // Act - var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId); - - // Assert - Assert.False(resultTask.IsCompleted); - taskCompletionSource.SetResult(this._testSession); - Assert.True(resultTask.IsCompleted); - Assert.Same(this._testSession, await resultTask); - } - - /// - /// Verify that SaveSessionAsync awaits the inner store's completion before returning. - /// - [Fact] - public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() - { - // Arrange - const string ExpectedConversationId = "test-conversation-id"; - var expectedSession = new TestAgentSession(); - var taskCompletionSource = new TaskCompletionSource(); - - var innerStoreMock = new Mock(); - innerStoreMock - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new ValueTask(taskCompletionSource.Task)); - - var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); - - // Act - var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession); - - // Assert - Assert.False(resultTask.IsCompleted); - taskCompletionSource.SetResult(); - Assert.True(resultTask.IsCompleted); - await resultTask; - } - - #endregion - - #region GetService Tests - - /// - /// Verify that GetService returns itself when requesting the exact type. - /// - [Fact] - public void GetServiceReturnsItselfForExactType() - { - // Act - var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore)); - - // Assert - Assert.Same(this._delegatingStore, result); - } - - /// - /// Verify that GetService returns itself when requesting a base type. - /// - [Fact] - public void GetServiceReturnsItselfForBaseType() - { - // Act - var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore)); - - // Assert - Assert.Same(this._delegatingStore, result); - } - - /// - /// Verify that GetService returns itself when requesting AgentSessionStore. - /// - [Fact] - public void GetServiceReturnsItselfForAgentSessionStoreType() - { - // Act - var result = this._delegatingStore.GetService(typeof(AgentSessionStore)); - - // Assert - Assert.Same(this._delegatingStore, result); - } - - /// - /// Verify that GetService chains to inner store when type is not satisfied by outer store. - /// - [Fact] - public void GetServiceChainsToInnerStore() - { - // Arrange - var innerStore = new ConcreteAgentSessionStore(); - var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); - - // Act - var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore)); - - // Assert - Assert.Same(innerStore, result); - } - - /// - /// Verify that GetService chains through multiple delegation layers. - /// - [Fact] - public void GetServiceChainsThoughMultipleDelegationLayers() - { - // Arrange - create a three-layer chain: outer -> middle -> inner - var innerStore = new ConcreteAgentSessionStore(); - var middleStore = new AnotherDelegatingAgentSessionStore(innerStore); - var outerStore = new TestDelegatingAgentSessionStore(middleStore); - - // Act - request the innermost store type - var result = outerStore.GetService(typeof(ConcreteAgentSessionStore)); - - // Assert - Assert.Same(innerStore, result); - } - - /// - /// Verify that GetService can find a store in the middle of the delegation chain. - /// - [Fact] - public void GetServiceFindsMiddleStoreInChain() - { - // Arrange - create a three-layer chain: outer -> middle -> inner - var innerStore = new ConcreteAgentSessionStore(); - var middleStore = new AnotherDelegatingAgentSessionStore(innerStore); - var outerStore = new TestDelegatingAgentSessionStore(middleStore); - - // Act - request the middle store type - var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore)); - - // Assert - Assert.Same(middleStore, result); - } - - /// - /// Verify that GetService returns null when the requested type is not found in the chain. - /// - [Fact] - public void GetServiceReturnsNullWhenTypeNotFound() - { - // Arrange - var innerStore = new ConcreteAgentSessionStore(); - var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); - - // Act - var result = delegatingStore.GetService(typeof(string)); - - // Assert - Assert.Null(result); - } - - /// - /// Verify that GetService returns null when a service key is provided but not matched. - /// - [Fact] - public void GetServiceReturnsNullWhenServiceKeyProvided() - { - // Act - var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key"); - - // Assert - Assert.Null(result); - } - - /// - /// Verify that GetService throws ArgumentNullException when serviceType is null. - /// - [Fact] - public void GetServiceThrowsWhenServiceTypeIsNull() => - Assert.Throws("serviceType", () => this._delegatingStore.GetService(null!)); - - /// - /// Verify that GetService generic method works correctly. - /// - [Fact] - public void GetServiceGenericReturnsItself() - { - // Act - var result = this._delegatingStore.GetService(); - - // Assert - Assert.Same(this._delegatingStore, result); - } - - /// - /// Verify that GetService generic method chains to inner store. - /// - [Fact] - public void GetServiceGenericChainsToInnerStore() - { - // Arrange - var innerStore = new ConcreteAgentSessionStore(); - var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); - - // Act - var result = delegatingStore.GetService(); - - // Assert - Assert.Same(innerStore, result); - } - - /// - /// Verify that GetService generic method returns null when type not found. - /// - [Fact] - public void GetServiceGenericReturnsNullWhenTypeNotFound() - { - // Act - var result = this._delegatingStore.GetService(); - - // Assert - Assert.Null(result); - } - - #endregion - - #region Test Implementation - - /// - /// Test implementation of DelegatingAgentSessionStore for testing purposes. - /// - private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore) - { - public new AgentSessionStore InnerStore => base.InnerStore; - } - - /// - /// Another delegating store implementation for testing multi-layer chains. - /// - private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore); - - /// - /// Concrete (non-delegating) session store for testing GetService chaining. - /// - private sealed class ConcreteAgentSessionStore : AgentSessionStore - { - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => new(new TestAgentSession()); - - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - } - - private sealed class TestAgentSession : AgentSession; - - #endregion -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs index 8af3fc43ece..694053273fe 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -2,76 +2,30 @@ using System; using System.Collections.Generic; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Moq; -using Moq.Protected; namespace Microsoft.Agents.AI.Hosting.UnitTests; /// -/// Unit tests for across the in-box stores. +/// Unit tests for the in-box session stores. /// public class InMemoryAgentSessionStoreTests { [Fact] - public async Task DeleteSessionAsync_RemovesStoredSession_SoNextGetCreatesAsync() - { - // Arrange - var stored = JsonSerializer.SerializeToElement(new { marker = "stored" }); - var restoredSession = new TestAgentSession(); - var createdSession = new TestAgentSession(); - var agent = new Mock(); - agent.Protected() - .Setup>("SerializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) - .Returns(new ValueTask(stored)); - agent.Protected() - .Setup>("DeserializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) - .Returns(new ValueTask(restoredSession)); - agent.Protected() - .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .Returns(new ValueTask(createdSession)); - - var store = new InMemoryAgentSessionStore(); - - // Act & Assert - await store.SaveSessionAsync(agent.Object, "s1", new TestAgentSession()); - Assert.Same(restoredSession, await store.GetSessionAsync(agent.Object, "s1")); - - await store.DeleteSessionAsync(agent.Object, "s1"); - Assert.Same(createdSession, await store.GetSessionAsync(agent.Object, "s1")); - } - - [Fact] - public async Task DeleteSessionAsync_UnknownId_DoesNotThrowAsync() + public async Task GetSessionAsync_MissingSession_ReturnsNullAsync() { // Arrange var store = new InMemoryAgentSessionStore(); + var agent = new Mock(); - // Act & Assert (no exception) - await store.DeleteSessionAsync(new Mock().Object, "missing"); - } - - [Fact] - public async Task DeleteSessionAsync_NoopStore_CompletesAsync() - { - // Arrange - var store = new NoopAgentSessionStore(); - - // Act & Assert (no exception) - await store.DeleteSessionAsync(new Mock().Object, "any"); - } - - [Fact] - public async Task DeleteSessionAsync_StoreOptsOut_ThrowsNotSupportedAsync() - { - // Arrange: a store that chooses not to support deletion throws NotSupportedException itself. - AgentSessionStore store = new ConcreteAgentSessionStore(); + // Act + AgentSession? session = await store.GetSessionAsync(agent.Object, new AgentSessionStoreKey("missing")); - // Act & Assert - await Assert.ThrowsAsync(() => store.DeleteSessionAsync(new Mock().Object, "any").AsTask()); + // Assert + Assert.Null(session); } [Fact] @@ -81,16 +35,19 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshot_ForConcurrentBranch // and a stored session that carries some state to copy. AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new InMemoryAgentSessionStore(); + var key = new AgentSessionStoreKey("s1").WithPartition("user", "user-1"); AgentSession original = await agent.CreateSessionAsync(); original.StateBag.SetValue("marker", "v1"); - await store.SaveSessionAsync(agent, "s1", original); + await store.SaveSessionAsync(agent, key, original); // Act: two concurrent branches read the same stored id. - AgentSession branchA = await store.GetSessionAsync(agent, "s1"); - AgentSession branchB = await store.GetSessionAsync(agent, "s1"); + AgentSession? branchA = await store.GetSessionAsync(agent, key); + AgentSession? branchB = await store.GetSessionAsync(agent, key); // Assert: each branch is an independent instance carrying the same content. + Assert.NotNull(branchA); + Assert.NotNull(branchB); Assert.NotSame(branchA, branchB); Assert.Equal("v1", branchA.StateBag.GetValue("marker")); Assert.Equal("v1", branchB.StateBag.GetValue("marker")); @@ -99,22 +56,31 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshot_ForConcurrentBranch branchA.StateBag.SetValue("marker", "mutated"); Assert.Equal("v1", branchB.StateBag.GetValue("marker")); - AgentSession branchC = await store.GetSessionAsync(agent, "s1"); + AgentSession? branchC = await store.GetSessionAsync(agent, key); + Assert.NotNull(branchC); Assert.Equal("v1", branchC.StateBag.GetValue("marker")); } - private sealed class TestAgentSession : AgentSession; - - private sealed class ConcreteAgentSessionStore : AgentSessionStore + [Fact] + public async Task GetSessionAsync_DifferentUsers_AreIsolatedAsync() { - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => default; - - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => new(new TestAgentSession()); - - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new InMemoryAgentSessionStore(); + var user1Key = new AgentSessionStoreKey("s1").WithPartition("user", "user-1"); + var user2Key = new AgentSessionStoreKey("s1").WithPartition("user", "user-2"); + AgentSession session = await agent.CreateSessionAsync(); + session.StateBag.SetValue("marker", "user-1"); + await store.SaveSessionAsync(agent, user1Key, session); + + // Act + AgentSession? matchingUser = await store.GetSessionAsync(agent, user1Key); + AgentSession? differentUser = await store.GetSessionAsync(agent, user2Key); + + // Assert + Assert.NotNull(matchingUser); + Assert.Equal("user-1", matchingUser.StateBag.GetValue("marker")); + Assert.Null(differentUser); } // A chat client that is never invoked: these tests only create, serialize, and deserialize sessions. diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs index 2521d06a113..33469250f1f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -13,421 +13,167 @@ namespace Microsoft.Agents.AI.Hosting.UnitTests; public class IsolationKeyScopedAgentSessionStoreTests { private const string TestIsolationKey = "test-key"; - private const string TestConversationId = "test-conversation-id"; - private readonly Mock _innerStoreMock; - private readonly Mock _agentMock; - private readonly AgentSession _testSession; + private readonly Mock _innerStoreMock = new(); + private readonly Mock _agentMock = new(); - /// - /// Initializes a new instance of the class. - /// - public IsolationKeyScopedAgentSessionStoreTests() - { - this._innerStoreMock = new Mock(); - this._agentMock = new Mock(); - this._testSession = new TestAgentSession(); - - this._innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(this._testSession); - - this._innerStoreMock - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(ValueTask.CompletedTask); - } - - #region Constructor Tests - - /// - /// Verify that constructor throws ArgumentNullException when innerStore is null. - /// [Fact] public void RequiresInnerStore() { // Arrange var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - // Act & Assert + // Act and assert Assert.Throws("innerStore", () => new IsolationKeyScopedAgentSessionStore(null!, provider)); } - /// - /// Verify that constructor uses default options when options is null. - /// [Fact] - public void UsesDefaultOptionsWhenNull() + public async Task GetSessionAsync_AddsIsolationPartitionAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - - // Act & Assert - should not throw - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null); - Assert.NotNull(store); - } - - #endregion - - #region GetSessionAsync Tests - - /// - /// Verify that GetSessionAsync scopes the conversation ID with the isolation key. - /// - [Fact] - public async Task GetSessionAsyncScopesConversationIdWithKeyAsync() - { - // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + var expectedSession = new TestAgentSession(); + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + this._agentMock.Object, + It.Is(actual => + actual.SessionId == "session-1" + && actual.Partitions["tenant"] == "tenant-1" + && actual.Partitions["isolation"] == TestIsolationKey), + It.IsAny())) + .ReturnsAsync(expectedSession); + var store = this.CreateStore(TestIsolationKey); // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + AgentSession? session = await store.GetSessionAsync(this._agentMock.Object, key); // Assert - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"{TestIsolationKey}::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode. - /// - [Fact] - public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync() - { - // Arrange - var provider = new TestAgentIsolationKeyProvider(null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - provider, - new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); - - Assert.Contains("Agent isolation key is required", exception.Message); + Assert.Same(expectedSession, session); + this._innerStoreMock.VerifyAll(); } - /// - /// Verify that GetSessionAsync does not throw when key is null in non-strict mode. - /// [Fact] - public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() + public async Task SaveSessionAsync_AddsIsolationPartitionAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - provider, - new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); - - // Act - should not throw - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - conversation ID should be passed through unmodified - this._innerStoreMock.Verify( - x => x.GetSessionAsync( + var key = new AgentSessionStoreKey("session-1"); + var session = new TestAgentSession(); + this._innerStoreMock + .Setup(x => x.SaveSessionAsync( this._agentMock.Object, - TestConversationId, - It.IsAny()), - Times.Once); - } - - /// - /// Verify that GetSessionAsync returns the session from the inner store. - /// - [Fact] - public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync() - { - // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + It.Is(actual => + actual.SessionId == "session-1" + && actual.Partitions["isolation"] == TestIsolationKey), + session, + It.IsAny())) + .Returns(ValueTask.CompletedTask); + var store = this.CreateStore(TestIsolationKey); // Act - var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + await store.SaveSessionAsync(this._agentMock.Object, key, session); // Assert - Assert.Same(this._testSession, result); + this._innerStoreMock.VerifyAll(); } - #endregion - - #region SaveSessionAsync Tests - - /// - /// Verify that SaveSessionAsync scopes the conversation ID with the isolation key. - /// [Fact] - public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync() + public async Task GetOrCreateSessionAsync_ForwardsScopedKeyToSpecializedInnerStoreAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); - var sessionToSave = new TestAgentSession(); + var expectedSession = new TestAgentSession(); + var key = new AgentSessionStoreKey("session-1"); + this._innerStoreMock + .Setup(x => x.GetOrCreateSessionAsync( + this._agentMock.Object, + It.Is(actual => + actual.Partitions["isolation"] == TestIsolationKey), + It.IsAny())) + .ReturnsAsync(expectedSession); + var store = this.CreateStore(TestIsolationKey); // Act - await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + AgentSession session = await store.GetOrCreateSessionAsync(this._agentMock.Object, key); // Assert - this._innerStoreMock.Verify( - x => x.SaveSessionAsync( - this._agentMock.Object, - $"{TestIsolationKey}::{TestConversationId}", - sessionToSave, - It.IsAny()), - Times.Once); + Assert.Same(expectedSession, session); + this._innerStoreMock.VerifyAll(); } - /// - /// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode. - /// [Fact] - public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync() + public async Task GetSessionAsync_StrictModeWithoutIsolationKey_ThrowsAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - provider, + var store = this.CreateStore( + isolationKey: null, new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); - var sessionToSave = new TestAgentSession(); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave)); - - Assert.Contains("Agent isolation key is required", exception.Message); - } - - /// - /// Verify that SaveSessionAsync does not throw when key is null in non-strict mode. - /// - [Fact] - public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() - { - // Arrange - var provider = new TestAgentIsolationKeyProvider(null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - provider, - new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); - var sessionToSave = new TestAgentSession(); - - // Act - should not throw - await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); - - // Assert - conversation ID should be passed through unmodified - this._innerStoreMock.Verify( - x => x.SaveSessionAsync( - this._agentMock.Object, - TestConversationId, - sessionToSave, - It.IsAny()), - Times.Once); - } - - #endregion - - #region Escaping Tests - - /// - /// Verify that colons in the isolation key are escaped. - /// - [Fact] - public async Task EscapesColonsInIsolationKeyAsync() - { - // Arrange - const string KeyWithColon = "key:with:colons"; - var provider = new TestAgentIsolationKeyProvider(KeyWithColon); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - colons should be escaped as \: - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"key\\:with\\:colons::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that backslashes in the isolation key are escaped. - /// - [Fact] - public async Task EscapesBackslashesInIsolationKeyAsync() - { - // Arrange - const string KeyWithBackslash = @"domain\key"; - var provider = new TestAgentIsolationKeyProvider(KeyWithBackslash); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - backslashes should be escaped as \\ - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"domain\\\\key::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that both backslashes and colons in the isolation key are escaped correctly. - /// - [Fact] - public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync() - { - // Arrange - const string KeyWithBoth = @"domain\key:role"; - var provider = new TestAgentIsolationKeyProvider(KeyWithBoth); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - backslashes escaped first, then colons - this._innerStoreMock.Verify( - x => x.GetSessionAsync( + var exception = await Assert.ThrowsAsync( + () => store.GetSessionAsync( this._agentMock.Object, - $"domain\\\\key\\:role::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - #endregion - - #region Isolation Tests - - /// - /// Verify that different isolation keys result in different scoped conversation IDs. - /// - [Fact] - public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync() - { - // Arrange - const string Key1 = "key-1"; - const string Key2 = "key-2"; - string? capturedConversationId1 = null; - string? capturedConversationId2 = null; - - this._innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, conversationId, _) => - { - if (capturedConversationId1 == null) - { - capturedConversationId1 = conversationId; - } - else - { - capturedConversationId2 = conversationId; - } - }) - .ReturnsAsync(this._testSession); - - // Act - Key 1 - var provider1 = new TestAgentIsolationKeyProvider(Key1); - var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1); - await store1.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Act - Key 2 - var provider2 = new TestAgentIsolationKeyProvider(Key2); - var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2); - await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); + new AgentSessionStoreKey("session-1")).AsTask()); // Assert - Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1); - Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2); - Assert.NotEqual(capturedConversationId1, capturedConversationId2); + Assert.Contains("Agent isolation key is required", exception.Message); } - #endregion - - #region GetService Tests - - /// - /// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain. - /// [Fact] - public void GetServiceReturnsIsolationKeyScopedAgentSessionStore() + public async Task GetSessionAsync_NonStrictModePreservesExistingPartitionsAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + this._agentMock.Object, + key, + It.IsAny())) + .ReturnsAsync((AgentSession?)null); + var store = this.CreateStore( + isolationKey: null, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); // Act - var result = store.GetService(); + await store.GetSessionAsync(this._agentMock.Object, key); // Assert - Assert.Same(store, result); + this._innerStoreMock.VerifyAll(); } - /// - /// Verify that GetService chains through to find inner store types. - /// [Fact] - public void GetServiceChainsToInnerStore() + public async Task GetSessionAsync_IsolationProviderReplacesExistingIsolationPartitionAsync() { // Arrange - var concreteInnerStore = new ConcreteAgentSessionStore(); - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider); + var key = new AgentSessionStoreKey("session-1").WithPartition("isolation", "caller-value"); + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + this._agentMock.Object, + It.Is(actual => + actual.Partitions["isolation"] == TestIsolationKey), + It.IsAny())) + .ReturnsAsync((AgentSession?)null); + var store = this.CreateStore(TestIsolationKey); // Act - var result = store.GetService(); + await store.GetSessionAsync(this._agentMock.Object, key); // Assert - Assert.Same(concreteInnerStore, result); + this._innerStoreMock.VerifyAll(); } - #endregion - - #region Helper Classes + private IsolationKeyScopedAgentSessionStore CreateStore( + string? isolationKey, + IsolationKeyScopedAgentSessionStoreOptions? options = null) + => new( + this._innerStoreMock.Object, + new TestAgentIsolationKeyProvider(isolationKey), + options); - /// - /// Test implementation of for testing purposes. - /// - private sealed class TestAgentIsolationKeyProvider : AgentIsolationKeyProvider + private sealed class TestAgentIsolationKeyProvider(string? key) : AgentIsolationKeyProvider { - private readonly string? _key; - - public TestAgentIsolationKeyProvider(string? key) - { - this._key = key; - } - public override ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken = default) - { - return new ValueTask(this._key); - } + => new(key); } private sealed class TestAgentSession : AgentSession; - - /// - /// Concrete (non-delegating) session store for testing GetService chaining. - /// - private sealed class ConcreteAgentSessionStore : AgentSessionStore - { - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => new(new TestAgentSession()); - - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - } - - #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs new file mode 100644 index 00000000000..b53ffb30b02 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class DelegatingAgentSessionStoreTests +{ + private readonly Mock _innerStoreMock; + private readonly Mock _agentMock; + private readonly TestDelegatingAgentSessionStore _delegatingStore; + private readonly AgentSession _testSession; + + /// + /// Initializes a new instance of the class. + /// + public DelegatingAgentSessionStoreTests() + { + this._innerStoreMock = new Mock(); + this._agentMock = new Mock(); + this._testSession = new TestAgentSession(); + + // Setup inner store mock + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(this._testSession); + + this._innerStoreMock + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(default(ValueTask)); + + this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); + } + + #region Constructor Tests + + /// + /// Verify that constructor throws ArgumentNullException when innerStore is null. + /// + [Fact] + public void RequiresInnerStore() => + // Act & Assert + Assert.Throws("innerStore", () => new TestDelegatingAgentSessionStore(null!)); + + /// + /// Verify that constructor sets the inner store correctly. + /// + [Fact] + public void Constructor_WithValidInnerStore_SetsInnerStore() + { + // Act + var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); + + // Assert + Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore); + } + + #endregion + + #region Method Delegation Tests + + /// + /// Verify that GetSessionAsync delegates to inner store with correct parameters. + /// + [Fact] + public async Task GetSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); + var expectedCancellationToken = new CancellationToken(); + + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + It.Is(a => a == this._agentMock.Object), + It.Is(key => key.Equals(expectedKey)), + It.Is(ct => ct == expectedCancellationToken))) + .ReturnsAsync(this._testSession); + + // Act + var session = await this._delegatingStore.GetSessionAsync( + this._agentMock.Object, + expectedKey, + expectedCancellationToken); + + // Assert + Assert.Same(this._testSession, session); + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + expectedKey, + expectedCancellationToken), + Times.Once); + } + + /// + /// Verify that SaveSessionAsync delegates to inner store with correct parameters. + /// + [Fact] + public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); + var expectedCancellationToken = new CancellationToken(); + var expectedSession = new TestAgentSession(); + + this._innerStoreMock + .Setup(x => x.SaveSessionAsync( + It.Is(a => a == this._agentMock.Object), + It.Is(key => key.Equals(expectedKey)), + It.Is(s => s == expectedSession), + It.Is(ct => ct == expectedCancellationToken))) + .Returns(default(ValueTask)); + + // Act + await this._delegatingStore.SaveSessionAsync( + this._agentMock.Object, + expectedKey, + expectedSession, + expectedCancellationToken); + + // Assert + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + expectedKey, + expectedSession, + expectedCancellationToken), + Times.Once); + } + + /// + /// Verify that GetSessionAsync awaits the inner store's result before returning. + /// + [Fact] + public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id"); + var taskCompletionSource = new TaskCompletionSource(); + + var innerStoreMock = new Mock(); + innerStoreMock + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(taskCompletionSource.Task)); + + var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); + + // Act + var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, expectedKey); + + // Assert + Assert.False(resultTask.IsCompleted); + taskCompletionSource.SetResult(this._testSession); + Assert.True(resultTask.IsCompleted); + Assert.Same(this._testSession, await resultTask); + } + + /// + /// Verify that GetOrCreateSessionAsync honors a derived GetSessionAsync override. + /// + [Fact] + public async Task GetOrCreateSessionAsyncUsesOverriddenGetSessionAsyncAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); + var store = new OverridingGetSessionStore(this._innerStoreMock.Object, this._testSession); + + // Act + AgentSession session = await store.GetOrCreateSessionAsync( + this._agentMock.Object, + expectedKey); + + // Assert + Assert.Same(this._testSession, session); + this._innerStoreMock.Verify( + x => x.GetOrCreateSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + /// + /// Verify that SaveSessionAsync awaits the inner store's completion before returning. + /// + [Fact] + public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id"); + var expectedSession = new TestAgentSession(); + var taskCompletionSource = new TaskCompletionSource(); + + var innerStoreMock = new Mock(); + innerStoreMock + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(taskCompletionSource.Task)); + + var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); + + // Act + var resultTask = delegatingStore.SaveSessionAsync( + this._agentMock.Object, + expectedKey, + expectedSession); + + // Assert + Assert.False(resultTask.IsCompleted); + taskCompletionSource.SetResult(true); + Assert.True(resultTask.IsCompleted); + await resultTask; + } + + #endregion + + #region Test Implementation + + /// + /// Test implementation of DelegatingAgentSessionStore for testing purposes. + /// + private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore) + { + public new AgentSessionStore InnerStore => base.InnerStore; + } + + private sealed class OverridingGetSessionStore(AgentSessionStore innerStore, AgentSession session) + : DelegatingAgentSessionStore(innerStore) + { + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + => new(session); + } + + private sealed class TestAgentSession : AgentSession; + + #endregion +}