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..ff47acb1791 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,12 @@ 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. The required user partition and lookup behavior defined here remain unchanged. + ## 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..cb3ec1bef1b --- /dev/null +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -0,0 +1,80 @@ +--- +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. +- Every caller must explicitly decide whether the session is partitioned by user. +- 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. + +## 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 require a `string? userId` argument with no default value. + A non-null value must not be empty or contain only whitespace. +- `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. + +The conventional Hosting implementations adopt the same behavior. `IsolationKeyScopedAgentSessionStore` +passes the key from `AgentIsolationKeyProvider` as the `userId` argument while leaving `conversationId` +unchanged. The in-memory and Azure Blob stores return `null` for a missing session and partition saved +sessions by user. `AIHostAgent` uses `GetOrCreateSessionAsync` when it needs a ready session. + +Azure Blob Storage hashes a tagged, length-prefixed encoding of `userId` and `conversationId` under a +version 2 path. This prevents a scoped session from sharing a blob with an unscoped conversation whose +identifier contains the old delimiter. Reading version 1 keys is available only through +`EnableLegacyKeyFallback`, which defaults to `false`. It is intended for a controlled migration after all +application instances write version 2 keys and only when scoped and unscoped identifiers cannot coexist. + +## Consequences + +Positive: + +- Storage implementations can be shared by Foundry Hosting, conventional Hosting, and future protocols. +- Missing session handling is explicit and consistent. +- User isolation is represented by its own argument instead of being encoded into a conversation identifier. +- `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 pass `userId: null` explicitly when no user partition exists. +- Consumers that need deletion must use a storage-specific API until a separate shared deletion capability is defined. +- Existing Azure Blob sessions require an explicit, controlled version 1 fallback during migration. + +## More Information + +- [ADR-0031](0031-hosted-per-user-session-storage-isolation.md) defines the explicit user partition used by the promoted contract. +- [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..67d94947ba5 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -87,23 +87,37 @@ 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, + string conversationId, + string? userId, + CancellationToken cancellationToken = default); + + public virtual ValueTask GetOrCreateSessionAsync( + AIAgent agent, + string conversationId, + string? userId, + CancellationToken cancellationToken = default); + + public abstract ValueTask SaveSessionAsync( + AIAgent agent, + string conversationId, + AgentSession session, + string? userId, + 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 +135,17 @@ 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, id, userId)` +returns `null` on a miss, while `GetOrCreateSessionAsync(agent, id, userId)` 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, id, session, userId)` 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. + +Storage implementations must encode `userId` and the conversation identifier without collisions. A +scoped tuple and an unscoped conversation identifier must never resolve to the same storage key. `HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory `sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but @@ -171,8 +187,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. +- For multi-user hosts, pass a trusted `userId`, or wrap the store with + `IsolationKeyScopedAgentSessionStore` so `AgentIsolationKeyProvider` supplies it. - Persist session/checkpoint state only after the run or stream has completed. ## E2E Code Samples @@ -193,7 +209,11 @@ 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 session = await sessionStore.GetOrCreateSessionAsync( + agent, + sessionId, + userId: null, + cancellationToken: ct); string responseId = OpenAIResponses.CreateResponseId(); @@ -206,12 +226,22 @@ 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, + responseId, + session, + userId: null, + cancellationToken: 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, + responseId, + session, + userId: null, + cancellationToken: ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId)); }); ``` 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..8fbafa7bb81 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(); @@ -66,7 +66,11 @@ static string LookupWeather([Description("The city to look up weather for.")] st string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run); string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, cancellationToken).ConfigureAwait(false); + AgentSession session = await sessionStore.GetOrCreateSessionAsync( + agent, + sessionStoreId, + userId: null, + cancellationToken: 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, saveId, session, userId: null, cancellationToken: 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, saveId, session, userId: null, cancellationToken: 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..9d4f0148df9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs @@ -0,0 +1,94 @@ +// 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 unique identifier for the conversation. + /// The session to save. + /// + /// The per-user partition key that scopes this session to its owner. Pass only + /// when there is no user context, such as in a single-user application or local development. + /// Non-null values must not be empty or contain only whitespace. The parameter is required so every + /// caller consciously decides the session scope. + /// + /// 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 an 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 to retrieve. + /// + /// The per-user partition key that scopes this session to its owner. It must match the value used when the + /// session was saved. Pass only when there is no user context. Non-null values must + /// not be empty or contain only whitespace. + /// + /// 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, + string conversationId, + string? userId, + 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 unique identifier for the conversation 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. + /// + /// 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, + string conversationId, + string? userId, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(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.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt index ab058de62d4..ca6228dbf37 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,6 @@ #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, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, Microsoft.Agents.AI.AgentSession! session, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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..ca6228dbf37 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,6 @@ #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, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, Microsoft.Agents.AI.AgentSession! session, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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..ca6228dbf37 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,6 @@ #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, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, Microsoft.Agents.AI.AgentSession! session, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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..ca6228dbf37 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,6 @@ #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, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, Microsoft.Agents.AI.AgentSession! session, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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..ca6228dbf37 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,6 @@ #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, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, Microsoft.Agents.AI.AgentSession! session, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, string? userId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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..8634596bb9f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs @@ -152,6 +152,7 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat ArgumentNullException.ThrowIfNull(agent); ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); ArgumentNullException.ThrowIfNull(session); + ValidateUserId(userId); JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -212,6 +213,7 @@ private string BuildNotWritableMessage(string sessionFilePath) => { ArgumentNullException.ThrowIfNull(agent); ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ValidateUserId(userId); string path = this.GetSessionPath(agent, conversationId, userId); if (!File.Exists(path)) @@ -256,7 +258,7 @@ private string GetSessionPath(AIAgent agent, string conversationId, string? user dir = Path.Combine(dir, "a-" + Sanitize(agent.Name!)); } - if (!string.IsNullOrWhiteSpace(userId)) + if (userId is not null) { // 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. @@ -309,6 +311,14 @@ private static void ValidatePathSegment(string segment, string kind) } } + private static void ValidateUserId(string? userId) + { + if (userId is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + } + } + private static string Sanitize(string value) { // Percent-encode every character that is invalid in a filename, plus '%' itself diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs index 195a7971673..daccd152bdf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs @@ -132,6 +132,7 @@ public override async ValueTask SaveSessionAsync( _ = Throw.IfNull(agent); _ = Throw.IfNullOrWhitespace(conversationId); _ = Throw.IfNull(session); + ValidateUserId(userId); string agentIdentity = ResolveAgentIdentity(agent); JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -159,6 +160,7 @@ await store.SetItemAsync( { _ = Throw.IfNull(agent); _ = Throw.IfNullOrWhitespace(conversationId); + ValidateUserId(userId); string logicalKey = BuildLogicalKey(ResolveAgentIdentity(agent), conversationId, userId); FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); @@ -193,7 +195,7 @@ internal static string BuildLogicalKey(string agentIdentity, string conversation { StringBuilder builder = new(); AppendComponent(builder, 'a', Throw.IfNullOrWhitespace(agentIdentity)); - AppendComponent(builder, 'u', string.IsNullOrWhiteSpace(userId) ? null : userId); + AppendComponent(builder, 'u', userId); AppendComponent(builder, 'c', Throw.IfNullOrWhitespace(conversationId)); builder.Length--; return builder.ToString(); @@ -229,6 +231,14 @@ private static void AppendComponent(StringBuilder builder, char prefix, string? builder.Append('|'); } + private static void ValidateUserId(string? userId) + { + if (userId is not null) + { + _ = Throw.IfNullOrWhitespace(userId); + } + } + /// /// 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 diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs index 579240432b6..025b017e93d 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; @@ -35,6 +36,11 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore /// public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(session); + ValidateUserId(userId); + var key = GetKey(agent, conversationId, userId); this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -42,6 +48,10 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat /// public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ValidateUserId(userId); + var key = GetKey(agent, conversationId, userId); if (!this._sessions.TryGetValue(key, out var existingSession)) { @@ -65,11 +75,19 @@ private static string GetKey(AIAgent agent, string conversationId, string? userI key += $"a-{agent.Name}:"; } - if (!string.IsNullOrWhiteSpace(userId)) + if (userId is not null) { key += $"u-{userId}:"; } return key + $"c-{conversationId}"; } + + private static void ValidateUserId(string? userId) + { + if (userId is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + } + } } 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..a25d17ba258 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 supply the session's user 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 supply the session's user 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..e9abb4e6af2 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,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -9,6 +10,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 +30,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureStorage; /// default. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AzureBlobAgentSessionStore : AgentSessionStore { private const int MaxBlobNameLength = 1024; @@ -43,6 +46,7 @@ public sealed class AzureBlobAgentSessionStore : AgentSessionStore private readonly string _agentKey; private readonly string? _blobNamePrefix; private readonly bool _createContainerIfNotExists; + private readonly bool _enableLegacyKeyFallback; private Task? _containerInitializationTask; /// @@ -64,6 +68,7 @@ public AzureBlobAgentSessionStore( options ??= new AzureBlobAgentSessionStoreOptions(); this._createContainerIfNotExists = options.CreateContainerIfNotExists; + this._enableLegacyKeyFallback = options.EnableLegacyKeyFallback; this._blobNamePrefix = NormalizePrefix(options.BlobNamePrefix); if (this._blobNamePrefix is { Length: > MaxBlobNameLength - BaseBlobNameLength - 1 }) @@ -77,18 +82,20 @@ public AzureBlobAgentSessionStore( /// public override async ValueTask SaveSessionAsync( AIAgent agent, - string sessionStoreId, + string conversationId, AgentSession session, + string? userId, CancellationToken cancellationToken = default) { Throw.IfNull(agent); - Throw.IfNull(sessionStoreId); + Throw.IfNull(conversationId); Throw.IfNull(session); + ValidateUserId(userId); 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(conversationId, userId)); await blobClient.UploadAsync( BinaryData.FromString(serializedSession.GetRawText()), s_uploadOptions, @@ -96,50 +103,48 @@ await blobClient.UploadAsync( } /// - public override async ValueTask GetSessionAsync( + public override async ValueTask GetSessionAsync( AIAgent agent, - string sessionStoreId, + string conversationId, + string? userId, CancellationToken cancellationToken = default) { Throw.IfNull(agent); - Throw.IfNull(sessionStoreId); + Throw.IfNull(conversationId); + ValidateUserId(userId); await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false); - BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId)); - - try - { - Response response = await blobClient.DownloadContentAsync(cancellationToken).ConfigureAwait(false); - using JsonDocument document = JsonDocument.Parse(response.Value.Content); - return await agent.DeserializeSessionAsync(document.RootElement, cancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound.ToString()) + AgentSession? session = await this.TryGetSessionAsync( + agent, + this.GetBlobName(conversationId, userId), + cancellationToken).ConfigureAwait(false); + if (session is not null || !this._enableLegacyKeyFallback) { - return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + return session; } + + return await this.TryGetSessionAsync( + agent, + this.GetLegacyBlobName(conversationId, userId), + cancellationToken).ConfigureAwait(false); } - /// - public override async ValueTask DeleteSessionAsync( + private async ValueTask TryGetSessionAsync( AIAgent agent, - string sessionStoreId, - CancellationToken cancellationToken = default) + string blobName, + CancellationToken cancellationToken) { - Throw.IfNull(agent); - Throw.IfNull(sessionStoreId); - - BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId)); - + BlobClient blobClient = this._containerClient.GetBlobClient(blobName); try { - await blobClient.DeleteIfExistsAsync( - DeleteSnapshotsOption.IncludeSnapshots, - cancellationToken: cancellationToken).ConfigureAwait(false); + Response response = await blobClient.DownloadContentAsync(cancellationToken).ConfigureAwait(false); + using JsonDocument document = JsonDocument.Parse(response.Value.Content); + return await agent.DeserializeSessionAsync(document.RootElement, cancellationToken: cancellationToken).ConfigureAwait(false); } - catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.ContainerNotFound.ToString()) + catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound.ToString()) { - // A missing container cannot contain the requested session, so deletion remains idempotent. + return null; } } @@ -177,9 +182,22 @@ 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(string conversationId, string? userId) + { + string sessionKey = ComputeKey(BuildLogicalKey(conversationId, userId)); + string baseName = $"v2/{this._agentKey}/{sessionKey}.json"; + + return this._blobNamePrefix is null + ? baseName + : $"{this._blobNamePrefix}/{baseName}"; + } + + internal string GetLegacyBlobName(string conversationId, string? userId) { - string sessionKey = ComputeKey(sessionStoreId); + string legacyConversationId = userId is null + ? conversationId + : $"{EscapeIsolationKey(userId)}::{conversationId}"; + string sessionKey = ComputeKey(legacyConversationId); string baseName = $"v1/{this._agentKey}/{sessionKey}.json"; return this._blobNamePrefix is null @@ -187,6 +205,36 @@ private string GetBlobName(string sessionStoreId) : $"{this._blobNamePrefix}/{baseName}"; } + private static string BuildLogicalKey(string conversationId, string? userId) + { + StringBuilder builder = new(); + AppendComponent(builder, 'u', userId); + AppendComponent(builder, 'c', conversationId); + return builder.ToString(); + } + + 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('|'); + } + + private static string EscapeIsolationKey(string userId) + => userId.Replace("\\", "\\\\").Replace(":", "\\:"); + + private static void ValidateUserId(string? userId) + { + if (userId is not null) + { + _ = Throw.IfNullOrWhitespace(userId); + } + } + private static async Task WaitWithCancellationAsync(Task task, CancellationToken cancellationToken) { if (task.IsCompleted || !cancellationToken.CanBeCanceled) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStoreOptions.cs index 76f4258827b..8fcbd767ffd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStoreOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStoreOptions.cs @@ -16,6 +16,17 @@ public sealed class AzureBlobAgentSessionStoreOptions /// public bool CreateContainerIfNotExists { get; set; } = true; + /// + /// Gets or sets a value indicating whether reads may fall back to the legacy version 1 blob key. + /// + /// + /// Defaults to because the legacy key can map a scoped session and an unscoped + /// session to the same blob. Enable this only during a controlled migration after every application + /// instance writes the current key format and only when scoped and unscoped session identifiers cannot + /// coexist. Sessions loaded through the fallback are written with the current key on their next save. + /// + public bool EnableLegacyKeyFallback { get; set; } + /// /// Gets or sets the blob name prefix to use for organizing sessions. /// 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..b3af3cce6b6 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,8 @@ + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs index ac54968cdce..3be0e54a4e2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs @@ -55,7 +55,11 @@ public ValueTask GetOrCreateSessionAsync(string conversationId, Ca _ = Throw.IfNullOrWhitespace(conversationId); MarkFeatureUsed(); - return this._sessionStore.GetSessionAsync(this.InnerAgent, conversationId, cancellationToken); + return this._sessionStore.GetOrCreateSessionAsync( + this.InnerAgent, + conversationId, + userId: null, + cancellationToken: cancellationToken); } /// @@ -73,7 +77,12 @@ public ValueTask SaveSessionAsync(string conversationId, AgentSession session, C _ = Throw.IfNull(session); MarkFeatureUsed(); - return this._sessionStore.SaveSessionAsync(this.InnerAgent, conversationId, session, cancellationToken); + return this._sessionStore.SaveSessionAsync( + this.InnerAgent, + conversationId, + session, + userId: null, + cancellationToken: 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/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs index f340cec8af5..cfd7b801bd1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -1,8 +1,10 @@ // 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; @@ -23,6 +25,7 @@ namespace Microsoft.Agents.AI.Hosting; /// interface. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public abstract class DelegatingAgentSessionStore : AgentSessionStore { /// @@ -53,33 +56,19 @@ 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, + string conversationId, + string? userId, + CancellationToken cancellationToken = default) + => this.InnerStore.GetSessionAsync(agent, conversationId, userId, 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, + string conversationId, + AgentSession session, + string? userId, + CancellationToken cancellationToken = default) + => this.InnerStore.SaveSessionAsync(agent, conversationId, session, userId, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index a13eab90384..1096361777e 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 supplies the per-user 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 supplies the per-user 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 supplies the per-user 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..6b5a3d11cf5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -1,16 +1,18 @@ // 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.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 supplies the per-user partition key from an +/// . /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore { private readonly AgentIsolationKeyProvider? _keyProvider; @@ -54,63 +56,57 @@ 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. + /// Resolves the user partition passed to the inner store. A key supplied by the provider takes precedence + /// over the caller value because it represents the current hosting context. /// - /// 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) - { - string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); - if (key == null) - { - return bareSessionStoreId; - } - - return $"{EscapeIsolationKey(key)}::{bareSessionStoreId}"; - } + private async ValueTask GetUserIdAsync(string? userId, CancellationToken cancellationToken) + => await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false) ?? userId; /// - public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + string conversationId, + string? userId, + CancellationToken cancellationToken = default) { - string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - return await this.InnerStore.GetSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false); + string? resolvedUserId = await this.GetUserIdAsync(userId, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetSessionAsync(agent, conversationId, resolvedUserId, cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + public override async ValueTask GetOrCreateSessionAsync( + AIAgent agent, + string conversationId, + string? userId, + CancellationToken cancellationToken = default) { - string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - await this.InnerStore.SaveSessionAsync(agent, scopedSessionStoreId, session, cancellationToken).ConfigureAwait(false); + string? resolvedUserId = await this.GetUserIdAsync(userId, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetOrCreateSessionAsync(agent, conversationId, resolvedUserId, cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + string conversationId, + AgentSession session, + string? userId, + CancellationToken cancellationToken = default) { - string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - await this.InnerStore.DeleteSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false); + string? resolvedUserId = await this.GetUserIdAsync(userId, cancellationToken).ConfigureAwait(false); + await this.InnerStore.SaveSessionAsync(agent, conversationId, session, resolvedUserId, 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..4b93cd830f4 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 caller supplied userId is passed through when the isolation key is + /// absent. A caller value allows unscoped access to the underlying session store. + /// 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..31cecc76dba 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,66 @@ 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, string? UserId, string ConversationId), JsonElement> _sessions = new(); /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + string conversationId, + AgentSession session, + string? userId, + CancellationToken cancellationToken = default) { - var key = GetKey(sessionStoreId, agent.Id); - this._threads[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); + _ = Throw.IfNull(agent); + _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(session); + ValidateUserId(userId); + + var key = GetKey(agent, conversationId, userId); + this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + string conversationId, + string? userId, + CancellationToken cancellationToken = default) { - var key = GetKey(sessionStoreId, agent.Id); - JsonElement? sessionContent = this._threads.TryGetValue(key, out var existingSession) ? existingSession : null; + _ = Throw.IfNull(agent); + _ = Throw.IfNullOrWhitespace(conversationId); + ValidateUserId(userId); - return sessionContent switch - { - null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false), - _ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false), - }; + var key = GetKey(agent, conversationId, userId); + return this._sessions.TryGetValue(key, out JsonElement existingSession) + ? await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false) + : null; } - /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + private static (string AgentId, string? UserId, string ConversationId) GetKey( + AIAgent agent, + string conversationId, + string? userId) + => (agent.Id, userId, conversationId); + + private static void ValidateUserId(string? userId) { - this._threads.TryRemove(GetKey(sessionStoreId, agent.Id), out _); - return default; + if (userId is not null) + { + _ = Throw.IfNullOrWhitespace(userId); + } } - - private static string GetKey(string sessionStoreId, string agentId) => $"{agentId}:{sessionStoreId}"; } 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..2d436c5f7d0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs @@ -1,31 +1,37 @@ // 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, + string conversationId, + AgentSession session, + string? userId, + CancellationToken cancellationToken = default) { return default; } /// - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override ValueTask GetSessionAsync( + AIAgent agent, + string conversationId, + string? userId, + 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/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs new file mode 100644 index 00000000000..523d55ee3af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs @@ -0,0 +1,103 @@ +// 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(); + + // Act + AgentSession session = await store.GetOrCreateSessionAsync( + agent.Object, + "conversation-1", + "user-1"); + + // Assert + Assert.Same(storedSession, session); + Assert.Equal("conversation-1", store.LastConversationId); + Assert.Equal("user-1", store.LastUserId); + 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(); + agent.Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(createdSession); + + // Act + AgentSession session = await store.GetOrCreateSessionAsync( + agent.Object, + "conversation-1", + userId: null); + + // 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!, "conversation-1", userId: null).AsTask()); + } + + private sealed class TestAgentSessionStore(AgentSession? session) : AgentSessionStore + { + public string? LastConversationId { get; private set; } + + public string? LastUserId { get; private set; } + + public override ValueTask GetSessionAsync( + AIAgent agent, + string conversationId, + string? userId, + CancellationToken cancellationToken = default) + { + this.LastConversationId = conversationId; + this.LastUserId = userId; + return new(session); + } + + public override ValueTask SaveSessionAsync( + AIAgent agent, + string conversationId, + AgentSession session, + string? userId, + CancellationToken cancellationToken = default) + => default; + } + + private sealed class TestAgentSession : AgentSession; +} 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..8f1aeb79153 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -1585,11 +1585,12 @@ 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 @@ -1597,6 +1598,7 @@ public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1621,6 +1623,7 @@ public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() It.IsAny(), It.Is(s => s == "ctx-stream"), It.IsAny(), + It.Is(u => u == null), It.IsAny()), Times.Once); } @@ -1633,11 +1636,12 @@ 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 @@ -1645,6 +1649,7 @@ public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSave It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1666,6 +1671,7 @@ public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSave It.IsAny(), It.Is(s => s == "ctx"), It.IsAny(), + It.Is(u => u == null), It.IsAny()), Times.Once); } @@ -1755,11 +1761,12 @@ 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 @@ -1767,6 +1774,7 @@ public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1792,6 +1800,7 @@ public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() x => x.GetSessionAsync( It.IsAny(), It.Is(s => s == "ctx-1"), + It.Is(u => u == null), It.IsAny()), Times.Once); mockSessionStore.Verify( @@ -1799,6 +1808,7 @@ public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() It.IsAny(), It.Is(s => s == "ctx-1"), It.IsAny(), + It.Is(u => u == null), It.IsAny()), Times.Once); } @@ -1960,12 +1970,21 @@ 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(), + 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(), + It.IsAny())) .Returns(ValueTask.CompletedTask); Mock agentMock = new() { CallBase = true }; @@ -2002,6 +2021,7 @@ await Assert.ThrowsAsync(() => It.IsAny(), It.Is(s => s == "ctx"), It.IsAny(), + It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2014,12 +2034,21 @@ 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(), + 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(), + It.IsAny())) .Returns(ValueTask.CompletedTask); Mock agentMock = new() { CallBase = true }; @@ -2056,6 +2085,7 @@ await Assert.ThrowsAsync(() => It.IsAny(), It.Is(s => s == "ctx-stream"), It.IsAny(), + It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2068,12 +2098,21 @@ 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(), + 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(), + It.IsAny())) .Returns(ValueTask.CompletedTask); Mock agentMock = new() { CallBase = true }; @@ -2116,6 +2155,7 @@ await Assert.ThrowsAsync(() => It.IsAny(), It.Is(s => s == "ctx-cont"), It.IsAny(), + It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2128,12 +2168,21 @@ 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(), + 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(), + It.IsAny())) .Returns(ValueTask.CompletedTask); AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); @@ -2159,6 +2208,7 @@ await handler.ExecuteAsync( It.IsAny(), It.Is(s => s == "ctx"), It.IsAny(), + It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2171,12 +2221,21 @@ 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(), + 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(), + It.IsAny())) .Returns(ValueTask.CompletedTask); AgentResponseUpdate[] updates = [new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }]; @@ -2202,6 +2261,7 @@ await handler.ExecuteAsync( It.IsAny(), It.Is(s => s == "ctx-stream"), It.IsAny(), + It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2214,12 +2274,21 @@ 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(), + 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(), + It.IsAny())) .Returns(ValueTask.CompletedTask); AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); @@ -2250,6 +2319,7 @@ await handler.ExecuteAsync( It.IsAny(), It.Is(s => s == "ctx-cont"), It.IsAny(), + It.Is(u => u == null), 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..319104b2f70 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,11 +423,12 @@ 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 @@ -435,6 +436,7 @@ public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUs It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -453,6 +455,7 @@ public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUs 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..82eb73ec92d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Azure; @@ -78,10 +79,11 @@ public async Task SaveAndGetSessionAsync_PersistsAcrossStoreAndAgentInstancesAsy var loadingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); // Act - await savingStore.SaveSessionAsync(savingAgent, "session-1", session); - AgentSession restored = await loadingStore.GetSessionAsync(loadingAgent, "session-1"); + await savingStore.SaveSessionAsync(savingAgent, "session-1", session, userId: "user-1"); + AgentSession? restored = await loadingStore.GetSessionAsync(loadingAgent, "session-1", userId: "user-1"); // Assert + Assert.NotNull(restored); Assert.Equal("saved", restored.StateBag.GetValue("marker")); } @@ -101,10 +103,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, firstId, firstSession, userId: null); + await store.SaveSessionAsync(agent, secondId, secondSession, userId: null); + AgentSession? restoredFirst = await store.GetSessionAsync(agent, firstId, userId: null); + AgentSession? restoredSecond = await store.GetSessionAsync(agent, secondId, userId: null); List blobNames = []; await foreach (BlobItem blob in this._containerClient.GetBlobsAsync()) { @@ -112,6 +114,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 +123,104 @@ 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"); + + // Act + AgentSession? restored = await store.GetSessionAsync(agent, "missing", userId: "user-1"); + + // Assert + Assert.Null(restored); + } + + [Fact] + public async Task SaveAndGetSessionAsync_IsolatesUsersAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + AgentSession first = await agent.CreateSessionAsync(); + first.StateBag.SetValue("marker", "first"); + AgentSession second = await agent.CreateSessionAsync(); + second.StateBag.SetValue("marker", "second"); + + // Act + await store.SaveSessionAsync(agent, "session-1", first, userId: "user-1"); + await store.SaveSessionAsync(agent, "session-1", second, userId: "user-2"); + AgentSession? restoredFirst = await store.GetSessionAsync(agent, "session-1", userId: "user-1"); + AgentSession? restoredSecond = await store.GetSessionAsync(agent, "session-1", userId: "user-2"); + + // 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"); + 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, "conversation", scoped, userId: "tenant"); + await store.SaveSessionAsync(agent, "tenant::conversation", unscoped, userId: null); + AgentSession? restoredScoped = await store.GetSessionAsync(agent, "conversation", userId: "tenant"); + AgentSession? restoredUnscoped = await store.GetSessionAsync(agent, "tenant::conversation", userId: null); + + // Assert + Assert.NotNull(restoredScoped); + Assert.NotNull(restoredUnscoped); + Assert.Equal("scoped", restoredScoped.StateBag.GetValue("marker")); + Assert.Equal("unscoped", restoredUnscoped.StateBag.GetValue("marker")); + } + + [Fact] + public async Task GetSessionAsync_LegacyScopedKey_RestoresSessionAsync() + { + // Arrange + const string UserId = @"domain\user:1"; + const string ConversationId = "session-1"; + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new AzureBlobAgentSessionStore( + this._containerClient, + "assistant", + new AzureBlobAgentSessionStoreOptions { EnableLegacyKeyFallback = true }); AgentSession session = await agent.CreateSessionAsync(); - session.StateBag.SetValue("marker", "saved"); - await store.SaveSessionAsync(agent, "session-to-delete", session); + session.StateBag.SetValue("marker", "legacy"); + await this.WriteLegacySessionAsync(store, agent, ConversationId, session, UserId); + + // Act + AgentSession? restored = await store.GetSessionAsync(agent, ConversationId, UserId); + + // Assert + Assert.NotNull(restored); + Assert.Equal("legacy", restored.StateBag.GetValue("marker")); + } + + [Fact] + public async Task GetSessionAsync_LegacyKeyFallbackDisabled_ReturnsNullAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + AgentSession session = await agent.CreateSessionAsync(); + await this.WriteLegacySessionAsync(store, agent, "session-1", session, "user-1"); // 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, "session-1", "user-1"); // Assert - Assert.Null(restored.StateBag.GetValue("marker")); + Assert.Null(restored); } [Fact] @@ -149,9 +235,9 @@ public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync() 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, "session-1", first, userId: null); + await store.SaveSessionAsync(agent, "session-1", second, userId: null); + AgentSession? restored = await store.GetSessionAsync(agent, "session-1", userId: null); List blobs = []; await foreach (BlobItem blob in this._containerClient.GetBlobsAsync()) { @@ -159,6 +245,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 +264,7 @@ public async Task GetSessionAsync_MissingContainerWithoutAutoCreatePropagatesErr // Act RequestFailedException exception = await Assert.ThrowsAsync( - () => store.GetSessionAsync(agent, "session-1").AsTask()); + () => store.GetSessionAsync(agent, "session-1", userId: null).AsTask()); // Assert Assert.Equal(BlobErrorCode.ContainerNotFound.ToString(), exception.ErrorCode); @@ -191,15 +278,18 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshotsAsync() var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); AgentSession original = await agent.CreateSessionAsync(); original.StateBag.SetValue("marker", "saved"); - await store.SaveSessionAsync(agent, "session-1", original); + await store.SaveSessionAsync(agent, "session-1", original, userId: null); // Act - AgentSession first = await store.GetSessionAsync(agent, "session-1"); - AgentSession second = await store.GetSessionAsync(agent, "session-1"); + AgentSession? first = await store.GetSessionAsync(agent, "session-1", userId: null); + AgentSession? second = await store.GetSessionAsync(agent, "session-1", userId: null); + 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, "session-1", userId: null); // Assert + Assert.NotNull(third); Assert.NotSame(first, second); Assert.Equal("saved", second.StateBag.GetValue("marker")); Assert.Equal("saved", third.StateBag.GetValue("marker")); @@ -217,7 +307,7 @@ 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, $"session-{index}", session, userId: null).AsTask()); } // Act @@ -277,6 +367,19 @@ public void Constructor_BlobNamePrefixExceedsAzureLimit_Throws() () => new AzureBlobAgentSessionStore(this._containerClient, "assistant", options)); } + private async Task WriteLegacySessionAsync( + AzureBlobAgentSessionStore store, + AIAgent agent, + string conversationId, + AgentSession session, + string? userId) + { + JsonElement serializedSession = await agent.SerializeSessionAsync(session); + await this._containerClient.CreateIfNotExistsAsync(); + BlobClient blobClient = this._containerClient.GetBlobClient(store.GetLegacyBlobName(conversationId, userId)); + await blobClient.UploadAsync(BinaryData.FromString(serializedSession.GetRawText())); + } + 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..b561e57d85c 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, sessionStoreId, userId: null); 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, secondSessionStoreId, userId: null); 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, sessionStoreId, userId: null); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); - await sessionStore.SaveSessionAsync(agent, responseId, session); + await sessionStore.SaveSessionAsync(agent, responseId, session, userId: null); 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..0cf5e18058a 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, sessionStoreId, userId: null); 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, secondSessionStoreId, userId: null); 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, sessionStoreId, userId: null); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); - await sessionStore.SaveSessionAsync(agent, responseId, session); + await sessionStore.SaveSessionAsync(agent, responseId, session, userId: null); 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..aaa659a877a 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,11 @@ 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, + sessionStoreId, + userId: null, + cancellationToken: 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 +238,22 @@ private async Task StartAgentHostAsync(IChatClient chatClient) await http.Response.WriteAsync(frame, ct); } - await sessionStore.SaveSessionAsync(agent, saveId, session, ct); + await sessionStore.SaveSessionAsync( + agent, + saveId, + session, + userId: null, + cancellationToken: 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, + saveId, + session, + userId: null, + cancellationToken: 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 index e5f452aa795..8eac6eaecc2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -28,11 +28,20 @@ public DelegatingAgentSessionStoreTests() // Setup inner store mock this._innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(this._testSession); this._innerStoreMock - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); @@ -73,12 +82,14 @@ public async Task GetSessionAsyncDelegatesToInnerStoreAsync() { // Arrange const string ExpectedConversationId = "test-conversation-id"; + const string ExpectedUserId = "test-user-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(u => u == ExpectedUserId), It.Is(ct => ct == expectedCancellationToken))) .ReturnsAsync(this._testSession); @@ -86,6 +97,7 @@ public async Task GetSessionAsyncDelegatesToInnerStoreAsync() var session = await this._delegatingStore.GetSessionAsync( this._agentMock.Object, ExpectedConversationId, + ExpectedUserId, expectedCancellationToken); // Assert @@ -94,6 +106,7 @@ public async Task GetSessionAsyncDelegatesToInnerStoreAsync() x => x.GetSessionAsync( this._agentMock.Object, ExpectedConversationId, + ExpectedUserId, expectedCancellationToken), Times.Once); } @@ -106,6 +119,7 @@ public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() { // Arrange const string ExpectedConversationId = "test-conversation-id"; + const string ExpectedUserId = "test-user-id"; var expectedCancellationToken = new CancellationToken(); var expectedSession = new TestAgentSession(); @@ -114,6 +128,7 @@ public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() It.Is(a => a == this._agentMock.Object), It.Is(c => c == ExpectedConversationId), It.Is(s => s == expectedSession), + It.Is(u => u == ExpectedUserId), It.Is(ct => ct == expectedCancellationToken))) .Returns(ValueTask.CompletedTask); @@ -122,6 +137,7 @@ await this._delegatingStore.SaveSessionAsync( this._agentMock.Object, ExpectedConversationId, expectedSession, + ExpectedUserId, expectedCancellationToken); // Assert @@ -130,6 +146,7 @@ await this._delegatingStore.SaveSessionAsync( this._agentMock.Object, ExpectedConversationId, expectedSession, + ExpectedUserId, expectedCancellationToken), Times.Once); } @@ -142,17 +159,21 @@ public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() { // Arrange const string ExpectedConversationId = "test-conversation-id"; - var taskCompletionSource = new TaskCompletionSource(); + var taskCompletionSource = new TaskCompletionSource(); var innerStoreMock = new Mock(); innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new ValueTask(taskCompletionSource.Task)); + .Setup(x => x.GetSessionAsync( + It.IsAny(), + 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); + var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId, userId: null); // Assert Assert.False(resultTask.IsCompleted); @@ -161,6 +182,34 @@ public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() Assert.Same(this._testSession, await resultTask); } + /// + /// Verify that GetOrCreateSessionAsync honors a derived GetSessionAsync override. + /// + [Fact] + public async Task GetOrCreateSessionAsyncUsesOverriddenGetSessionAsyncAsync() + { + // Arrange + const string ExpectedConversationId = "test-conversation-id"; + const string ExpectedUserId = "test-user-id"; + var store = new OverridingGetSessionStore(this._innerStoreMock.Object, this._testSession); + + // Act + AgentSession session = await store.GetOrCreateSessionAsync( + this._agentMock.Object, + ExpectedConversationId, + ExpectedUserId); + + // Assert + Assert.Same(this._testSession, session); + this._innerStoreMock.Verify( + x => x.GetOrCreateSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + /// /// Verify that SaveSessionAsync awaits the inner store's completion before returning. /// @@ -174,13 +223,22 @@ public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() var innerStoreMock = new Mock(); innerStoreMock - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + 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); + var resultTask = delegatingStore.SaveSessionAsync( + this._agentMock.Object, + ExpectedConversationId, + expectedSession, + userId: null); // Assert Assert.False(resultTask.IsCompleted); @@ -191,182 +249,6 @@ public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() #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 /// @@ -377,24 +259,15 @@ private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStor 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 + private sealed class OverridingGetSessionStore(AgentSessionStore innerStore, AgentSession session) + : DelegatingAgentSessionStore(innerStore) { - 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(); + public override ValueTask GetSessionAsync( + AIAgent agent, + string conversationId, + string? userId, + CancellationToken cancellationToken = default) + => new(session); } private sealed class TestAgentSession : AgentSession; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs index 8af3fc43ece..a5587e2a525 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -2,76 +2,44 @@ 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() + public async Task GetSessionAsync_MissingSession_ReturnsNullAsync() { // 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(); + var agent = new Mock(); - // Act & Assert - await store.SaveSessionAsync(agent.Object, "s1", new TestAgentSession()); - Assert.Same(restoredSession, await store.GetSessionAsync(agent.Object, "s1")); + // Act + AgentSession? session = await store.GetSessionAsync(agent.Object, "missing", userId: null); - await store.DeleteSessionAsync(agent.Object, "s1"); - Assert.Same(createdSession, await store.GetSessionAsync(agent.Object, "s1")); + // Assert + Assert.Null(session); } - [Fact] - public async Task DeleteSessionAsync_UnknownId_DoesNotThrowAsync() + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task GetSessionAsync_BlankUserId_ThrowsAsync(string userId) { // 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 & Assert - await Assert.ThrowsAsync(() => store.DeleteSessionAsync(new Mock().Object, "any").AsTask()); + // Act and assert + await Assert.ThrowsAsync( + () => store.GetSessionAsync(agent.Object, "conversation-1", userId).AsTask()); } [Fact] @@ -84,13 +52,15 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshot_ForConcurrentBranch AgentSession original = await agent.CreateSessionAsync(); original.StateBag.SetValue("marker", "v1"); - await store.SaveSessionAsync(agent, "s1", original); + await store.SaveSessionAsync(agent, "s1", original, userId: "user-1"); // 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, "s1", userId: "user-1"); + AgentSession? branchB = await store.GetSessionAsync(agent, "s1", userId: "user-1"); // 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 +69,29 @@ 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, "s1", userId: "user-1"); + 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(); + AgentSession session = await agent.CreateSessionAsync(); + session.StateBag.SetValue("marker", "user-1"); + await store.SaveSessionAsync(agent, "s1", session, userId: "user-1"); + + // Act + AgentSession? matchingUser = await store.GetSessionAsync(agent, "s1", userId: "user-1"); + AgentSession? differentUser = await store.GetSessionAsync(agent, "s1", userId: "user-2"); + + // 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..486b07512fc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -15,419 +15,191 @@ 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_PassesConversationAndIsolationKeySeparatelyAsync() { // 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); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - this._innerStoreMock.Verify( - x => x.GetSessionAsync( + var expectedSession = new TestAgentSession(); + this._innerStoreMock + .Setup(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); + TestConversationId, + TestIsolationKey, + It.IsAny())) + .ReturnsAsync(expectedSession); var store = new IsolationKeyScopedAgentSessionStore( this._innerStoreMock.Object, - provider, - new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); + new TestAgentIsolationKeyProvider(TestIsolationKey)); - // Act & Assert - var exception = await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); + // Act + AgentSession? session = await store.GetSessionAsync( + this._agentMock.Object, + TestConversationId, + userId: null); - Assert.Contains("Agent isolation key is required", exception.Message); + // Assert + 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_PassesConversationAndIsolationKeySeparatelyAsync() { // 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 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); + session, + TestIsolationKey, + It.IsAny())) + .Returns(ValueTask.CompletedTask); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + new TestAgentIsolationKeyProvider(TestIsolationKey)); // Act - var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + await store.SaveSessionAsync( + this._agentMock.Object, + TestConversationId, + session, + userId: null); // 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_PassesIsolationKeyToInnerStoreAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); - var sessionToSave = new TestAgentSession(); + var expectedSession = new TestAgentSession(); + this._innerStoreMock + .Setup(x => x.GetOrCreateSessionAsync( + this._agentMock.Object, + TestConversationId, + TestIsolationKey, + It.IsAny())) + .ReturnsAsync(expectedSession); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + new TestAgentIsolationKeyProvider(TestIsolationKey)); // Act - await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + AgentSession session = await store.GetOrCreateSessionAsync( + this._agentMock.Object, + TestConversationId, + userId: null); // 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, + new TestAgentIsolationKeyProvider(null), new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); - var sessionToSave = new TestAgentSession(); - // Act & Assert + // Act var exception = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave)); + () => store.GetSessionAsync(this._agentMock.Object, TestConversationId, userId: null).AsTask()); + // Assert 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() + public async Task SaveSessionAsync_StrictModeWithoutIsolationKey_ThrowsAsync() { // 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); + new TestAgentIsolationKeyProvider(null), + new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); // 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.SaveSessionAsync( 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); + TestConversationId, + new TestAgentSession(), + userId: null).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_NonStrictModePreservesCallerUserIdWhenKeyIsMissingAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + const string CallerUserId = "caller-user"; + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + this._agentMock.Object, + TestConversationId, + CallerUserId, + It.IsAny())) + .ReturnsAsync((AgentSession?)null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + new TestAgentIsolationKeyProvider(null), + new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); // Act - var result = store.GetService(); + await store.GetSessionAsync(this._agentMock.Object, TestConversationId, CallerUserId); // 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_IsolationKeyOverridesCallerUserIdAsync() { // Arrange - var concreteInnerStore = new ConcreteAgentSessionStore(); - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider); + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + this._agentMock.Object, + TestConversationId, + TestIsolationKey, + It.IsAny())) + .ReturnsAsync((AgentSession?)null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + new TestAgentIsolationKeyProvider(TestIsolationKey)); // Act - var result = store.GetService(); + await store.GetSessionAsync(this._agentMock.Object, TestConversationId, "caller-user"); // Assert - Assert.Same(concreteInnerStore, result); + this._innerStoreMock.VerifyAll(); } - #endregion - - #region Helper Classes - - /// - /// 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 }