From 21d1ee9617d820632df3009a4457f7574e68f9d3 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:31:57 +0100 Subject: [PATCH 1/7] feat(dotnet): share agent session store abstraction --- ...sted-per-user-session-storage-isolation.md | 6 + .../0032-dotnet-hosting-protocol-helpers.md | 3 + .../0039-shared-agent-session-store.md | 73 +++ .../003-dotnet-hosting-protocol-helpers.md | 75 ++- .../local_responses/Server/Program.cs | 14 +- .../local_responses/Server/README.md | 6 +- .../AgentSessionStore.cs | 83 ++++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 5 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 5 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 5 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 5 + .../netstandard2.0/PublicAPI.Unshipped.txt | 5 + .../AgentSessionStore.cs | 97 ---- .../FileSystemAgentSessionStore.cs | 12 +- .../FoundryAgentSessionStore.cs | 12 +- .../InMemoryAgentSessionStore.cs | 20 +- .../A2AServerServiceCollectionExtensions.cs | 20 +- .../Microsoft.Agents.AI.Hosting.A2A.csproj | 1 + .../AGUIEndpointRouteBuilderExtensions.cs | 21 +- ...t.Agents.AI.Hosting.AGUI.AspNetCore.csproj | 1 + ...ClaimsIdentityAgentIsolationKeyProvider.cs | 9 +- .../AzureBlobHostedAgentBuilderExtensions.cs | 4 +- .../Blob/AzureBlobAgentSessionStore.cs | 74 +-- ...soft.Agents.AI.Hosting.AzureStorage.csproj | 3 + .../AIHostAgent.cs | 13 +- .../AgentSessionStore.cs | 138 ------ .../DelegatingAgentSessionStore.cs | 47 +- .../HostedAgentBuilderExtensions.cs | 8 +- .../IsolationKeyScopedAgentSessionStore.cs | 80 ++-- ...lationKeyScopedAgentSessionStoreOptions.cs | 6 +- .../Local/InMemoryAgentSessionStore.cs | 73 +-- .../Microsoft.Agents.AI.Hosting.csproj | 3 + .../NoopAgentSessionStore.cs | 26 +- .../AgentSessionStoreTests.cs | 103 +++++ .../A2AAgentHandlerTests.cs | 112 ++++- ...AServerServiceCollectionExtensionsTests.cs | 7 +- .../AzureBlobAgentSessionStoreTests.cs | 95 +++- ...reBlobHostedAgentBuilderExtensionsTests.cs | 1 - .../AnthropicResponsesHostingLiveTests.cs | 8 +- .../OpenAIResponsesHostingLiveTests.cs | 8 +- .../OpenAIResponsesHostingTests.cs | 20 +- ...sIdentityAgentIsolationKeyProviderTests.cs | 6 +- .../DelegatingAgentSessionStoreTests.cs | 276 +++--------- .../InMemoryAgentSessionStoreTests.cs | 101 ++--- ...solationKeyScopedAgentSessionStoreTests.cs | 426 ++++-------------- 45 files changed, 1017 insertions(+), 1099 deletions(-) create mode 100644 docs/decisions/0039-shared-agent-session-store.md create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs 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..62d2af8e8ec --- /dev/null +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -0,0 +1,73 @@ +--- +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. + +## 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. + +## 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..83f3f734fe7 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,14 @@ 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. `HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory `sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but @@ -171,8 +184,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 +206,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 +223,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..f0472596c0e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs @@ -0,0 +1,83 @@ +// 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. + /// + 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. + 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..e46158f8653 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; @@ -77,18 +80,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,18 +101,30 @@ 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)); + return await this.TryGetSessionAsync( + agent, + this.GetBlobName(conversationId, userId), + cancellationToken).ConfigureAwait(false); + } + private async ValueTask TryGetSessionAsync( + AIAgent agent, + string blobName, + CancellationToken cancellationToken) + { + BlobClient blobClient = this._containerClient.GetBlobClient(blobName); try { Response response = await blobClient.DownloadContentAsync(cancellationToken).ConfigureAwait(false); @@ -116,30 +133,7 @@ public override async ValueTask GetSessionAsync( } catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound.ToString()) { - return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - } - } - - /// - public override async ValueTask DeleteSessionAsync( - AIAgent agent, - string sessionStoreId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(agent); - Throw.IfNull(sessionStoreId); - - BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId)); - - try - { - await blobClient.DeleteIfExistsAsync( - DeleteSnapshotsOption.IncludeSnapshots, - cancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.ContainerNotFound.ToString()) - { - // A missing container cannot contain the requested session, so deletion remains idempotent. + return null; } } @@ -177,9 +171,12 @@ 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(sessionStoreId); + string scopedConversationId = userId is null + ? conversationId + : $"{EscapeIsolationKey(userId)}::{conversationId}"; + string sessionKey = ComputeKey(scopedConversationId); string baseName = $"v1/{this._agentKey}/{sessionKey}.json"; return this._blobNamePrefix is null @@ -187,6 +184,17 @@ private string GetBlobName(string sessionStoreId) : $"{this._blobNamePrefix}/{baseName}"; } + 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/Microsoft.Agents.AI.Hosting.AzureStorage.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj index d2652c12a7e..c2892c3e1cc 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 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..41311cd0917 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,27 @@ 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 GetOrCreateSessionAsync( + AIAgent agent, + string conversationId, + string? userId, + CancellationToken cancellationToken = default) + => this.InnerStore.GetOrCreateSessionAsync(agent, conversationId, userId, 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..e8af2a5d745 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs @@ -78,10 +78,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 +102,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 +113,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 +122,66 @@ 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 GetSessionAsync_LegacyScopedKey_RestoresSessionAsync() + { + // Arrange + const string UserId = @"domain\user:1"; + const string ConversationId = "session-1"; + const string LegacyScopedConversationId = @"domain\\user\:1::session-1"; + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); AgentSession session = await agent.CreateSessionAsync(); - session.StateBag.SetValue("marker", "saved"); - await store.SaveSessionAsync(agent, "session-to-delete", session); + session.StateBag.SetValue("marker", "legacy"); + await store.SaveSessionAsync( + agent, + LegacyScopedConversationId, + session, + userId: null); // 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, ConversationId, UserId); // Assert - Assert.Null(restored.StateBag.GetValue("marker")); + Assert.NotNull(restored); + Assert.Equal("legacy", restored.StateBag.GetValue("marker")); } [Fact] @@ -149,9 +196,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 +206,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 +225,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 +239,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 +268,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 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..edc429e27d6 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,40 @@ public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() Assert.Same(this._testSession, await resultTask); } + /// + /// Verify that GetOrCreateSessionAsync delegates to a specialized inner store implementation. + /// + [Fact] + public async Task GetOrCreateSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + const string ExpectedConversationId = "test-conversation-id"; + const string ExpectedUserId = "test-user-id"; + this._innerStoreMock + .Setup(x => x.GetOrCreateSessionAsync( + this._agentMock.Object, + ExpectedConversationId, + ExpectedUserId, + It.IsAny())) + .ReturnsAsync(this._testSession); + + // Act + AgentSession session = await this._delegatingStore.GetOrCreateSessionAsync( + this._agentMock.Object, + ExpectedConversationId, + ExpectedUserId); + + // Assert + Assert.Same(this._testSession, session); + this._innerStoreMock.Verify( + x => x.GetOrCreateSessionAsync( + this._agentMock.Object, + ExpectedConversationId, + ExpectedUserId, + It.IsAny()), + Times.Once); + } + /// /// Verify that SaveSessionAsync awaits the inner store's completion before returning. /// @@ -174,13 +229,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 +255,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,26 +265,6 @@ 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 - { - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => new(new TestAgentSession()); - - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - } - private sealed class TestAgentSession : AgentSession; #endregion diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs index 8af3fc43ece..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 } From 973592fba81c66dff437a99991b2bac60f4f2893 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:07:48 +0100 Subject: [PATCH 2/7] fix(dotnet): address session store review findings --- .../0039-shared-agent-session-store.md | 7 ++ .../003-dotnet-hosting-protocol-helpers.md | 3 + .../AgentSessionStore.cs | 11 ++++ .../Blob/AzureBlobAgentSessionStore.cs | 46 ++++++++++++- .../Blob/AzureBlobAgentSessionStoreOptions.cs | 11 ++++ ...soft.Agents.AI.Hosting.AzureStorage.csproj | 4 ++ .../DelegatingAgentSessionStore.cs | 8 --- .../AzureBlobAgentSessionStoreTests.cs | 66 +++++++++++++++++-- .../DelegatingAgentSessionStoreTests.cs | 33 ++++++---- 9 files changed, 157 insertions(+), 32 deletions(-) diff --git a/docs/decisions/0039-shared-agent-session-store.md b/docs/decisions/0039-shared-agent-session-store.md index 62d2af8e8ec..cb3ec1bef1b 100644 --- a/docs/decisions/0039-shared-agent-session-store.md +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -52,6 +52,12 @@ passes the key from `AgentIsolationKeyProvider` as the `userId` argument while l 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: @@ -66,6 +72,7 @@ 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 diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 83f3f734fe7..67d94947ba5 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -144,6 +144,9 @@ application that needs concurrent runs against the same id to be serialized owns 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 `CheckpointInfo` carries no ordering, the holder remembers the head checkpoint per session so diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs index f0472596c0e..9d4f0148df9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs @@ -55,6 +55,12 @@ public abstract ValueTask SaveSessionAsync( /// 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, @@ -69,6 +75,11 @@ public abstract ValueTask SaveSessionAsync( /// 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, 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 e46158f8653..e9abb4e6af2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs @@ -46,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; /// @@ -67,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 }) @@ -113,10 +115,19 @@ await blobClient.UploadAsync( await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false); - return await this.TryGetSessionAsync( + AgentSession? session = await this.TryGetSessionAsync( agent, this.GetBlobName(conversationId, userId), cancellationToken).ConfigureAwait(false); + if (session is not null || !this._enableLegacyKeyFallback) + { + return session; + } + + return await this.TryGetSessionAsync( + agent, + this.GetLegacyBlobName(conversationId, userId), + cancellationToken).ConfigureAwait(false); } private async ValueTask TryGetSessionAsync( @@ -173,10 +184,20 @@ private async Task CreateContainerIfNotExistsAsync() private string GetBlobName(string conversationId, string? userId) { - string scopedConversationId = userId is null + 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 legacyConversationId = userId is null ? conversationId : $"{EscapeIsolationKey(userId)}::{conversationId}"; - string sessionKey = ComputeKey(scopedConversationId); + string sessionKey = ComputeKey(legacyConversationId); string baseName = $"v1/{this._agentKey}/{sessionKey}.json"; return this._blobNamePrefix is null @@ -184,6 +205,25 @@ private string GetBlobName(string conversationId, string? userId) : $"{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(":", "\\:"); 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 c2892c3e1cc..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 @@ -21,4 +21,8 @@ + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs index 41311cd0917..cfd7b801bd1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -63,14 +63,6 @@ protected DelegatingAgentSessionStore(AgentSessionStore innerStore) CancellationToken cancellationToken = default) => this.InnerStore.GetSessionAsync(agent, conversationId, userId, cancellationToken); - /// - public override ValueTask GetOrCreateSessionAsync( - AIAgent agent, - string conversationId, - string? userId, - CancellationToken cancellationToken = default) - => this.InnerStore.GetOrCreateSessionAsync(agent, conversationId, userId, cancellationToken); - /// public override ValueTask SaveSessionAsync( AIAgent agent, 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 e8af2a5d745..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; @@ -159,22 +160,44 @@ public async Task SaveAndGetSessionAsync_IsolatesUsersAsync() 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"; - const string LegacyScopedConversationId = @"domain\\user\:1::session-1"; AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); - var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var store = new AzureBlobAgentSessionStore( + this._containerClient, + "assistant", + new AzureBlobAgentSessionStoreOptions { EnableLegacyKeyFallback = true }); AgentSession session = await agent.CreateSessionAsync(); session.StateBag.SetValue("marker", "legacy"); - await store.SaveSessionAsync( - agent, - LegacyScopedConversationId, - session, - userId: null); + await this.WriteLegacySessionAsync(store, agent, ConversationId, session, UserId); // Act AgentSession? restored = await store.GetSessionAsync(agent, ConversationId, UserId); @@ -184,6 +207,22 @@ await store.SaveSessionAsync( 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 + AgentSession? restored = await store.GetSessionAsync(agent, "session-1", "user-1"); + + // Assert + Assert.Null(restored); + } + [Fact] public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync() { @@ -328,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.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs index edc429e27d6..8eac6eaecc2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -183,24 +183,18 @@ public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() } /// - /// Verify that GetOrCreateSessionAsync delegates to a specialized inner store implementation. + /// Verify that GetOrCreateSessionAsync honors a derived GetSessionAsync override. /// [Fact] - public async Task GetOrCreateSessionAsyncDelegatesToInnerStoreAsync() + public async Task GetOrCreateSessionAsyncUsesOverriddenGetSessionAsyncAsync() { // Arrange const string ExpectedConversationId = "test-conversation-id"; const string ExpectedUserId = "test-user-id"; - this._innerStoreMock - .Setup(x => x.GetOrCreateSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - ExpectedUserId, - It.IsAny())) - .ReturnsAsync(this._testSession); + var store = new OverridingGetSessionStore(this._innerStoreMock.Object, this._testSession); // Act - AgentSession session = await this._delegatingStore.GetOrCreateSessionAsync( + AgentSession session = await store.GetOrCreateSessionAsync( this._agentMock.Object, ExpectedConversationId, ExpectedUserId); @@ -209,11 +203,11 @@ public async Task GetOrCreateSessionAsyncDelegatesToInnerStoreAsync() Assert.Same(this._testSession, session); this._innerStoreMock.Verify( x => x.GetOrCreateSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - ExpectedUserId, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny()), - Times.Once); + Times.Never); } /// @@ -265,6 +259,17 @@ private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStor public new AgentSessionStore InnerStore => base.InnerStore; } + private sealed class OverridingGetSessionStore(AgentSessionStore innerStore, AgentSession session) + : DelegatingAgentSessionStore(innerStore) + { + public override ValueTask GetSessionAsync( + AIAgent agent, + string conversationId, + string? userId, + CancellationToken cancellationToken = default) + => new(session); + } + private sealed class TestAgentSession : AgentSession; #endregion From c1961d535b09f3b327958ff8b0e2a81ed6c9fee4 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:18:47 +0100 Subject: [PATCH 3/7] fix(dotnet): remove legacy session key fallback --- .../0039-shared-agent-session-store.md | 6 +-- .../Blob/AzureBlobAgentSessionStore.cs | 29 +--------- .../Blob/AzureBlobAgentSessionStoreOptions.cs | 11 ---- ...soft.Agents.AI.Hosting.AzureStorage.csproj | 3 -- .../AzureBlobAgentSessionStoreTests.cs | 53 ------------------- 5 files changed, 3 insertions(+), 99 deletions(-) diff --git a/docs/decisions/0039-shared-agent-session-store.md b/docs/decisions/0039-shared-agent-session-store.md index cb3ec1bef1b..1cdd4ed4c75 100644 --- a/docs/decisions/0039-shared-agent-session-store.md +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -54,9 +54,8 @@ sessions by user. `AIHostAgent` uses `GetOrCreateSessionAsync` when it needs a r 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. +identifier contains the old delimiter. Version 1 keys are not read because the package is still preview and +the version 1 format cannot distinguish those two cases safely. ## Consequences @@ -72,7 +71,6 @@ 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 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 e9abb4e6af2..daf00472e01 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs @@ -46,7 +46,6 @@ public sealed class AzureBlobAgentSessionStore : AgentSessionStore private readonly string _agentKey; private readonly string? _blobNamePrefix; private readonly bool _createContainerIfNotExists; - private readonly bool _enableLegacyKeyFallback; private Task? _containerInitializationTask; /// @@ -68,7 +67,6 @@ 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 }) @@ -115,18 +113,9 @@ await blobClient.UploadAsync( await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false); - AgentSession? session = await this.TryGetSessionAsync( - agent, - this.GetBlobName(conversationId, userId), - cancellationToken).ConfigureAwait(false); - if (session is not null || !this._enableLegacyKeyFallback) - { - return session; - } - return await this.TryGetSessionAsync( agent, - this.GetLegacyBlobName(conversationId, userId), + this.GetBlobName(conversationId, userId), cancellationToken).ConfigureAwait(false); } @@ -192,19 +181,6 @@ private string GetBlobName(string conversationId, string? userId) : $"{this._blobNamePrefix}/{baseName}"; } - internal string GetLegacyBlobName(string conversationId, string? userId) - { - 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 - ? baseName - : $"{this._blobNamePrefix}/{baseName}"; - } - private static string BuildLogicalKey(string conversationId, string? userId) { StringBuilder builder = new(); @@ -224,9 +200,6 @@ private static void AppendComponent(StringBuilder builder, char prefix, string? builder.Append('|'); } - private static string EscapeIsolationKey(string userId) - => userId.Replace("\\", "\\\\").Replace(":", "\\:"); - private static void ValidateUserId(string? userId) { if (userId is not null) 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 8fcbd767ffd..76f4258827b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStoreOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStoreOptions.cs @@ -16,17 +16,6 @@ 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 b3af3cce6b6..bd442cd24c4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj @@ -22,7 +22,4 @@ - - - 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 82eb73ec92d..9a250e9ea71 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Azure; @@ -184,45 +183,6 @@ public async Task SaveAndGetSessionAsync_ScopedAndUnscopedIdentifiersDoNotCollid 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", "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 - AgentSession? restored = await store.GetSessionAsync(agent, "session-1", "user-1"); - - // Assert - Assert.Null(restored); - } - [Fact] public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync() { @@ -367,19 +327,6 @@ 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)); From b7ff84b94bbdda20bdfa03f26687624432044859 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:44:07 +0100 Subject: [PATCH 4/7] feat(dotnet): add partitioned session store keys --- ...sted-per-user-session-storage-isolation.md | 3 +- .../0039-shared-agent-session-store.md | 34 +- .../003-dotnet-hosting-protocol-helpers.md | 41 +- .../af-hosting/local_responses/README.md | 2 +- .../local_responses/Server/Program.cs | 10 +- .../AgentSessionStore.cs | 29 +- .../AgentSessionStoreKey.cs | 195 +++++++++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 15 +- .../PublicAPI/net472/PublicAPI.Unshipped.txt | 15 +- .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 15 +- .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 15 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 15 +- .../AgentFrameworkResponseHandler.cs | 31 +- .../FileSystemAgentSessionStore.cs | 198 ++------- .../FoundryAgentSessionStore.cs | 86 ++-- .../FoundryHostingAgent.cs | 32 ++ .../InMemoryAgentSessionStore.cs | 55 +-- .../AzureBlobHostedAgentBuilderExtensions.cs | 4 +- .../Blob/AzureBlobAgentSessionStore.cs | 68 ++- .../AIHostAgent.cs | 38 +- .../DelegatingAgentSessionStore.cs | 10 +- .../HostedAgentBuilderExtensions.cs | 6 +- .../IsolationKeyScopedAgentSessionStore.cs | 40 +- ...lationKeyScopedAgentSessionStoreOptions.cs | 6 +- .../Local/InMemoryAgentSessionStore.cs | 35 +- .../NoopAgentSessionStore.cs | 8 +- .../AgentSessionStoreKeyTests.cs | 143 ++++++ .../AgentSessionStoreTests.cs | 30 +- ...FrameworkResponseHandlerResilienceTests.cs | 23 +- .../AgentFrameworkResponseHandlerTests.cs | 47 +- .../FileSystemAgentSessionStoreTests.cs | 412 ++++++------------ .../FoundryAgentSessionStoreTests.cs | 98 +++-- .../FoundryStateStoreLocalFallbackTests.cs | 5 +- .../HostedSessionIdentityContextTests.cs | 21 +- .../ResilientTwoLifetimeIntegrationTests.cs | 12 +- .../A2AAgentHandlerTests.cs | 84 ++-- ...AServerServiceCollectionExtensionsTests.cs | 9 +- .../AzureBlobAgentSessionStoreTests.cs | 75 ++-- .../AnthropicResponsesHostingLiveTests.cs | 8 +- .../OpenAIResponsesHostingLiveTests.cs | 8 +- .../OpenAIResponsesHostingTests.cs | 15 +- .../DelegatingAgentSessionStoreTests.cs | 59 +-- .../InMemoryAgentSessionStoreTests.cs | 33 +- ...solationKeyScopedAgentSessionStoreTests.cs | 116 ++--- 44 files changed, 1116 insertions(+), 1088 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs 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 ff47acb1791..beb6942c489 100644 --- a/docs/decisions/0031-hosted-per-user-session-storage-isolation.md +++ b/docs/decisions/0031-hosted-per-user-session-storage-isolation.md @@ -105,7 +105,8 @@ Negative: [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. +Hosting. It supersedes the required `userId` parameter with `AgentSessionStoreKey.Partitions`. Foundry +Hosting adds the resolved user identity as a named partition before loading or saving a session. ## Update (2026-07-01): local runs no longer fail closed; sample dev provider removed diff --git a/docs/decisions/0039-shared-agent-session-store.md b/docs/decisions/0039-shared-agent-session-store.md index 1cdd4ed4c75..db4bbd4ae3c 100644 --- a/docs/decisions/0039-shared-agent-session-store.md +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -22,7 +22,7 @@ on a specific hosting protocol package instead of the core agent abstractions. - 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. +- The contract must support any number of isolation dimensions without privileging user identity. - Existing Foundry storage behavior and per-user isolation must remain unchanged. ## Considered Options @@ -30,6 +30,7 @@ on a specific hosting protocol package instead of the core agent abstractions. 1. Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Abstractions`. 2. Promote the conventional Hosting contract and adapt Foundry Hosting to it. 3. Add a third contract and keep adapters for both existing contracts. +4. Represent session identity as an immutable key with arbitrary named partitions. ## Decision Outcome @@ -40,22 +41,29 @@ Chosen option: **Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Ab - The abstraction and every public implementation start as experimental under diagnostic `MAAI001`. - `GetSessionAsync` returns `AgentSession?` and returns `null` when no session is stored. - `GetOrCreateSessionAsync` performs the explicit lookup or creation operation. -- `SaveSessionAsync` and both lookup methods require a `string? userId` argument with no default value. - A non-null value must not be empty or contain only whitespace. +- `SaveSessionAsync` and both lookup methods receive an `AgentSessionStoreKey`. +- `AgentSessionStoreKey.SessionId` identifies the logical session. +- `AgentSessionStoreKey.Partitions` holds zero or more named isolation dimensions. Every partition is + part of identity and implementations cannot ignore unknown partitions. +- Partition order does not affect identity. Abstractions provides a stable, opaque storage key derived + from the session id and every partition. - `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. +adds the value from `AgentIsolationKeyProvider` under the `isolation` partition while preserving existing +partitions. Protocol-specific hosting can add named partitions such as `user`, `tenant`, or `chat` before +loading the session. `AIHostAgent` uses `GetOrCreateSessionAsync` when it needs a ready session. -Azure Blob Storage 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. Version 1 keys are not read because the package is still preview and -the version 1 format cannot distinguish those two cases safely. +Azure Blob Storage and the filesystem store use `AgentSessionStoreKey.StableStorageKey`. Foundry State +Store incorporates the same session id and partition collection into its item identity. Version 1 Azure +Blob keys are not read because the package is still preview and the previous format cannot distinguish +all partition combinations safely. + +Provider-specific metadata does not belong in `AgentSessionStoreKey`. For example, Foundry item tags can +be exposed by an overload or options type on `FoundryAgentSessionStore` without adding tags to Abstractions. ## Consequences @@ -63,16 +71,16 @@ 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. +- Isolation dimensions are explicit, composable, and independent from any hosting protocol. - `Microsoft.Agents.AI.Abstractions` owns the contract alongside `AIAgent` and `AgentSession`. Negative: - This is a source-breaking change for implementations of the preview Hosting contract. -- Callers must pass `userId: null` explicitly when no user partition exists. +- Callers must construct an `AgentSessionStoreKey`; unpartitioned sessions use only `SessionId`. - Consumers that need deletion must use a storage-specific API until a separate shared deletion capability is defined. ## More Information -- [ADR-0031](0031-hosted-per-user-session-storage-isolation.md) defines the explicit user partition used by the promoted contract. +- [ADR-0031](0031-hosted-per-user-session-storage-isolation.md) records the earlier Foundry-specific user partition. - [ADR-0032](0032-dotnet-hosting-protocol-helpers.md) records the previous conventional Hosting contract. diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 67d94947ba5..47bae157b27 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -96,21 +96,18 @@ public abstract class AgentSessionStore { public abstract ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default); public virtual ValueTask GetOrCreateSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default); public abstract ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default); } ``` @@ -135,17 +132,18 @@ public sealed class HostedWorkflowState } ``` -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. +For agents, the application uses `AgentSessionStore` directly. `GetSessionAsync(agent, key)` +returns `null` on a miss, while `GetOrCreateSessionAsync(agent, key)` returns a ready session. Each successful lookup returns an independent instance, so concurrent calls can fork the same stored state without observing another branch's changes. The store performs no cross-call locking. An application that needs concurrent runs against the same id to be serialized owns that coordination. -`SaveSessionAsync(agent, id, session, userId)` persists the post-run state, including under a newly +`SaveSessionAsync(agent, key, session)` persists the post-run state, including under a newly minted `resp_*` id when the protocol creates a continuation id. No agent-side holder is needed because the convenience method already performs lookup or creation. -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. +`AgentSessionStoreKey` contains a session id plus arbitrary named partitions. Every partition contributes +to identity, independent of dictionary order. Stores must not ignore unknown partitions. Provider-specific +metadata such as Foundry tags is not part of the key or the Abstractions contract. `HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory `sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but @@ -187,8 +185,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, pass a trusted `userId`, or wrap the store with - `IsolationKeyScopedAgentSessionStore` so `AgentIsolationKeyProvider` supplies it. +- Multi-user hosts must add a trusted identity partition, or wrap the store with + `IsolationKeyScopedAgentSessionStore` so `AgentIsolationKeyProvider` supplies one. - Persist session/checkpoint state only after the run or stream has completed. ## E2E Code Samples @@ -209,11 +207,8 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => string sessionId = Authorize(http.User, candidate) ?? OpenAIResponses.CreateResponseId(); var run = OpenAIResponses.ToAgentRunRequest(body); - var session = await sessionStore.GetOrCreateSessionAsync( - agent, - sessionId, - userId: null, - cancellationToken: ct); + var key = new AgentSessionStoreKey(sessionId); + var session = await sessionStore.GetOrCreateSessionAsync(agent, key, ct); string responseId = OpenAIResponses.CreateResponseId(); @@ -228,20 +223,18 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => } await sessionStore.SaveSessionAsync( agent, - responseId, + new AgentSessionStoreKey(responseId), session, - userId: null, - cancellationToken: ct); + ct); return Results.Empty; } var result = await agent.RunAsync(run.Messages, session, run.Options, ct); await sessionStore.SaveSessionAsync( agent, - responseId, + new AgentSessionStoreKey(responseId), session, - userId: null, - cancellationToken: ct); + ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId)); }); ``` diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md index 5f6fb1bd6d4..4873bd3b202 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md @@ -54,4 +54,4 @@ The client defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_ `OpenAIResponses.GetSessionStoreId(...)` returns an untrusted candidate key. The server's `Authorize(...)` is a placeholder; a real application must authenticate the caller and authorize/bind the id to the authenticated principal before using it as a session key. For multi-user hosts, scope the store with -`IsolationKeyScopedAgentSessionStore`. +`IsolationKeyScopedAgentSessionStore`, or add trusted partition values to `AgentSessionStoreKey`. diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index 8fbafa7bb81..71be67b9cc2 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -65,12 +65,12 @@ static string LookupWeather([Description("The city to look up weather for.")] st // this key to the principal before using it. This sample simply falls back to a fresh id. string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run); string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId(); + var sessionKey = new AgentSessionStoreKey(sessionStoreId); AgentSession session = await sessionStore.GetOrCreateSessionAsync( agent, - sessionStoreId, - userId: null, - cancellationToken: cancellationToken).ConfigureAwait(false); + sessionKey, + cancellationToken).ConfigureAwait(false); string responseId = OpenAIResponses.CreateResponseId(); // Choose where to persist the post-run session, which depends on how the caller continued the thread: @@ -96,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, userId: null, cancellationToken: cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(saveId), session, cancellationToken).ConfigureAwait(false); // The SSE body was already written straight to http.Response above, so return an empty result: // this returns from the handler (the non-streaming code below does not run) without writing a body. @@ -104,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, userId: null, cancellationToken: cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(saveId), session, cancellationToken).ConfigureAwait(false); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs index 9d4f0148df9..47b7ed4740a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs @@ -22,21 +22,14 @@ public abstract class AgentSessionStore /// Saves an agent session to persistent storage. /// /// The agent that owns this session. - /// The unique identifier for the conversation. + /// The key that identifies and partitions the session. /// 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, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default); /// @@ -44,12 +37,7 @@ public abstract ValueTask SaveSessionAsync( /// 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 key that identifies and partitions the session. /// The to monitor for cancellation requests. /// /// A task whose result contains the restored session, or when nothing is stored for @@ -63,16 +51,14 @@ public abstract ValueTask SaveSessionAsync( /// public abstract ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default); /// /// Retrieves the stored session for the given identifiers, or creates a new one when none is stored. /// /// The agent that owns this session. - /// The unique identifier for the conversation to retrieve. - /// The per-user partition key; see for its meaning. + /// The key that identifies and partitions the session. /// The to monitor for cancellation requests. /// A task whose result is always a usable session. /// @@ -82,13 +68,12 @@ public abstract ValueTask SaveSessionAsync( /// public virtual async ValueTask GetOrCreateSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { _ = Throw.IfNull(agent); - return await this.GetSessionAsync(agent, conversationId, userId, cancellationToken).ConfigureAwait(false) + return await this.GetSessionAsync(agent, key, cancellationToken).ConfigureAwait(false) ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs new file mode 100644 index 00000000000..1d2e1d6bac2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Identifies an agent session in persistent storage. +/// +/// +/// +/// identifies the session while contains additional +/// named dimensions that isolate sessions sharing that identifier. Every partition is part of the +/// identity and must be honored by implementations. +/// +/// +/// Partition names are compared using ordinal, case-sensitive comparison. Partition ordering does not +/// affect identity. Names and values cannot be empty or whitespace. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSessionStoreKey : IEquatable +{ + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + private readonly int _hashCode; + + /// + /// Initializes a new instance of the class. + /// + /// The logical session identifier. + /// + /// Optional named partition values. Every partition contributes to identity. The collection is copied. + /// + public AgentSessionStoreKey( + string sessionId, + IReadOnlyDictionary? partitions = null) + { + this.SessionId = ValidateIdentityComponent(sessionId, nameof(sessionId)); + + var partitionCopy = new SortedDictionary(StringComparer.Ordinal); + if (partitions is not null) + { + foreach (KeyValuePair partition in partitions) + { + partitionCopy.Add( + ValidateIdentityComponent(partition.Key, nameof(partitions)), + ValidateIdentityComponent(partition.Value, nameof(partitions))); + } + } + + this.Partitions = new ReadOnlyDictionary(partitionCopy); + + string canonicalValue = this.BuildCanonicalValue(); + this.StableStorageKey = ComputeStableStorageKey(canonicalValue); + this._hashCode = StringComparer.Ordinal.GetHashCode(canonicalValue); + } + + /// + /// Gets the logical session identifier. + /// + public string SessionId { get; } + + /// + /// Gets the named partition values that form part of the session identity. + /// + public IReadOnlyDictionary Partitions { get; } + + /// + /// Returns a new key containing the specified partition. + /// + /// The partition name. + /// The partition value. + /// + /// A new key with the partition added or replaced, or this instance when the partition already has + /// the specified value. + /// + public AgentSessionStoreKey WithPartition(string name, string value) + { + name = ValidateIdentityComponent(name, nameof(name)); + value = ValidateIdentityComponent(value, nameof(value)); + + if (this.Partitions.TryGetValue(name, out string? existingValue) + && string.Equals(existingValue, value, StringComparison.Ordinal)) + { + return this; + } + + var partitions = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair partition in this.Partitions) + { + partitions.Add(partition.Key, partition.Value); + } + partitions[name] = value; + + return new AgentSessionStoreKey(this.SessionId, partitions); + } + + /// + /// Gets a deterministic, opaque value suitable for addressing this key in persistent storage. + /// + /// + /// A versioned Base64URL-encoded SHA-256 hash of the session identifier and every partition. + /// + /// + /// This value is stable across processes and does not expose the original session or partition values. + /// + public string StableStorageKey { get; } + + /// + public bool Equals(AgentSessionStoreKey? other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other is null + || !string.Equals(this.SessionId, other.SessionId, StringComparison.Ordinal) + || this.Partitions.Count != other.Partitions.Count) + { + return false; + } + + foreach (KeyValuePair partition in this.Partitions) + { + if (!other.Partitions.TryGetValue(partition.Key, out string? value) + || !string.Equals(partition.Value, value, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object? obj) => this.Equals(obj as AgentSessionStoreKey); + + /// + public override int GetHashCode() => this._hashCode; + + private string BuildCanonicalValue() + { + StringBuilder builder = new(); + builder.Append("v1|s").Append(this.SessionId.Length).Append(':').Append(this.SessionId); + builder.Append("|p").Append(this.Partitions.Count).Append('|'); + + foreach (KeyValuePair partition in this.Partitions) + { + builder.Append('n').Append(partition.Key.Length).Append(':').Append(partition.Key); + builder.Append('v').Append(partition.Value.Length).Append(':').Append(partition.Value); + builder.Append('|'); + } + + return builder.ToString(); + } + + private static string ComputeStableStorageKey(string canonicalValue) + { + byte[] input = s_strictUtf8.GetBytes(canonicalValue); +#if NET8_0_OR_GREATER + byte[] hash = SHA256.HashData(input); +#else + byte[] hash; + using (SHA256 sha256 = SHA256.Create()) + { + hash = sha256.ComputeHash(input); + } +#endif + return $"ask1_{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; + } + + private static string ValidateIdentityComponent(string value, string paramName) + { + value = Throw.IfNullOrWhitespace(value, paramName); + + try + { + _ = s_strictUtf8.GetByteCount(value); + return value; + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException("Session key values must contain valid UTF-16 text.", paramName, exception); + } + } +} 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 ca6228dbf37..c5cae3a8bd4 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,6 +1,15 @@ #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 +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt index ca6228dbf37..c5cae3a8bd4 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,6 +1,15 @@ #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 +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt index ca6228dbf37..c5cae3a8bd4 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,6 +1,15 @@ #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 +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt index ca6228dbf37..c5cae3a8bd4 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,6 +1,15 @@ #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 +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index ca6228dbf37..c5cae3a8bd4 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,6 +1,15 @@ #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 +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 32248cf30b1..f6f68d9c4d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -36,6 +36,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; public class AgentFrameworkResponseHandler : ResponseHandler { private const string LatestWorkflowCheckpointIdMetadataKey = "_last_checkpoint_id"; + private const string UserPartitionName = "user"; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -130,8 +131,8 @@ public override async IAsyncEnumerable CreateAsync( // When resolvedHostedContext is null here the container is NOT hosted by Foundry (local // development: docker run / dotnet run outside the platform, so no x-agent-user-id header). - // Per-user isolation simply does not apply in that case: the request proceeds with a null user - // id (the session store treats null as "no user partition") and no hosted context is stamped or + // Per-user isolation simply does not apply in that case: no user partition is added to the + // session key and no hosted context is stamped or // validated. This lets contributors run the image locally without registering a fallback // provider, while production stays strict because FoundryEnvironment.IsHosted is true there. var resolvedUserId = resolvedHostedContext?.UserId; @@ -140,13 +141,20 @@ public override async IAsyncEnumerable CreateAsync( // Map the request to a stable MAF AgentSession key: conversation_id when present, else the // partition embedded in previous_response_id (chains converge), else the minted response id // (cold start). Container session id is intentionally not used — it spans many conversations. - // The session store partitions persisted state per user via resolvedUserId so one user can + // The session key partitions persisted state per user via resolvedUserId so one user can // never observe another user's session, even with a forged conversation id. Locally // (resolvedUserId is null) there is no user to partition on, so the session is unscoped/shared // by design — per-user isolation applies only when a user identity was resolved (hosted). var conversationId = request.GetConversationId(); var agentSessionId = HostedConversationKey.Resolve( conversationId, request.PreviousResponseId, context.ResponseId); + AgentSessionStoreKey? agentSessionKey = string.IsNullOrWhiteSpace(agentSessionId) + ? null + : new AgentSessionStoreKey(agentSessionId); + if (agentSessionKey is not null && resolvedUserId is not null) + { + agentSessionKey = agentSessionKey.WithPartition(UserPartitionName, resolvedUserId); + } var agentOptions = agent.GetService(); var hostingOptions = this._serviceProvider.GetService>()?.Value; @@ -157,7 +165,7 @@ public override async IAsyncEnumerable CreateAsync( // a session to run against. AgentSession? session; bool sessionRestoredFromStore = false; - if (string.IsNullOrWhiteSpace(agentSessionId)) + if (agentSessionKey is null) { session = await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } @@ -165,8 +173,7 @@ public override async IAsyncEnumerable CreateAsync( { session = await sessionStore.GetSessionAsync( agent, - agentSessionId, - resolvedUserId, + agentSessionKey, cancellationToken).ConfigureAwait(false); sessionRestoredFromStore = session is not null; @@ -469,7 +476,7 @@ await this._toolboxService if (!isResilientTurn || workflowCheckpointRecovery is null || session is null - || string.IsNullOrWhiteSpace(agentSessionId) + || agentSessionKey is null || (stream.InternalMetadata.TryGetValue(LatestWorkflowCheckpointIdMetadataKey, out string? lastCheckpointId) && string.Equals(lastCheckpointId, checkpoint.CheckpointId, StringComparison.Ordinal))) { @@ -480,9 +487,8 @@ await this._toolboxService { await sessionStore.SaveSessionAsync( agent, - agentSessionId, + agentSessionKey, session, - resolvedUserId, checkpointCancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) @@ -667,12 +673,12 @@ bool CheckNotAllowedStoreUsage() => && evt is ResponseOutputItemDoneEvent && workflowCheckpointRecovery is null && session is not null - && !string.IsNullOrWhiteSpace(agentSessionId) + && agentSessionKey is not null && !turnFailed) { try { - await sessionStore.SaveSessionAsync(agent, agentSessionId!, session, resolvedUserId, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, agentSessionKey, session, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -709,9 +715,8 @@ bool CheckNotAllowedStoreUsage() => { await sessionStore.SaveSessionAsync( agent, - agentSessionId!, + agentSessionKey!, session, - resolvedUserId, steeringDetected ? CancellationToken.None : cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs index 8634596bb9f..5b0766c2ed3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs @@ -1,10 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Buffers; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -14,7 +12,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// Provides a file-system backed implementation of that persists -/// the agent-framework's serialized state for each (agent, conversation) +/// the agent-framework's serialized state for each agent and session key /// pair to disk. This complements Foundry storage (which owns conversation messages, agent /// definitions, and threads) — it is not a replacement for it. /// @@ -33,7 +31,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// read-only and paths outside $HOME may be cleared between requests. Locally, /// sessions fall under {cwd}/.checkpoints. The session JSON produced when the agent /// serializes the session already contains the workflow's in-memory checkpoint manager -/// state, so a single file per (agent, conversation) pair is sufficient to resume +/// state, so a single file per agent and session key is sufficient to resume /// long-running workflows across process restarts. /// /// @@ -147,16 +145,19 @@ private static bool IsUsableHostedHomeDirectory(string? homeDirectory) } /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(agent); - ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(session); - ValidateUserId(userId); JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); - string path = this.GetSessionPath(agent, conversationId, userId); + string path = this.GetSessionPath(agent, key); // Each save writes to its own temp file before atomically renaming over the // destination. Last writer wins for the final file, but no reader can observe @@ -209,13 +210,15 @@ private string BuildNotWritableMessage(string sessionFilePath) => $"(for example {nameof(InMemoryAgentSessionStore)}) via AddFoundryResponses(agent, agentSessionStore)."; /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(agent); - ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); - ValidateUserId(userId); + ArgumentNullException.ThrowIfNull(key); - string path = this.GetSessionPath(agent, conversationId, userId); + string path = this.GetSessionPath(agent, key); if (!File.Exists(path)) { return null; @@ -233,40 +236,20 @@ private string BuildNotWritableMessage(string sessionFilePath) => return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false); } - private string GetSessionPath(AIAgent agent, string conversationId, string? userId) + private string GetSessionPath(AIAgent agent, AgentSessionStoreKey key) { - // Path layout uses self-describing, prefixed segments so every layer is unambiguous and a - // collapsed layout can never be confused with a different layer (e.g. a user id can never - // masquerade as an agent name): + // The stable key incorporates the session identifier and every partition without exposing + // those values in the filesystem: // - // {root}/a-{agent}/u-{userId}/c-{conversationId}.json + // {root}/a-{agent}/k-{stable-key}.json // - // - a-{agent} buckets per hosted agent, because a single container hosts multiple keyed - // agents that must not collide on the same conversationId. (agent.Id is NOT - // used: it is regenerated on every startup for in-memory-defined agents.) - // - u-{userId} partitions per end user (x-agent-user-id) for multi-tenant isolation. Present - // only when a user id was resolved; absent for local runs with no platform header. - // - c-{conv} the conversation/context key. {conversationId} is HostedConversationKey.Resolve's - // output (conversation_id, else the partition of previous_response_id / response id). - // - // The prefixes are constant literals applied AFTER sanitizing/validating each untrusted value, - // so they can never themselves introduce path traversal. - string dir = this.RootDirectory; - - if (!string.IsNullOrEmpty(agent.Name)) - { - dir = Path.Combine(dir, "a-" + Sanitize(agent.Name!)); - } + // Persistent storage requires the stable name or keyed registration carried by the hosted + // wrapper. Hashing it avoids case-insensitive and platform-specific directory collisions. + string agentIdentity = FoundryHostingAgent.GetSessionStorageIdentity(agent); + string agentKey = new AgentSessionStoreKey(agentIdentity).StableStorageKey; + string dir = Path.Combine(this.RootDirectory, "a-" + agentKey); - 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. - ValidatePathSegment(userId!, "user id"); - dir = Path.Combine(dir, "u-" + Sanitize(userId!)); - } - - string path = Path.Combine(dir, "c-" + Sanitize(conversationId) + ".json"); + string path = Path.Combine(dir, "k-" + key.StableStorageKey + ".json"); // Defense in depth: regardless of per-segment handling, the fully-resolved path must remain // under the storage root. Reject anything that escapes (CWE-22). @@ -283,135 +266,4 @@ private string GetSessionPath(AIAgent agent, string conversationId, string? user return path; } - - /// - /// Validates that is a single safe path component (CWE-22). - /// - /// - /// The value originates from caller-controlled or platform-injected fields (such as the - /// x-agent-user-id partition key). It must be treated as an untrusted single path segment: - /// path separators, drive letters, parent references and similar would otherwise let the resulting - /// directory escape the configured storage root. We deliberately do not URL-decode the value (the - /// hosting layer never decodes these ids before joining them, so forms such as %2e%2e are - /// accepted as literal directory names), and we do not "sanitize" by stripping characters because - /// that can introduce collisions between distinct ids — a non-conforming value is rejected outright. - /// - private static void ValidatePathSegment(string segment, string kind) - { - // Reject any value that is not a single safe path component. This covers POSIX/Windows - // separators, NUL bytes, drive letters, rooted paths, and all-dot segments (".", "..", "..."). - if (segment.IndexOf('/') >= 0 - || segment.IndexOf('\\') >= 0 - || segment.IndexOf('\0') >= 0 - || segment.Trim('.').Length == 0 - || Path.IsPathRooted(segment) - || !string.IsNullOrEmpty(Path.GetPathRoot(segment))) - { - throw new InvalidOperationException($"Invalid {kind}: '{segment}'."); - } - } - - private static 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 - // so the encoding is unambiguous. This is reversible and avoids the collision - // hazard of a lossy character substitution (e.g. "foo/bar" and "foo_bar" sharing - // a sanitized name). - char[] invalid = Path.GetInvalidFileNameChars(); - - int encodedLength = ComputeEncodedLength(value, invalid); - - // stackalloc is bounded so an externally-controlled length cannot crash the - // hosting process with StackOverflowException. - const int StackLimit = 512; - string sanitized; - if (encodedLength <= StackLimit) - { - Span buffer = stackalloc char[encodedLength]; - SanitizeCore(value, invalid, buffer); - sanitized = new string(buffer); - } - else - { - char[] rented = ArrayPool.Shared.Rent(encodedLength); - try - { - Span buffer = rented.AsSpan(0, encodedLength); - SanitizeCore(value, invalid, buffer); - sanitized = new string(buffer); - } - finally - { - ArrayPool.Shared.Return(rented); - } - } - - // '.' and '..' are valid filename characters but resolve to current/parent - // directory when used as a bare path component. Windows additionally strips - // trailing dots from filenames, so a segment like "..." would survive on disk - // as "" and a partial-encode like "%2E.." would survive as "%2E". Encode every - // dot in any all-dot segment so the result has no special meaning to the OS. - if (sanitized.Length > 0 && IsAllDots(sanitized)) - { - return string.Concat(Enumerable.Repeat("%2E", sanitized.Length)); - } - - return sanitized; - } - - private static int ComputeEncodedLength(string value, char[] invalid) - { - int extra = 0; - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (c == '%' || Array.IndexOf(invalid, c) >= 0) - { - extra += 2; // 1 char ('%' or invalid) becomes 3 chars ("%XX") - } - } - return value.Length + extra; - } - - private static bool IsAllDots(string value) - { - for (int i = 0; i < value.Length; i++) - { - if (value[i] != '.') - { - return false; - } - } - - return true; - } - - private static void SanitizeCore(string value, char[] invalid, Span buffer) - { - int j = 0; - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (c == '%' || Array.IndexOf(invalid, c) >= 0) - { - buffer[j++] = '%'; - buffer[j++] = HexChar((c >> 4) & 0xF); - buffer[j++] = HexChar(c & 0xF); - } - else - { - buffer[j++] = c; - } - } - } - - private static char HexChar(int n) => (char)(n < 10 ? '0' + n : 'A' + n - 10); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs index daccd152bdf..2bf27d9f46c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs @@ -28,17 +28,16 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// /// Layout. All sessions live in one state store, named unless -/// overridden, and each (agent, user, conversation) triple is one item in it. The item key is a -/// hash of an unambiguous, length-prefixed encoding of the hosted registration identity, user id, -/// and conversation id. Hashing is required because the platform limits an item key to 128 +/// overridden, and each agent and pair is one item in it. The item +/// key is a hash of an unambiguous encoding of the hosted registration identity and session key. +/// Hashing is required because the platform limits an item key to 128 /// characters. The readable encoding is stored alongside the session so an item can still be traced /// back to its partition. /// /// -/// Per-user isolation is expressed through the item key rather than through the state store's own -/// userIsolation option. That option is fixed when the store is created and resolves the -/// user from the calling identity, whereas the user id handled here arrives per request and the -/// container always calls the storage API with its own identity. +/// Logical isolation is expressed through rather than +/// through the state store's own userIsolation option. That option is fixed when the store is +/// created, while session partitions may vary per request. /// /// /// The bound state store is resolved once, on first use, and reused for the lifetime of this @@ -54,6 +53,8 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class FoundryAgentSessionStore : AgentSessionStore { + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + /// /// The default state-store name used to hold every agent session persisted by this store. /// @@ -124,21 +125,19 @@ internal FoundryAgentSessionStore(Func public override async ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { _ = Throw.IfNull(agent); - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); _ = Throw.IfNull(session); - ValidateUserId(userId); - string agentIdentity = ResolveAgentIdentity(agent); + string agentIdentity = FoundryHostingAgent.GetSessionStorageIdentity(agent); JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); BinaryData sessionData = ToBinaryData(serialized); - string logicalKey = BuildLogicalKey(agentIdentity, conversationId, userId); + string logicalKey = BuildLogicalKey(agentIdentity, key); FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); await store.SetItemAsync( @@ -154,15 +153,13 @@ await store.SetItemAsync( /// public override async ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { _ = Throw.IfNull(agent); - _ = Throw.IfNullOrWhitespace(conversationId); - ValidateUserId(userId); + _ = Throw.IfNull(key); - string logicalKey = BuildLogicalKey(ResolveAgentIdentity(agent), conversationId, userId); + string logicalKey = BuildLogicalKey(FoundryHostingAgent.GetSessionStorageIdentity(agent), key); FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); // GetItemAsync already answers null for an item that is not there, which is exactly the @@ -191,33 +188,20 @@ private ValueTask GetStoreAsync(CancellationToken cancellatio /// Builds an unambiguous readable partition key from the hosted agent identity, end user, and /// conversation. Each component carries its length so delimiters inside values cannot collide. /// - internal static string BuildLogicalKey(string agentIdentity, string conversationId, string? userId) + internal static string BuildLogicalKey(string agentIdentity, AgentSessionStoreKey key) { + _ = Throw.IfNull(key); + StringBuilder builder = new(); AppendComponent(builder, 'a', Throw.IfNullOrWhitespace(agentIdentity)); - AppendComponent(builder, 'u', userId); - AppendComponent(builder, 'c', Throw.IfNullOrWhitespace(conversationId)); - builder.Length--; - return builder.ToString(); - } - - private static string ResolveAgentIdentity(AIAgent agent) - { - _ = Throw.IfNull(agent); - - if (agent.GetService() is { } hostingAgent) - { - return hostingAgent.SessionStorageIdentity; - } - - if (string.IsNullOrWhiteSpace(agent.Name)) + AppendComponent(builder, 's', key.SessionId); + foreach (KeyValuePair partition in key.Partitions) { - throw new InvalidOperationException( - $"Direct use of {nameof(FoundryAgentSessionStore)} requires an agent with a stable {nameof(AIAgent.Name)}. " + - "Foundry hosting supplies the keyed or default registration identity separately."); + AppendComponent(builder, 'n', partition.Key); + AppendComponent(builder, 'v', partition.Value); } - - return $"name:{agent.Name}"; + builder.Length--; + return builder.ToString(); } private static void AppendComponent(StringBuilder builder, char prefix, string? value) @@ -231,23 +215,23 @@ 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 - /// logical key is hashed rather than truncated: truncation would let two different conversations + /// characters, which an agent identity plus a session id and its partitions can exceed, so the + /// logical key is hashed rather than truncated: truncation would let two different sessions /// share a key and therefore overwrite each other's session. /// internal static string BuildItemKey(string logicalKey) { - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(logicalKey)); + byte[] hash; + try + { + hash = SHA256.HashData(s_strictUtf8.GetBytes(logicalKey)); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException("Session keys and agent identities must contain valid UTF-16 text.", nameof(logicalKey), exception); + } return $"s-{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs index cc17a707dab..6b4f5d04f1c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -21,6 +22,37 @@ internal FoundryHostingAgent(AIAgent innerAgent, string sessionStorageIdentity) internal string SessionStorageIdentity { get; } + /// + /// Gets the session storage identity carried by a hosting wrapper, or derives one for direct use. + /// + /// The agent whose storage identity is required. + /// + /// Whether an unnamed direct agent may use its process-local . + /// + /// The resolved session storage identity. + internal static string GetSessionStorageIdentity(AIAgent agent, bool allowInstanceId = false) + { + _ = Throw.IfNull(agent); + + if (agent.GetService() is { } hostingAgent) + { + return hostingAgent.SessionStorageIdentity; + } + + if (!string.IsNullOrWhiteSpace(agent.Name)) + { + return $"name:{agent.Name}"; + } + + if (allowInstanceId) + { + return $"id:{agent.Id}"; + } + + throw new InvalidOperationException( + $"Persistent session storage requires a stable {nameof(AIAgent.Name)} or Foundry hosting registration."); + } + /// /// Resolves the stable identity used to partition session storage for the resolved agent. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs index 025b017e93d..c1c92858962 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs @@ -34,26 +34,30 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore private readonly ConcurrentDictionary _sessions = new(); /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(agent); - ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(session); - ValidateUserId(userId); - var key = GetKey(agent, conversationId, userId); - this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); + string storageKey = GetKey(agent, key); + this._sessions[storageKey] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(agent); - ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); - ValidateUserId(userId); + ArgumentNullException.ThrowIfNull(key); - var key = GetKey(agent, conversationId, userId); - if (!this._sessions.TryGetValue(key, out var existingSession)) + if (!this._sessions.TryGetValue(GetKey(agent, key), out var existingSession)) { return null; } @@ -61,33 +65,6 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false); } - // Keyed with the same a-/u-/c- prefix scheme as FileSystemAgentSessionStore so the in-memory store - // partitions per agent and per user identically. Like FileSystemAgentSessionStore, the agent segment - // uses agent.Name (a stable identity) and is omitted when no name is set; agent.Id is intentionally - // NOT used because it is regenerated on every startup for in-memory-defined agents, which would break - // session continuity for a transient or recreated agent. The user segment is omitted when no user id - // is supplied. - private static string GetKey(AIAgent agent, string conversationId, string? userId) - { - string key = string.Empty; - if (!string.IsNullOrEmpty(agent.Name)) - { - key += $"a-{agent.Name}:"; - } - - if (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); - } - } + private static string GetKey(AIAgent agent, AgentSessionStoreKey key) + => $"{FoundryHostingAgent.GetSessionStorageIdentity(agent, allowInstanceId: true)}:{key.StableStorageKey}"; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs index a25d17ba258..d06116a6717 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs @@ -20,7 +20,7 @@ public static class AzureBlobHostedAgentBuilderExtensions /// The Blob container client used to store sessions. /// Optional session store configuration. /// - /// Whether to supply the session's user partition from the configured . + /// Whether to add an isolation partition from the configured . /// /// The supplied . public static IHostedAgentBuilder WithAzureBlobSessionStore( @@ -49,7 +49,7 @@ public static IHostedAgentBuilder WithAzureBlobSessionStore( /// Optional session store configuration. /// The dependency injection lifetime of the registered session store. /// - /// Whether to supply the session's user partition from the configured . + /// Whether to add an isolation partition from the configured . /// /// The supplied . public static IHostedAgentBuilder WithAzureBlobSessionStore( diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs index daf00472e01..9556d58e43a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs @@ -40,6 +40,7 @@ public sealed class AzureBlobAgentSessionStore : AgentSessionStore { HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" }, }; + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); private readonly BlobContainerClient _containerClient; private readonly object _containerInitializationLock = new(); @@ -63,7 +64,7 @@ public AzureBlobAgentSessionStore( AzureBlobAgentSessionStoreOptions? options = null) { this._containerClient = Throw.IfNull(containerClient); - this._agentKey = ComputeKey(Throw.IfNullOrWhitespace(agentNamespace)); + this._agentKey = ComputeAgentKey(Throw.IfNullOrWhitespace(agentNamespace)); options ??= new AzureBlobAgentSessionStoreOptions(); this._createContainerIfNotExists = options.CreateContainerIfNotExists; @@ -80,20 +81,18 @@ public AzureBlobAgentSessionStore( /// public override async ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { Throw.IfNull(agent); - Throw.IfNull(conversationId); + Throw.IfNull(key); 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(conversationId, userId)); + BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(key)); await blobClient.UploadAsync( BinaryData.FromString(serializedSession.GetRawText()), s_uploadOptions, @@ -103,19 +102,17 @@ await blobClient.UploadAsync( /// public override async ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { Throw.IfNull(agent); - Throw.IfNull(conversationId); - ValidateUserId(userId); + Throw.IfNull(key); await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false); return await this.TryGetSessionAsync( agent, - this.GetBlobName(conversationId, userId), + this.GetBlobName(key), cancellationToken).ConfigureAwait(false); } @@ -171,43 +168,15 @@ private async Task EnsureContainerExistsAsync(CancellationToken cancellationToke private async Task CreateContainerIfNotExistsAsync() => await this._containerClient.CreateIfNotExistsAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); - private string GetBlobName(string conversationId, string? userId) + private string GetBlobName(AgentSessionStoreKey key) { - string sessionKey = ComputeKey(BuildLogicalKey(conversationId, userId)); - string baseName = $"v2/{this._agentKey}/{sessionKey}.json"; + string baseName = $"v2/{this._agentKey}/{key.StableStorageKey}.json"; return this._blobNamePrefix is null ? baseName : $"{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 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) @@ -226,7 +195,7 @@ private static async Task WaitWithCancellationAsync(Task task, CancellationToken private static string ComputeKey(string value) { - byte[] input = Encoding.UTF8.GetBytes(value); + byte[] input = s_strictUtf8.GetBytes(value); #if NET8_0_OR_GREATER return Convert.ToHexString(SHA256.HashData(input)); #else @@ -245,6 +214,21 @@ private static string ComputeKey(string value) #endif } + private static string ComputeAgentKey(string agentNamespace) + { + try + { + return ComputeKey(agentNamespace); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException( + "The agent namespace must contain valid UTF-16 text.", + nameof(agentNamespace), + exception); + } + } + #if !NET8_0_OR_GREATER private static char ToHexChar(int value) => (char)(value < 10 ? '0' + value : 'A' + value - 10); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs index 3be0e54a4e2..a0396feeccf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs @@ -51,15 +51,25 @@ public AIHostAgent(AIAgent innerAgent, AgentSessionStore sessionStore) /// A task that represents the asynchronous operation. The task result contains the agent session associated with the /// specified conversation. If no session exists, a new session is created and returned. public ValueTask GetOrCreateSessionAsync(string conversationId, CancellationToken cancellationToken = default) + => this.GetOrCreateSessionAsync(new AgentSessionStoreKey(conversationId), cancellationToken); + + /// + /// Gets an existing agent session for the specified storage key, or creates a new one if none exists. + /// + /// The key that identifies and partitions the session. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task whose result contains the stored or newly created agent session. + public ValueTask GetOrCreateSessionAsync( + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); MarkFeatureUsed(); return this._sessionStore.GetOrCreateSessionAsync( this.InnerAgent, - conversationId, - userId: null, - cancellationToken: cancellationToken); + key, + cancellationToken); } /// @@ -72,17 +82,29 @@ public ValueTask GetOrCreateSessionAsync(string conversationId, Ca /// is null or whitespace. /// is . public ValueTask SaveSessionAsync(string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => this.SaveSessionAsync(new AgentSessionStoreKey(conversationId), session, cancellationToken); + + /// + /// Persists a session under the specified storage key. + /// + /// The key that identifies and partitions the session. + /// The session to persist. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + public ValueTask SaveSessionAsync( + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); _ = Throw.IfNull(session); MarkFeatureUsed(); return this._sessionStore.SaveSessionAsync( this.InnerAgent, - conversationId, + key, session, - userId: null, - cancellationToken: cancellationToken); + cancellationToken); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs index cfd7b801bd1..92600d3173c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -58,17 +58,15 @@ protected DelegatingAgentSessionStore(AgentSessionStore innerStore) /// public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) - => this.InnerStore.GetSessionAsync(agent, conversationId, userId, cancellationToken); + => this.InnerStore.GetSessionAsync(agent, key, cancellationToken); /// public override ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) - => this.InnerStore.SaveSessionAsync(agent, conversationId, session, userId, cancellationToken); + => this.InnerStore.SaveSessionAsync(agent, key, session, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index 1096361777e..f190d0a65b3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -18,7 +18,7 @@ public static class HostedAgentBuilderExtensions /// /// The host agent builder to configure with the in-memory session store. /// When , wraps the session store with an - /// that supplies the per-user partition from . Defaults to . + /// that adds a partition from . Defaults to . /// The same instance, configured to use an in-memory session store. public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true) => builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation); @@ -30,7 +30,7 @@ public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuil /// The host agent builder to configure with the session store. Cannot be null. /// The agent session store instance to register. Cannot be null. /// When , wraps the session store with an - /// that supplies the per-user partition from . Defaults to . + /// that adds a partition from . Defaults to . /// The same host agent builder instance, allowing for method chaining. public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true) => builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation); @@ -44,7 +44,7 @@ public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder buil /// The DI service lifetime for the session store registration. Defaults to /// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime. /// When , wraps the session store with an - /// that supplies the per-user partition from . Defaults to . + /// that adds a partition from . Defaults to . /// The same host agent builder instance, enabling further configuration. public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index 6b5a3d11cf5..c6772d5bba3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -5,16 +5,19 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; /// -/// A delegating that supplies the per-user partition key from an +/// A delegating that adds an isolation partition from an /// . /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore { + private const string IsolationPartitionName = "isolation"; + private readonly AgentIsolationKeyProvider? _keyProvider; private readonly bool _strict; @@ -70,43 +73,46 @@ public IsolationKeyScopedAgentSessionStore( } /// - /// 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. + /// Adds the isolation value from the current hosting context to the session key. /// - private async ValueTask GetUserIdAsync(string? userId, CancellationToken cancellationToken) - => await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false) ?? userId; + private async ValueTask GetScopedKeyAsync( + AgentSessionStoreKey key, + CancellationToken cancellationToken) + { + _ = Throw.IfNull(key); + + string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + return isolationKey is null ? key : key.WithPartition(IsolationPartitionName, isolationKey); + } /// public override async ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { - string? resolvedUserId = await this.GetUserIdAsync(userId, cancellationToken).ConfigureAwait(false); - return await this.InnerStore.GetSessionAsync(agent, conversationId, resolvedUserId, cancellationToken).ConfigureAwait(false); + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetSessionAsync(agent, scopedKey, cancellationToken).ConfigureAwait(false); } /// public override async ValueTask GetOrCreateSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { - string? resolvedUserId = await this.GetUserIdAsync(userId, cancellationToken).ConfigureAwait(false); - return await this.InnerStore.GetOrCreateSessionAsync(agent, conversationId, resolvedUserId, cancellationToken).ConfigureAwait(false); + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetOrCreateSessionAsync(agent, scopedKey, cancellationToken).ConfigureAwait(false); } /// public override async ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { - string? resolvedUserId = await this.GetUserIdAsync(userId, cancellationToken).ConfigureAwait(false); - await this.InnerStore.SaveSessionAsync(agent, conversationId, session, resolvedUserId, cancellationToken).ConfigureAwait(false); + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + await this.InnerStore.SaveSessionAsync(agent, scopedKey, session, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs index 4b93cd830f4..662225ee5ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs @@ -16,9 +16,9 @@ public class IsolationKeyScopedAgentSessionStoreOptions /// when returns . /// /// - /// If , the 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. + /// If , the original is passed through without + /// an isolation partition when the provider returns . This mode is suitable + /// for development scenarios or environments where not all requests have isolation keys. /// /// public bool Strict { get; set; } = true; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs index 31cecc76dba..084c9eaabce 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs @@ -41,53 +41,34 @@ namespace Microsoft.Agents.AI.Hosting; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class InMemoryAgentSessionStore : AgentSessionStore { - private readonly ConcurrentDictionary<(string AgentId, string? UserId, string ConversationId), JsonElement> _sessions = new(); + private readonly ConcurrentDictionary<(string AgentId, AgentSessionStoreKey Key), JsonElement> _sessions = new(); /// public override async ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { _ = Throw.IfNull(agent); - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); _ = Throw.IfNull(session); - ValidateUserId(userId); - var key = GetKey(agent, conversationId, userId); - this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); + var storageKey = (agent.Id, key); + this._sessions[storageKey] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } /// public override async ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { _ = Throw.IfNull(agent); - _ = Throw.IfNullOrWhitespace(conversationId); - ValidateUserId(userId); + _ = Throw.IfNull(key); - var key = GetKey(agent, conversationId, userId); - return this._sessions.TryGetValue(key, out JsonElement existingSession) + return this._sessions.TryGetValue((agent.Id, key), out JsonElement existingSession) ? await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false) : null; } - - 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) - { - if (userId is not null) - { - _ = Throw.IfNullOrWhitespace(userId); - } - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs index 2d436c5f7d0..8d847b54c70 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.Hosting; /// /// This store implementation does not have any store under the hood and therefore does not store sessions. -/// always returns . +/// always returns . /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class NoopAgentSessionStore : AgentSessionStore @@ -17,9 +17,8 @@ public sealed class NoopAgentSessionStore : AgentSessionStore /// public override ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { return default; @@ -28,8 +27,7 @@ public override ValueTask SaveSessionAsync( /// public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { return new((AgentSession?)null); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs new file mode 100644 index 00000000000..e8904baf500 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class AgentSessionStoreKeyTests +{ + [Fact] + public void Constructor_CopiesAndSortsPartitions() + { + // Arrange + var partitions = new Dictionary + { + ["user"] = "user-1", + ["tenant"] = "tenant-1", + }; + + // Act + var key = new AgentSessionStoreKey("session-1", partitions); + partitions["user"] = "changed"; + + // Assert + Assert.Equal("session-1", key.SessionId); + Assert.Equal(["tenant", "user"], key.Partitions.Keys); + Assert.Equal("user-1", key.Partitions["user"]); + } + + [Fact] + public void Equality_IgnoresPartitionInsertionOrder() + { + // Arrange + var first = new AgentSessionStoreKey( + "session-1", + new Dictionary + { + ["tenant"] = "tenant-1", + ["user"] = "user-1", + }); + var second = new AgentSessionStoreKey( + "session-1", + new Dictionary + { + ["user"] = "user-1", + ["tenant"] = "tenant-1", + }); + + // Act and assert + Assert.Equal(first, second); + Assert.Equal(first.GetHashCode(), second.GetHashCode()); + Assert.Equal(first.StableStorageKey, second.StableStorageKey); + } + + [Fact] + public void StableStorageKey_DistinguishesPartitionNamesValuesAndMissingPartitions() + { + // Arrange + var unpartitioned = new AgentSessionStoreKey("tenant::session"); + var tenantPartition = new AgentSessionStoreKey("session").WithPartition("tenant", "tenant"); + var userPartition = new AgentSessionStoreKey("session").WithPartition("user", "tenant"); + + // Act and assert + Assert.NotEqual(unpartitioned.StableStorageKey, tenantPartition.StableStorageKey); + Assert.NotEqual(tenantPartition.StableStorageKey, userPartition.StableStorageKey); + } + + [Fact] + public void WithPartition_ReturnsNewKeyAndPreservesOriginal() + { + // Arrange + var original = new AgentSessionStoreKey("session-1"); + + // Act + AgentSessionStoreKey partitioned = original.WithPartition("tenant", "tenant-1"); + + // Assert + Assert.Empty(original.Partitions); + Assert.Equal("tenant-1", partitioned.Partitions["tenant"]); + } + + [Fact] + public void WithPartition_SameValue_ReturnsSameInstance() + { + // Arrange + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); + + // Act + AgentSessionStoreKey result = key.WithPartition("tenant", "tenant-1"); + + // Assert + Assert.Same(key, result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_BlankSessionId_Throws(string sessionId) + { + // Act and assert + Assert.Throws(() => new AgentSessionStoreKey(sessionId)); + } + + [Theory] + [InlineData("", "value")] + [InlineData(" ", "value")] + [InlineData("name", "")] + [InlineData("name", " ")] + public void Constructor_BlankPartition_Throws(string name, string value) + { + // Act and assert + Assert.Throws( + () => new AgentSessionStoreKey( + "session-1", + new Dictionary { [name] = value })); + } + + [Fact] + public void Constructor_InvalidUtf16SessionId_Throws() + { + // Arrange + string invalid = new((char)0xD800, 1); + + // Act and assert + Assert.Throws(() => new AgentSessionStoreKey(invalid)); + } + + [Fact] + public void Constructor_InvalidUtf16Partition_Throws() + { + // Arrange + string invalid = new((char)0xD800, 1); + + // Act and assert + Assert.Throws( + () => new AgentSessionStoreKey( + "session-1", + new Dictionary { ["tenant"] = invalid })); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs index 523d55ee3af..6c046dadad6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs @@ -20,17 +20,14 @@ public async Task GetOrCreateSessionAsync_StoredSession_ReturnsStoredSessionAsyn var storedSession = new TestAgentSession(); var store = new TestAgentSessionStore(storedSession); var agent = new Mock(); + var key = new AgentSessionStoreKey("conversation-1").WithPartition("user", "user-1"); // Act - AgentSession session = await store.GetOrCreateSessionAsync( - agent.Object, - "conversation-1", - "user-1"); + AgentSession session = await store.GetOrCreateSessionAsync(agent.Object, key); // Assert Assert.Same(storedSession, session); - Assert.Equal("conversation-1", store.LastConversationId); - Assert.Equal("user-1", store.LastUserId); + Assert.Same(key, store.LastKey); agent.Protected().Verify( "CreateSessionCoreAsync", Times.Never(), @@ -44,15 +41,13 @@ public async Task GetOrCreateSessionAsync_MissingSession_CreatesSessionAsync() var createdSession = new TestAgentSession(); var store = new TestAgentSessionStore(session: null); var agent = new Mock(); + var key = new AgentSessionStoreKey("conversation-1"); agent.Protected() .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) .ReturnsAsync(createdSession); // Act - AgentSession session = await store.GetOrCreateSessionAsync( - agent.Object, - "conversation-1", - userId: null); + AgentSession session = await store.GetOrCreateSessionAsync(agent.Object, key); // Assert Assert.Same(createdSession, session); @@ -70,31 +65,26 @@ public async Task GetOrCreateSessionAsync_NullAgent_ThrowsAsync() // Act and assert await Assert.ThrowsAsync( - () => store.GetOrCreateSessionAsync(null!, "conversation-1", userId: null).AsTask()); + () => store.GetOrCreateSessionAsync(null!, new AgentSessionStoreKey("conversation-1")).AsTask()); } private sealed class TestAgentSessionStore(AgentSession? session) : AgentSessionStore { - public string? LastConversationId { get; private set; } - - public string? LastUserId { get; private set; } + public AgentSessionStoreKey? LastKey { get; private set; } public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { - this.LastConversationId = conversationId; - this.LastUserId = userId; + this.LastKey = key; return new(session); } public override ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) => default; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs index 6b2567244ef..7f46da4a85f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs @@ -426,7 +426,11 @@ private sealed class ThrowOnceSessionStore : AgentSessionStore public int SaveAttempts => this._saveAttempts; - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { var attempt = Interlocked.Increment(ref this._saveAttempts); if (attempt == 1) @@ -437,7 +441,10 @@ public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, return default; } - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) => + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } @@ -449,9 +456,8 @@ private sealed class CountingSessionStore : AgentSessionStore public override ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { Interlocked.Increment(ref this._saveAttempts); @@ -460,8 +466,7 @@ public override ValueTask SaveSessionAsync( public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => new((AgentSession?)null); } @@ -470,16 +475,14 @@ private sealed class AlwaysLoadedSessionStore : AgentSessionStore { public override ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) => default; public override async ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs index 2b7ae5b0061..fe0014cc1a8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -1391,7 +1391,12 @@ private static ResponseContext NewContextServing(string responseId, IReadOnlyLis private static async Task SerializedSessionOfAsync(AIAgent agent, InMemoryAgentSessionStore store, string responseId) { var sessionKey = HostedConversationKey.Resolve(conversationId: null, previousResponseId: null, responseId); - var session = await store.GetSessionAsync(agent, sessionKey!, FakeHostedSessionIsolationKeyProvider.DefaultUserId, CancellationToken.None); + AIAgent storageAgent = new FoundryHostingAgent( + agent, + FoundryHostingAgent.ResolveSessionStorageIdentity(agent, registrationKey: null, defaultAgent: agent)); + var storageKey = new AgentSessionStoreKey(sessionKey!) + .WithPartition("user", FakeHostedSessionIsolationKeyProvider.DefaultUserId); + var session = await store.GetSessionAsync(storageAgent, storageKey, CancellationToken.None); // The handler persists the session at the end of every turn, so a missing one means the turn did // not get that far and the assertions below would otherwise pass without proving anything. @@ -1792,11 +1797,10 @@ public async Task CreateAsync_AfterStreamCompletes_DoesNotLeakCallIdToCallerCont // These drive the hosted-agent handler (the in-process "hosted instance") against a REAL // FileSystemAgentSessionStore and the REAL PlatformHostedSessionIsolationKeyProvider (no fake), so the // user id is genuinely captured from the request's x-agent-user-id (ResponseContext.PlatformContext). - // They assert the on-disk layout {root}/a-{agent}/u-{userId}/c-{conv}.json for combinations of agent - // name and user. + // They assert distinct stable-key files for combinations of agent name and user. [Fact] - public async Task CreateAsync_MultipleUsersSameAgent_WritePerUserDirectoriesAsync() + public async Task CreateAsync_MultipleUsersSameAgent_WriteDistinctPartitionedFilesAsync() { var root = NewIsolationTempRoot(); try @@ -1811,10 +1815,11 @@ public async Task CreateAsync_MultipleUsersSameAgent_WritePerUserDirectoriesAsyn var (bobReq, bobCtx) = BuildUserRequest("concierge", "trip", userId: "bob"); await DrainEventsAsync(handler.CreateAsync(bobReq, bobCtx.Object, CancellationToken.None)); - // Assert: each user's session is persisted under its own u-{userId} directory beneath the - // shared a-{agent} directory; neither can reach the other's path. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-alice", "c-trip.json"))); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-bob", "c-trip.json"))); + // Assert + Assert.Equal(2, Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories).Length); } finally { @@ -1840,8 +1845,11 @@ public async Task CreateAsync_MultipleAgentsSameUser_WritePerAgentDirectoriesAsy // Assert: each agent buckets the user's session under its own a-{agent} directory, so two // agents in the same container cannot collide on a shared conversation id. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-alice", "c-trip.json"))); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-scheduler", "u-alice", "c-trip.json"))); + Assert.Equal(2, Directory.GetDirectories(store.RootDirectory).Length); + Assert.Equal(2, Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories).Length); } finally { @@ -1873,9 +1881,11 @@ public async Task CreateAsync_SecondUserSameConversation_GetsFreshSessionNoLeakA // (Alice turn 1, Bob turn 1) and one restore (Alice turn 2). Assert.Equal(2, agent.CreateCount); Assert.Equal(1, agent.DeserializeCount); - // And the files live in distinct per-user directories. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-alice", "c-trip.json"))); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-bob", "c-trip.json"))); + // And the users produce distinct partitioned keys. + Assert.Equal(2, Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories).Length); } finally { @@ -1892,7 +1902,7 @@ public async Task CreateAsync_NoUserIdCaptured_NotHosted_SucceedsUnscopedAsync() // Arrange: a non-hosted (local) request whose x-agent-user-id was not captured (PlatformContext // is null). Under unit tests FoundryEnvironment.IsHosted is false, so the container is treated as // local: per-user isolation is simply not triggered and the request succeeds instead of 500ing. - // The session is persisted without a u-{userId} segment (unscoped). The hosted-but-missing-user + // The session key contains no user partition. The hosted-but-missing-user // branch (which still rejects) cannot be unit-tested because FoundryEnvironment.IsHosted is a // process-cached static; it is exercised by the investigation repro app's "hosted" scenario. var store = new FileSystemAgentSessionStore(root); @@ -1902,10 +1912,11 @@ public async Task CreateAsync_NoUserIdCaptured_NotHosted_SucceedsUnscopedAsync() // Act: the request drains without throwing. await DrainEventsAsync(handler.CreateAsync(req, ctx.Object, CancellationToken.None)); - // Assert: the session is written under the agent bucket with NO per-user (u-*) segment. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "c-trip.json"))); - var agentDir = Path.Combine(store.RootDirectory, "a-concierge"); - Assert.Empty(Directory.GetDirectories(agentDir, "u-*")); + // Assert: the session is written under the agent bucket using an unpartitioned key. + Assert.Single(Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories)); } finally { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs index 01552edcd15..631760d001a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs @@ -12,12 +12,8 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; public sealed class FileSystemAgentSessionStoreTests : IDisposable { - private readonly string _root; - - public FileSystemAgentSessionStoreTests() - { - this._root = Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N")); - } + private readonly string _root = + Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N")); public void Dispose() { @@ -30,7 +26,7 @@ public void Dispose() } catch { - // best-effort cleanup + // Best-effort cleanup. } } @@ -55,11 +51,12 @@ public async Task GetSessionAsync_NoFileOnDisk_ReturnsNullAsync() var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent(); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + AgentSession? session = await store.GetSessionAsync(agent, new AgentSessionStoreKey("session-1")); Assert.Null(session); Assert.Equal(0, agent.CreateCalls); Assert.Equal(0, agent.DeserializeCalls); + Assert.False(Directory.Exists(this._root)); } [Fact] @@ -68,7 +65,7 @@ public async Task GetOrCreateSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAg var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent(); - var session = await store.GetOrCreateSessionAsync(agent, "conv-1", userId: null); + AgentSession session = await store.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey("session-1")); Assert.NotNull(session); Assert.Equal(1, agent.CreateCalls); @@ -79,29 +76,28 @@ public async Task GetOrCreateSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAg public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsNullAsync() { var store = new FileSystemAgentSessionStore(this._root); - Directory.CreateDirectory(store.RootDirectory); - File.WriteAllText(Path.Combine(store.RootDirectory, "conv-empty.json"), string.Empty); - + var key = new AgentSessionStoreKey("empty"); var agent = new TestAgent(); - var session = await store.GetSessionAsync(agent, "conv-empty", userId: null); + string agentDirectory = AgentDirectory(store, "name:test-agent"); + Directory.CreateDirectory(agentDirectory); + File.WriteAllText(Path.Combine(agentDirectory, $"k-{key.StableStorageKey}.json"), string.Empty); + + AgentSession? session = await store.GetSessionAsync(agent, key); Assert.Null(session); - Assert.Equal(0, agent.CreateCalls); Assert.Equal(0, agent.DeserializeCalls); } [Fact] - public async Task SaveSessionAsync_CreatesRootDirectoryIfMissingAsync() + public async Task SaveSessionAsync_CreatesRootDirectoryAndStableKeyFileAsync() { var nested = Path.Combine(this._root, "nested", "deeper"); var store = new FileSystemAgentSessionStore(nested); - Assert.False(Directory.Exists(nested)); + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); - var agent = new TestAgent("{\"workflow\":\"x\"}"); - await store.SaveSessionAsync(agent, "conv-2", NewSession(), userId: null); + await store.SaveSessionAsync(new TestAgent("{\"workflow\":\"x\"}"), key, NewSession()); - Assert.True(Directory.Exists(nested)); - Assert.True(File.Exists(Path.Combine(nested, "c-conv-2.json"))); + Assert.True(File.Exists(Path.Combine(AgentDirectory(store, "name:test-agent"), $"k-{key.StableStorageKey}.json"))); } [Fact] @@ -109,100 +105,92 @@ public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsViaAgentSeriali { var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent("{\"foo\":42}"); + var key = new AgentSessionStoreKey("round-trip").WithPartition("tenant", "tenant-1"); - await store.SaveSessionAsync(agent, "round-trip", NewSession(), userId: null); - await store.GetSessionAsync(agent, "round-trip", userId: null); + await store.SaveSessionAsync(agent, key, NewSession()); + await store.GetSessionAsync(agent, key); Assert.Equal(1, agent.SerializeCalls); Assert.Equal(1, agent.DeserializeCalls); - Assert.NotNull(agent.LastDeserialized); - Assert.Equal(JsonValueKind.Object, agent.LastDeserialized!.Value.ValueKind); Assert.Equal(42, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32()); } [Fact] - public async Task SaveSessionAsync_TwoAgentsSameConversationId_DoNotCollideAsync() + public async Task SaveSessionAsync_TwoAgentsSameKey_DoNotCollideAsync() { var store = new FileSystemAgentSessionStore(this._root); + var key = new AgentSessionStoreKey("shared"); var agentA = new TestAgent("{\"who\":\"a\"}", name: "AgentA"); var agentB = new TestAgent("{\"who\":\"b\"}", name: "AgentB"); - await store.SaveSessionAsync(agentA, "shared-conv", NewSession(), userId: null); - await store.SaveSessionAsync(agentB, "shared-conv", NewSession(), userId: null); + await store.SaveSessionAsync(agentA, key, NewSession()); + await store.SaveSessionAsync(agentB, key, NewSession()); - // Agents with distinct Names get distinct subdirectories so neither overwrites the other. - var pathA = Path.Combine(store.RootDirectory, "a-AgentA", "c-shared-conv.json"); - var pathB = Path.Combine(store.RootDirectory, "a-AgentB", "c-shared-conv.json"); - Assert.True(File.Exists(pathA)); - Assert.True(File.Exists(pathB)); + string pathA = Path.Combine(AgentDirectory(store, "name:AgentA"), $"k-{key.StableStorageKey}.json"); + string pathB = Path.Combine(AgentDirectory(store, "name:AgentB"), $"k-{key.StableStorageKey}.json"); Assert.Contains("\"a\"", File.ReadAllText(pathA), StringComparison.Ordinal); Assert.Contains("\"b\"", File.ReadAllText(pathB), StringComparison.Ordinal); } [Fact] - public async Task SaveSessionAsync_LongConversationId_DoesNotStackOverflowAsync() + public async Task SaveSessionAsync_ArbitraryIdentifiersDoNotBecomePathSegmentsAsync() { - // Keep the value < typical OS file-name limits (~255 chars) so the file write - // succeeds, but long enough to force Sanitize past its small-input fast path. var store = new FileSystemAgentSessionStore(this._root); - var conversationId = new string('a', 200); - var agent = new TestAgent(); + var key = new AgentSessionStoreKey("../../session\0") + .WithPartition("../tenant", "/rooted/value"); - await store.SaveSessionAsync(agent, conversationId, NewSession(), userId: null); + await store.SaveSessionAsync(new TestAgent(), key, NewSession()); - var files = Directory.GetFiles(store.RootDirectory, "*.json"); - Assert.Single(files); + string file = Assert.Single(Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories)); + Assert.Equal($"k-{key.StableStorageKey}.json", Path.GetFileName(file)); + Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, Path.GetFullPath(file), StringComparison.Ordinal); } [Fact] - public async Task SaveSessionAsync_SanitizesInvalidPathCharactersAsync() + public async Task SaveSessionAsync_DifferentPartitionsProduceDistinctFilesAsync() { var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent(); + var aliceKey = new AgentSessionStoreKey("shared").WithPartition("user", "alice"); + var bobKey = new AgentSessionStoreKey("shared").WithPartition("user", "bob"); - // Pick an invalid filename char for the current OS. The set differs by platform - // (e.g. '?' is invalid on Windows but not on Linux), so we must select dynamically. - var invalidChars = Path.GetInvalidFileNameChars(); - Assert.NotEmpty(invalidChars); - char invalid = invalidChars[0]; - // Avoid NUL specifically because some shells/loggers handle it oddly; prefer - // the next character if available. - if (invalid == '\0' && invalidChars.Length > 1) - { - invalid = invalidChars[1]; - } + await store.SaveSessionAsync(agent, aliceKey, NewSession()); + await store.SaveSessionAsync(agent, bobKey, NewSession()); - var conversationId = $"id-with{invalid}invalid-chars"; + Assert.Equal(2, Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories).Length); + } - await store.SaveSessionAsync(agent, conversationId, NewSession(), userId: null); + [Fact] + public async Task GetSessionAsync_DifferentPartition_DoesNotReadStoredSessionAsync() + { + var store = new FileSystemAgentSessionStore(this._root); + var agent = new TestAgent("{\"secret\":\"alice-only\"}"); + var aliceKey = new AgentSessionStoreKey("shared").WithPartition("user", "alice"); + var bobKey = new AgentSessionStoreKey("shared").WithPartition("user", "bob"); + await store.SaveSessionAsync(agent, aliceKey, NewSession()); + + AgentSession? bobSession = await store.GetSessionAsync(agent, bobKey); - var files = Directory.GetFiles(store.RootDirectory, "*.json"); - Assert.Single(files); - var fileName = Path.GetFileName(files[0]); - Assert.DoesNotContain(invalid.ToString(), fileName, StringComparison.Ordinal); - Assert.Contains("id-with", fileName, StringComparison.Ordinal); - Assert.Contains("invalid-chars", fileName, StringComparison.Ordinal); + Assert.Null(bobSession); + Assert.Equal(0, agent.DeserializeCalls); } [Fact] - public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollideOnTempFileAsync() + public async Task SaveSessionAsync_ConcurrentSavesOnSameKey_DoNotCollideOnTempFileAsync() { var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent("{\"x\":1}"); - - // Fan out N concurrent saves; with a fixed temp filename ("path.tmp") this would - // race on FileMode.Create / Move. Verify they all complete successfully. + var key = new AgentSessionStoreKey("concurrent"); var tasks = new List(); - for (int i = 0; i < 16; i++) + for (int index = 0; index < 16; index++) { - tasks.Add(store.SaveSessionAsync(agent, "concurrent", NewSession(), userId: null).AsTask()); + tasks.Add(store.SaveSessionAsync(agent, key, NewSession()).AsTask()); } await Task.WhenAll(tasks); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "c-concurrent.json"))); - var leftoverTempFiles = Directory.GetFiles(store.RootDirectory, "*.tmp"); - Assert.Empty(leftoverTempFiles); + Assert.True(File.Exists(Path.Combine(AgentDirectory(store, "name:test-agent"), $"k-{key.StableStorageKey}.json"))); + Assert.Empty(Directory.GetFiles(store.RootDirectory, "*.tmp", SearchOption.AllDirectories)); } [Theory] @@ -212,272 +200,119 @@ public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollid public async Task SaveSessionAsync_AgentNameIsDotSegment_DoesNotEscapeRootAsync(string agentName) { var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: agentName); - await store.SaveSessionAsync(agent, "conv-dots", NewSession(), userId: null); + await store.SaveSessionAsync( + new TestAgent(name: agentName), + new AgentSessionStoreKey("session-1"), + NewSession()); - // The session file must land inside RootDirectory, not in (or above) it as a sibling. - var allFiles = Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories); - Assert.Single(allFiles); - var fullPath = Path.GetFullPath(allFiles[0]); + string fullPath = Path.GetFullPath(Assert.Single( + Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories))); Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, fullPath, StringComparison.Ordinal); - - // The bucket directory name must not be a navigable dot-segment. After - // percent-encoding every dot in an all-dot segment, names like ".", "..", and - // "..." become "%2E", "%2E%2E", "%2E%2E%2E" — distinct, OS-neutral filenames. - var bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!); - Assert.NotEmpty(bucketName); - Assert.NotEqual(".", bucketName); - Assert.NotEqual("..", bucketName); - Assert.DoesNotContain(bucketName, c => c == '.'); + string bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!); + Assert.DoesNotContain(bucketName, value => value == '.'); } [Fact] - public async Task SaveSessionAsync_DistinctNamesWithInvalidChars_ProduceDistinctFilesAsync() + public async Task SaveSessionAsync_DistinctAgentNamesWithInvalidCharacters_DoNotCollideAsync() { - // Percent-encoding must keep otherwise-colliding inputs distinct: under the - // earlier underscore-substitution scheme, "foo/bar" and "foo_bar" both sanitized - // to "foo_bar" and would have shared a session bucket on disk. var store = new FileSystemAgentSessionStore(this._root); - var agentSlash = new TestAgent(name: "foo/bar"); - var agentUnderscore = new TestAgent(name: "foo_bar"); + var key = new AgentSessionStoreKey("session-1"); - await store.SaveSessionAsync(agentSlash, "conv-1", NewSession(), userId: null); - await store.SaveSessionAsync(agentUnderscore, "conv-1", NewSession(), userId: null); + await store.SaveSessionAsync(new TestAgent(name: "foo/bar"), key, NewSession()); + await store.SaveSessionAsync(new TestAgent(name: "foo_bar"), key, NewSession()); - var bucketDirs = Directory.GetDirectories(store.RootDirectory); - Assert.Equal(2, bucketDirs.Length); + Assert.Equal(2, Directory.GetDirectories(store.RootDirectory).Length); } [Fact] - public async Task GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync() + public async Task SaveSessionAsync_UnnamedDirectAgent_ThrowsAsync() { - // Read operations must not have side effects on the file system. var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "agent-with-bucket"); - var session = await store.GetSessionAsync(agent, "missing-id", userId: null); + await Assert.ThrowsAsync( + () => store.SaveSessionAsync( + new TestAgent(name: null), + new AgentSessionStoreKey("session-1"), + NewSession()).AsTask()); + } - Assert.Null(session); - Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory."); + [Fact] + public async Task SaveSessionAsync_NonWritableDirectory_ThrowsClearActionableIOExceptionAsync() + { + Directory.CreateDirectory(this._root); + string blockingFile = Path.Combine(this._root, "blocking-file"); + File.WriteAllText(blockingFile, "x"); + var store = new FileSystemAgentSessionStore(Path.Combine(blockingFile, ".checkpoints")); + + IOException exception = await Assert.ThrowsAsync( + () => store.SaveSessionAsync( + new TestAgent(), + new AgentSessionStoreKey("session-1"), + NewSession()).AsTask()); + + Assert.Contains("could not be created or written to", exception.Message, StringComparison.Ordinal); + Assert.Contains(FileSystemAgentSessionStore.SessionDataDirectoryEnvironmentVariable, exception.Message, StringComparison.Ordinal); + Assert.Contains(FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, exception.Message, StringComparison.Ordinal); + Assert.NotNull(exception.InnerException); } [Fact] public void ResolveDefaultRootDirectory_Hosted_RootsUnderHome() { - // Arrange / Act - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( + string root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( isHosted: true, homeDirectory: "/home/session", currentDirectory: "/some/cwd"); - // Assert - Assert.Equal( - Path.Combine("/home/session", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); + Assert.Equal(Path.Combine("/home/session", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), root); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void ResolveDefaultRootDirectory_HostedWithoutHome_UsesDefaultSessionDataDirectory(string? home) + [InlineData("/")] + public void ResolveDefaultRootDirectory_HostedWithUnusableHome_UsesDefault(string? home) { - // Arrange / Act - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( + string root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( isHosted: true, homeDirectory: home, currentDirectory: "/some/cwd"); - // Assert: falls back to the spec default ("/home/session"), never the filesystem root. Assert.Equal( Path.Combine( FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, FileSystemAgentSessionStore.LocalCheckpointDirectoryName), root); - Assert.NotEqual("/.checkpoints", root); } [Fact] public void ResolveDefaultRootDirectory_NotHosted_UsesCurrentDirectory() { - // Arrange / Act - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( + string root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( isHosted: false, homeDirectory: "/home/session", currentDirectory: "/some/cwd"); - // Assert - Assert.Equal( - Path.Combine("/some/cwd", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); - } - - [Theory] - [InlineData("/")] - public void ResolveDefaultRootDirectory_HostedWithFilesystemRootHome_FallsBackToDefault(string home) - { - // Arrange / Act: a filesystem-root HOME (e.g. "/") must NOT root the store at - // "/.checkpoints", which is read-only in the container and caused issue #6231. - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( - isHosted: true, - homeDirectory: home, - currentDirectory: "/some/cwd"); - - // Assert: falls back to the default session-data directory, never the filesystem root. - Assert.Equal( - Path.Combine( - FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, - FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); - Assert.NotEqual( - Path.Combine(Path.GetPathRoot(Path.GetFullPath(home))!, FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); - } - - [Fact] - public async Task SaveSessionAsync_NonWritableDirectory_ThrowsClearActionableIOExceptionAsync() - { - // Arrange: place a file where the store's root directory needs to be created. Creating - // a directory under an existing file fails with IOException on every OS, standing in for - // the read-only root filesystem of a Foundry hosted container (issue #6231). - Directory.CreateDirectory(this._root); - var blockingFile = Path.Combine(this._root, "blocking-file"); - File.WriteAllText(blockingFile, "x"); - - var store = new FileSystemAgentSessionStore(Path.Combine(blockingFile, ".checkpoints")); - var agent = new TestAgent(); - - // Act - var ex = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-fatal", NewSession(), userId: null)); - - // Assert: failure stays fatal but the message is clear and actionable, and the original - // IO error is preserved as the inner exception. - Assert.Contains("could not be created or written to", ex.Message, StringComparison.Ordinal); - Assert.Contains(FileSystemAgentSessionStore.SessionDataDirectoryEnvironmentVariable, ex.Message, StringComparison.Ordinal); - Assert.Contains(FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, ex.Message, StringComparison.Ordinal); - Assert.Contains(store.RootDirectory, ex.Message, StringComparison.Ordinal); - Assert.NotNull(ex.InnerException); - } - - [Fact] - public async Task SaveSessionAsync_WithUserId_NestsUnderPrefixedAgentAndUserAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent("{\"who\":\"alice\"}", name: "Concierge"); - - await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: "alice"); - - // Layout: {root}/a-{agent}/u-{userId}/c-{conv}.json - var expected = Path.Combine(store.RootDirectory, "a-Concierge", "u-alice", "c-conv-1.json"); - Assert.True(File.Exists(expected), $"expected session at {expected}"); - } - - [Fact] - public async Task SaveSessionAsync_NoUserId_OmitsUserSegmentAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "Concierge"); - - await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: null); - - // No user id -> the u- layer collapses: {root}/a-{agent}/c-{conv}.json - var expected = Path.Combine(store.RootDirectory, "a-Concierge", "c-conv-1.json"); - Assert.True(File.Exists(expected), $"expected session at {expected}"); - Assert.Empty(Directory.GetDirectories(Path.Combine(store.RootDirectory, "a-Concierge"))); - } - - [Fact] - public async Task GetSessionAsync_DifferentUser_DoesNotReadAnotherUsersSessionAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent("{\"secret\":\"alice-only\"}", name: "Concierge"); - - // Alice saves under the same conversationId Bob will guess/forge. - await store.SaveSessionAsync(agent, "shared-conv", NewSession(), userId: "alice"); - - // Bob requests the same conversationId. The per-user partition means Bob's path is distinct, - // so the store returns null (no leak), not Alice's persisted state. - var bobSession = await store.GetSessionAsync(agent, "shared-conv", userId: "bob"); - - Assert.Null(bobSession); // no session for Bob under his partition - Assert.Equal(0, agent.CreateCalls); // a plain lookup never creates - Assert.Equal(0, agent.DeserializeCalls); // Alice's file never deserialized for Bob - } - - [Fact] - public async Task SaveSessionAsync_UserIdEqualToAgentName_StaysDistinctViaPrefixesAsync() - { - // Without prefixes, agent "x" + no user could collide with no-agent + user "x". The a-/u- - // prefixes keep the layers unambiguous. - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "x"); - - await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: "x"); - - var expected = Path.Combine(store.RootDirectory, "a-x", "u-x", "c-conv-1.json"); - Assert.True(File.Exists(expected), $"expected session at {expected}"); - } - - [Theory] - [InlineData("../../escape")] - [InlineData("..")] - [InlineData("user/../../escape")] - [InlineData("a/b")] - [InlineData("a\\b")] - [InlineData(".")] - public async Task SaveSessionAsync_TraversalUserId_IsRejectedAsync(string userId) - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "Concierge"); - - // A forged user id that is not a single safe path segment is rejected outright (CWE-22), - // not sanitized — so it can never escape the storage root. - await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: userId)); - - // Nothing was written outside (or inside) the root. - Assert.False(Directory.Exists(this._root) && Directory.GetFiles(this._root, "*.json", SearchOption.AllDirectories).Length > 0); - } - - [Fact] - public async Task SaveSessionAsync_AbsoluteOrRootedUserId_IsRejectedAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "Concierge"); - - var rooted = Path.IsPathRooted("/etc") ? "/etc" : Path.GetFullPath("/etc"); - await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: rooted)); - } - - [Fact] - public async Task SaveSessionAsync_ThenGetSessionAsync_WithUserId_RoundTripsAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent("{\"foo\":7}", name: "Concierge"); - - await store.SaveSessionAsync(agent, "round-trip", NewSession(), userId: "alice"); - await store.GetSessionAsync(agent, "round-trip", userId: "alice"); - - Assert.Equal(1, agent.SerializeCalls); - Assert.Equal(1, agent.DeserializeCalls); - Assert.Equal(7, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32()); + Assert.Equal(Path.Combine("/some/cwd", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), root); } private static TestSession NewSession() => new(); - private sealed class TestSession : AgentSession - { - } + private static string AgentDirectory(FileSystemAgentSessionStore store, string identity) + => Path.Combine( + store.RootDirectory, + "a-" + new AgentSessionStoreKey(identity).StableStorageKey); + + private sealed class TestSession : AgentSession; private sealed class TestAgent : AIAgent { private readonly string _serializedJson; private readonly string? _name; - public TestAgent(string serializedJson = "{}", string? name = null) + public TestAgent(string serializedJson = "{}", string? name = "test-agent") { this._serializedJson = serializedJson; this._name = name; @@ -486,8 +321,11 @@ public TestAgent(string serializedJson = "{}", string? name = null) public override string? Name => this._name; public int CreateCalls { get; private set; } + public int SerializeCalls { get; private set; } + public int DeserializeCalls { get; private set; } + public JsonElement? LastDeserialized { get; private set; } protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) @@ -496,24 +334,38 @@ protected override ValueTask CreateSessionCoreAsync(CancellationTo return new ValueTask(NewSession()); } - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) { this.SerializeCalls++; - using var doc = JsonDocument.Parse(this._serializedJson); - return new ValueTask(doc.RootElement.Clone()); + using var document = JsonDocument.Parse(this._serializedJson); + return new ValueTask(document.RootElement.Clone()); } - protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) { this.DeserializeCalls++; this.LastDeserialized = serializedState.Clone(); return new ValueTask(NewSession()); } - protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); - protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs index 3fb3da21ddb..27156390f54 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs @@ -20,10 +20,11 @@ public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsAsync() var backing = new FakeStateStore(); var store = NewStore(backing); var agent = new TestAgent("{\"foo\":7}", name: "Concierge"); + var key = Key("round-trip", "user", "alice"); // Act - await store.SaveSessionAsync(agent, "round-trip", new TestSession(), userId: "alice"); - var session = await store.GetSessionAsync(agent, "round-trip", userId: "alice"); + await store.SaveSessionAsync(agent, key, new TestSession()); + var session = await store.GetSessionAsync(agent, key); // Assert Assert.NotNull(session); @@ -39,13 +40,14 @@ public async Task SaveSessionAsync_StoresReadableLogicalKeyAlongsideTheSessionAs var backing = new FakeStateStore(); var store = NewStore(backing); var agent = new TestAgent(name: "Concierge"); + var key = Key("conv-1", "user", "alice"); // Act - await store.SaveSessionAsync(agent, "conv-1", new TestSession(), userId: "alice"); + await store.SaveSessionAsync(agent, key, new TestSession()); // Assert: the item body keeps the readable key so a stored item can be traced back. var item = Assert.Single(backing.Items); - Assert.Equal("\"a14:name:Concierge|u5:alice|c6:conv-1\"", item["key"].ToString()); + Assert.Equal("\"a14:name:Concierge|s6:conv-1|n4:user|v5:alice\"", item["key"].ToString()); } [Fact] @@ -56,7 +58,7 @@ public async Task GetSessionAsync_NothingStored_ReturnsNullAsync() var agent = new TestAgent(name: "Concierge"); // Act - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -72,7 +74,7 @@ public async Task GetOrCreateSessionAsync_NothingStored_ReturnsFreshSessionFromA var agent = new TestAgent(name: "Concierge"); // Act - var session = await store.GetOrCreateSessionAsync(agent, "conv-1", userId: null); + var session = await store.GetOrCreateSessionAsync(agent, Key("conv-1")); // Assert Assert.NotNull(session); @@ -86,10 +88,10 @@ public async Task GetSessionAsync_DifferentUser_DoesNotReadAnotherUsersSessionAs // Arrange: Alice saves under the conversation id Bob will forge. var store = NewStore(new FakeStateStore()); var agent = new TestAgent("{\"secret\":\"alice-only\"}", name: "Concierge"); - await store.SaveSessionAsync(agent, "shared-conv", new TestSession(), userId: "alice"); + await store.SaveSessionAsync(agent, Key("shared-conv", "user", "alice"), new TestSession()); // Act - var bobSession = await store.GetSessionAsync(agent, "shared-conv", userId: "bob"); + var bobSession = await store.GetSessionAsync(agent, Key("shared-conv", "user", "bob")); // Assert Assert.Null(bobSession); @@ -104,10 +106,11 @@ public async Task GetSessionAsync_DifferentAgent_DoesNotReadAnotherAgentsSession var store = NewStore(backing); var concierge = new TestAgent("{\"owner\":\"concierge\"}", name: "Concierge"); var researcher = new TestAgent(name: "Researcher"); - await store.SaveSessionAsync(concierge, "shared-conv", new TestSession(), userId: "alice"); + var key = Key("shared-conv", "user", "alice"); + await store.SaveSessionAsync(concierge, key, new TestSession()); // Act - var otherSession = await store.GetSessionAsync(researcher, "shared-conv", userId: "alice"); + var otherSession = await store.GetSessionAsync(researcher, key); // Assert Assert.Null(otherSession); @@ -125,10 +128,11 @@ public async Task GetSessionAsync_DifferentKeyedRegistration_DoesNotReadAnotherA var support = new TestAgent(); AIAgent billing = new FoundryHostingAgent(billingLeaf, "key:billing"); AIAgent hostedSupport = new FoundryHostingAgent(support, "key:support"); - await store.SaveSessionAsync(billing, "shared-conv", new TestSession(), userId: "alice"); + var key = Key("shared-conv", "user", "alice"); + await store.SaveSessionAsync(billing, key, new TestSession()); // Act - var supportSession = await store.GetSessionAsync(hostedSupport, "shared-conv", userId: "alice"); + var supportSession = await store.GetSessionAsync(hostedSupport, key); // Assert Assert.Null(supportSession); @@ -144,7 +148,7 @@ public async Task SaveSessionAsync_UnnamedAgent_ThrowsAsync() // Act var exception = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-1", new TestSession(), userId: "alice")); + async () => await store.SaveSessionAsync(agent, Key("conv-1", "user", "alice"), new TestSession())); // Assert Assert.Contains(nameof(AIAgent.Name), exception.Message, StringComparison.Ordinal); @@ -159,7 +163,7 @@ public async Task GetSessionAsync_UnnamedAgent_ThrowsAsync() // Act var exception = await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: "alice")); + async () => await store.GetSessionAsync(agent, Key("conv-1", "user", "alice"))); // Assert Assert.Contains(nameof(AIAgent.Name), exception.Message, StringComparison.Ordinal); @@ -179,9 +183,9 @@ public async Task GetStoreAsync_ResolvesTheStoreOnceAcrossManyCallsAsync() var agent = new TestAgent(name: "Concierge"); // Act - await store.SaveSessionAsync(agent, "conv-1", new TestSession(), userId: null); - await store.GetSessionAsync(agent, "conv-1", userId: null); - await store.GetSessionAsync(agent, "conv-2", userId: null); + await store.SaveSessionAsync(agent, Key("conv-1"), new TestSession()); + await store.GetSessionAsync(agent, Key("conv-1")); + await store.GetSessionAsync(agent, Key("conv-2")); // Assert Assert.Equal(1, bindCount); @@ -204,8 +208,8 @@ public async Task GetStoreAsync_FailedBinding_IsRetriedOnTheNextCallAsync() // Act await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null)); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + async () => await store.GetSessionAsync(agent, Key("conv-1"))); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -230,8 +234,8 @@ public async Task GetStoreAsync_CanceledBinding_IsRetriedOnTheNextCallAsync() // Act await Assert.ThrowsAnyAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null)); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + async () => await store.GetSessionAsync(agent, Key("conv-1"))); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -256,8 +260,8 @@ public async Task GetStoreAsync_BindingFaultedWithCancellation_IsRetriedOnTheNex // Act await Assert.ThrowsAnyAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null)); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + async () => await store.GetSessionAsync(agent, Key("conv-1"))); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -282,9 +286,9 @@ public async Task GetStoreAsync_CallerCancellation_DoesNotDiscardTheSharedBindin // Act await Assert.ThrowsAnyAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null, cancellation.Token)); + async () => await store.GetSessionAsync(agent, Key("conv-1"), cancellation.Token)); binding.SetResult(backing); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -292,15 +296,15 @@ await Assert.ThrowsAnyAsync( } [Theory] - [InlineData("name:Concierge", "alice", "conv-1", "a14:name:Concierge|u5:alice|c6:conv-1")] - [InlineData("name:Concierge", null, "conv-1", "a14:name:Concierge|u-1:|c6:conv-1")] - [InlineData("default", "alice", "conv-1", "a7:default|u5:alice|c6:conv-1")] - [InlineData("default", null, "conv-1", "a7:default|u-1:|c6:conv-1")] - [InlineData("name:x", "x", "conv-1", "a6:name:x|u1:x|c6:conv-1")] - public void BuildLogicalKey_UsesLengthPrefixedComponents(string agentIdentity, string? userId, string conversationId, string expected) + [InlineData("name:Concierge", "conv-1", "a14:name:Concierge|s6:conv-1")] + [InlineData("default", "conv-1", "a7:default|s6:conv-1")] + public void BuildLogicalKey_UsesLengthPrefixedComponents( + string agentIdentity, + string sessionId, + string expected) { // Act - var key = FoundryAgentSessionStore.BuildLogicalKey(agentIdentity, conversationId, userId); + string key = FoundryAgentSessionStore.BuildLogicalKey(agentIdentity, Key(sessionId)); // Assert Assert.Equal(expected, key); @@ -309,10 +313,13 @@ public void BuildLogicalKey_UsesLengthPrefixedComponents(string agentIdentity, s [Fact] public void BuildLogicalKey_DelimitersInsideComponents_DoNotCollide() { - // Act: these tuples produced the same delimiter-joined string before components carried - // their lengths. - string first = FoundryAgentSessionStore.BuildLogicalKey("name:Concierge", "x:c-y", "alice"); - string second = FoundryAgentSessionStore.BuildLogicalKey("name:Concierge", "y", "alice:c-x"); + // Act + string first = FoundryAgentSessionStore.BuildLogicalKey( + "name:Concierge", + Key("x:c-y", "user", "alice")); + string second = FoundryAgentSessionStore.BuildLogicalKey( + "name:Concierge", + Key("y", "user", "alice:c-x")); // Assert Assert.NotEqual(first, second); @@ -324,11 +331,10 @@ public void BuildLogicalKey_DelimitersInsideComponents_DoNotCollide() [Fact] public void BuildItemKey_StaysWithinThePlatformKeyLimitForAnyInput() { - // Arrange: an agent name plus a user id plus a conversation id can easily pass 128 chars. + // Arrange var logicalKey = FoundryAgentSessionStore.BuildLogicalKey( $"name:{new string('a', 200)}", - new string('c', 200), - new string('u', 200)); + Key(new string('s', 200), "user", new string('u', 200))); // Act var itemKey = FoundryAgentSessionStore.BuildItemKey(logicalKey); @@ -341,9 +347,9 @@ public void BuildItemKey_StaysWithinThePlatformKeyLimitForAnyInput() public void BuildItemKey_IsStableAndDistinctPerLogicalKey() { // Arrange / Act - var first = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|u5:alice|c6:conv-1"); - var same = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|u5:alice|c6:conv-1"); - var other = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|u3:bob|c6:conv-1"); + var first = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); + var same = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); + var other = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|s6:conv-1|n4:user|v3:bob"); // Assert Assert.Equal(first, same); @@ -371,6 +377,14 @@ public void Constructor_WithoutCredential_IsAllowedForTheSdkLocalFallback() private static FoundryAgentSessionStore NewStore(FakeStateStore backing) => new(_ => Task.FromResult(backing)); + private static AgentSessionStoreKey Key( + string sessionId, + string? partitionName = null, + string? partitionValue = null) + => partitionName is null + ? new AgentSessionStoreKey(sessionId) + : new AgentSessionStoreKey(sessionId).WithPartition(partitionName, partitionValue!); + /// /// An in-memory stand-in for the platform state store. exposes a /// protected constructor and virtual members precisely so it can be substituted like this. diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs index 68e7fb17984..296ee32defd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs @@ -38,8 +38,9 @@ public async Task StoresWithoutCredential_RoundTripThroughTheSdkLocalFallbackAsy var checkpointStore = new FoundryJsonCheckpointStore(); // Act - await sessionStore.SaveSessionAsync(agent, "conversation-1", new TestSession(), userId: "user-1"); - AgentSession? session = await sessionStore.GetSessionAsync(agent, "conversation-1", userId: "user-1"); + var key = new AgentSessionStoreKey("conversation-1").WithPartition("user", "user-1"); + await sessionStore.SaveSessionAsync(agent, key, new TestSession()); + AgentSession? session = await sessionStore.GetSessionAsync(agent, key); using JsonDocument document = JsonDocument.Parse("""{"step":1}"""); CheckpointInfo checkpointInfo = await checkpointStore.CreateCheckpointAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs index 51f0e978746..63e1530c17d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs @@ -123,7 +123,12 @@ public async Task Handler_ResumeSession_MatchingKeys_PassesAsync() // when it has a conversation id; here we plant it directly so we can drive a resume request). // The session is scoped to the same user ("alice") that will resume it. const string ConversationId = "resume-chat-id"; - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession, "alice", CancellationToken.None); + AIAgent storageAgent = new FoundryHostingAgent(capturingAgent, "default"); + await sessionStore.SaveSessionAsync( + storageAgent, + new AgentSessionStoreKey(ConversationId).WithPartition("user", "alice"), + capturingAgent.LastSession, + CancellationToken.None); // Step 3: drive a resume request with the same isolation keys. var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId); @@ -151,12 +156,17 @@ public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async() var (freshRequest, freshContext) = BuildFreshRequest(); await DrainAsync(aliceHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None)); const string ConversationId = "resume-chat-id"; + AIAgent storageAgent = new FoundryHostingAgent(capturingAgent, "default"); // Plant Alice's stamped session UNDER BOB'S partition to simulate a session that reached Bob's // key despite the per-user path partitioning (e.g. a non-partitioning custom store, or in-process // tampering). The 403 identity check is the defense-in-depth layer that must still reject it even // when the physical partition was bypassed. - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, "bob", CancellationToken.None); + await sessionStore.SaveSessionAsync( + storageAgent, + new AgentSessionStoreKey(ConversationId).WithPartition("user", "bob"), + capturingAgent.LastSession!, + CancellationToken.None); // Bob attempts to resume Alice's conversation. var bobProvider = new FakeHostedSessionIsolationKeyProvider("bob"); @@ -181,7 +191,12 @@ public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync() var sessionStore = new InMemoryAgentSessionStore(); const string ConversationId = "untagged-chat-id"; var untagged = await capturingAgent.CreateSessionAsync(CancellationToken.None); - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, untagged, "alice", CancellationToken.None); + AIAgent storageAgent = new FoundryHostingAgent(capturingAgent, "default"); + await sessionStore.SaveSessionAsync( + storageAgent, + new AgentSessionStoreKey(ConversationId).WithPartition("user", "alice"), + untagged, + CancellationToken.None); var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice"); var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs index 6c1bf39e809..98020b21e59 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs @@ -561,9 +561,8 @@ private sealed class PhaseObservingSessionStore( { public override async ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { JsonElement state = await agent.SerializeSessionAsync( @@ -572,9 +571,8 @@ public override async ValueTask SaveSessionAsync( coordinator.SerializedStates.Add(state.GetRawText()); await inner.SaveSessionAsync( agent, - conversationId, + key, session, - userId, cancellationToken); JsonProperty? phaseProperty = state @@ -592,13 +590,11 @@ await inner.SaveSessionAsync( public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => inner.GetSessionAsync( agent, - conversationId, - userId, + key, cancellationToken); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs index 8f1aeb79153..684ce6b6b50 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -1589,16 +1589,14 @@ public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1621,9 +1619,8 @@ public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-stream"), + It.Is(key => key.SessionId == "ctx-stream"), It.IsAny(), - It.Is(u => u == null), It.IsAny()), Times.Once); } @@ -1640,16 +1637,14 @@ public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSave mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1669,9 +1664,8 @@ public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSave mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx"), + It.Is(key => key.SessionId == "ctx"), It.IsAny(), - It.Is(u => u == null), It.IsAny()), Times.Once); } @@ -1765,16 +1759,14 @@ public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1799,16 +1791,14 @@ public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() mockSessionStore.Verify( x => x.GetSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-1"), - It.Is(u => u == null), + It.Is(key => key.SessionId == "ctx-1"), It.IsAny()), Times.Once); mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-1"), + It.Is(key => key.SessionId == "ctx-1"), It.IsAny(), - It.Is(u => u == null), It.IsAny()), Times.Once); } @@ -1974,16 +1964,14 @@ public async Task ExecuteAsync_NonStreaming_WhenRunAsyncThrows_SavesSessionWithU mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -2019,9 +2007,8 @@ await Assert.ThrowsAsync(() => mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx"), + It.Is(key => key.SessionId == "ctx"), It.IsAny(), - It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2038,16 +2025,14 @@ public async Task ExecuteAsync_Streaming_WhenRunStreamingAsyncThrows_SavesSessio mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -2083,9 +2068,8 @@ await Assert.ThrowsAsync(() => mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-stream"), + It.Is(key => key.SessionId == "ctx-stream"), It.IsAny(), - It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2102,16 +2086,14 @@ public async Task ExecuteAsync_OnContinuation_WhenRunAsyncThrows_SavesSessionWit mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -2153,9 +2135,8 @@ await Assert.ThrowsAsync(() => mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-cont"), + It.Is(key => key.SessionId == "ctx-cont"), It.IsAny(), - It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2172,16 +2153,14 @@ public async Task ExecuteAsync_NonStreaming_SavesSessionWithUncancelledTokenAsyn mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -2206,9 +2185,8 @@ await handler.ExecuteAsync( mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx"), + It.Is(key => key.SessionId == "ctx"), It.IsAny(), - It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2225,16 +2203,14 @@ public async Task ExecuteAsync_Streaming_SavesSessionWithUncancelledTokenAsync() mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -2259,9 +2235,8 @@ await handler.ExecuteAsync( mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-stream"), + It.Is(key => key.SessionId == "ctx-stream"), It.IsAny(), - It.Is(u => u == null), It.Is(ct => ct == CancellationToken.None)), Times.Once); } @@ -2278,16 +2253,14 @@ public async Task ExecuteAsync_OnContinuation_SavesSessionWithUncancelledTokenAs mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -2317,9 +2290,8 @@ await handler.ExecuteAsync( mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-cont"), + It.Is(key => key.SessionId == "ctx-cont"), It.IsAny(), - It.Is(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 319104b2f70..9145d82cd5d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -427,16 +427,14 @@ public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUs mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -454,8 +452,7 @@ public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUs mockSessionStore.Verify( x => x.GetSessionAsync( It.IsAny(), - 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 9a250e9ea71..a8ccb35eefd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs @@ -76,10 +76,11 @@ public async Task SaveAndGetSessionAsync_PersistsAcrossStoreAndAgentInstancesAsy var savingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); var loadingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-1").WithPartition("user", "user-1"); // Act - await savingStore.SaveSessionAsync(savingAgent, "session-1", session, userId: "user-1"); - AgentSession? restored = await loadingStore.GetSessionAsync(loadingAgent, "session-1", userId: "user-1"); + await savingStore.SaveSessionAsync(savingAgent, key, session); + AgentSession? restored = await loadingStore.GetSessionAsync(loadingAgent, key); // Assert Assert.NotNull(restored); @@ -95,6 +96,8 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() string commonPrefix = new('s', 2048); string firstId = commonPrefix + "\0:first"; string secondId = commonPrefix + "\u0001/second"; + var firstKey = new AgentSessionStoreKey(firstId); + var secondKey = new AgentSessionStoreKey(secondId); AgentSession firstSession = await agent.CreateSessionAsync(); firstSession.StateBag.SetValue("marker", "first"); @@ -102,10 +105,10 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() secondSession.StateBag.SetValue("marker", "second"); // Act - 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); + await store.SaveSessionAsync(agent, firstKey, firstSession); + await store.SaveSessionAsync(agent, secondKey, secondSession); + AgentSession? restoredFirst = await store.GetSessionAsync(agent, firstKey); + AgentSession? restoredSecond = await store.GetSessionAsync(agent, secondKey); List blobNames = []; await foreach (BlobItem blob in this._containerClient.GetBlobsAsync()) { @@ -129,28 +132,32 @@ public async Task GetSessionAsync_MissingSession_ReturnsNullAsync() var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); // Act - AgentSession? restored = await store.GetSessionAsync(agent, "missing", userId: "user-1"); + AgentSession? restored = await store.GetSessionAsync( + agent, + new AgentSessionStoreKey("missing").WithPartition("user", "user-1")); // Assert Assert.Null(restored); } [Fact] - public async Task SaveAndGetSessionAsync_IsolatesUsersAsync() + public async Task SaveAndGetSessionAsync_IsolatesPartitionsAsync() { // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var user1Key = new AgentSessionStoreKey("session-1").WithPartition("user", "user-1"); + var user2Key = new AgentSessionStoreKey("session-1").WithPartition("user", "user-2"); AgentSession first = await agent.CreateSessionAsync(); first.StateBag.SetValue("marker", "first"); AgentSession second = await agent.CreateSessionAsync(); second.StateBag.SetValue("marker", "second"); // Act - await store.SaveSessionAsync(agent, "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"); + await store.SaveSessionAsync(agent, user1Key, first); + await store.SaveSessionAsync(agent, user2Key, second); + AgentSession? restoredFirst = await store.GetSessionAsync(agent, user1Key); + AgentSession? restoredSecond = await store.GetSessionAsync(agent, user2Key); // Assert Assert.NotNull(restoredFirst); @@ -165,16 +172,18 @@ public async Task SaveAndGetSessionAsync_ScopedAndUnscopedIdentifiersDoNotCollid // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var partitionedKey = new AgentSessionStoreKey("conversation").WithPartition("tenant", "tenant"); + var unpartitionedKey = new AgentSessionStoreKey("tenant::conversation"); AgentSession scoped = await agent.CreateSessionAsync(); scoped.StateBag.SetValue("marker", "scoped"); AgentSession unscoped = await agent.CreateSessionAsync(); unscoped.StateBag.SetValue("marker", "unscoped"); // Act - await store.SaveSessionAsync(agent, "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); + await store.SaveSessionAsync(agent, partitionedKey, scoped); + await store.SaveSessionAsync(agent, unpartitionedKey, unscoped); + AgentSession? restoredScoped = await store.GetSessionAsync(agent, partitionedKey); + AgentSession? restoredUnscoped = await store.GetSessionAsync(agent, unpartitionedKey); // Assert Assert.NotNull(restoredScoped); @@ -189,15 +198,16 @@ public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync() // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-1"); AgentSession first = await agent.CreateSessionAsync(); first.StateBag.SetValue("marker", "first"); AgentSession second = await agent.CreateSessionAsync(); second.StateBag.SetValue("marker", "second"); // Act - await store.SaveSessionAsync(agent, "session-1", first, userId: null); - await store.SaveSessionAsync(agent, "session-1", second, userId: null); - AgentSession? restored = await store.GetSessionAsync(agent, "session-1", userId: null); + await store.SaveSessionAsync(agent, key, first); + await store.SaveSessionAsync(agent, key, second); + AgentSession? restored = await store.GetSessionAsync(agent, key); List blobs = []; await foreach (BlobItem blob in this._containerClient.GetBlobsAsync()) { @@ -224,7 +234,7 @@ public async Task GetSessionAsync_MissingContainerWithoutAutoCreatePropagatesErr // Act RequestFailedException exception = await Assert.ThrowsAsync( - () => store.GetSessionAsync(agent, "session-1", userId: null).AsTask()); + () => store.GetSessionAsync(agent, new AgentSessionStoreKey("session-1")).AsTask()); // Assert Assert.Equal(BlobErrorCode.ContainerNotFound.ToString(), exception.ErrorCode); @@ -236,17 +246,18 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshotsAsync() // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-1"); AgentSession original = await agent.CreateSessionAsync(); original.StateBag.SetValue("marker", "saved"); - await store.SaveSessionAsync(agent, "session-1", original, userId: null); + await store.SaveSessionAsync(agent, key, original); // Act - AgentSession? first = await store.GetSessionAsync(agent, "session-1", userId: null); - AgentSession? second = await store.GetSessionAsync(agent, "session-1", userId: null); + AgentSession? first = await store.GetSessionAsync(agent, key); + AgentSession? second = await store.GetSessionAsync(agent, key); Assert.NotNull(first); Assert.NotNull(second); first.StateBag.SetValue("marker", "changed"); - AgentSession? third = await store.GetSessionAsync(agent, "session-1", userId: null); + AgentSession? third = await store.GetSessionAsync(agent, key); // Assert Assert.NotNull(third); @@ -267,7 +278,10 @@ public async Task SaveSessionAsync_ConcurrentFirstWritesCreateContainerSafelyAsy { AgentSession session = await agent.CreateSessionAsync(); session.StateBag.SetValue("marker", index.ToString()); - writes.Add(store.SaveSessionAsync(agent, $"session-{index}", session, userId: null).AsTask()); + writes.Add(store.SaveSessionAsync( + agent, + new AgentSessionStoreKey($"session-{index}"), + session).AsTask()); } // Act @@ -327,6 +341,17 @@ public void Constructor_BlobNamePrefixExceedsAzureLimit_Throws() () => new AzureBlobAgentSessionStore(this._containerClient, "assistant", options)); } + [Fact] + public void Constructor_InvalidUtf16AgentNamespace_Throws() + { + // Arrange + string invalid = new((char)0xD800, 1); + + // Act and assert + Assert.Throws( + () => new AzureBlobAgentSessionStore(this._containerClient, invalid)); + } + private static async Task IsAzuriteAvailableAsync() { using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromSeconds(3)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs index b561e57d85c..6c9ba0428d7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs @@ -38,7 +38,7 @@ public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() // Act OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, sessionStoreId, userId: null); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); @@ -62,7 +62,7 @@ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!; - AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, secondSessionStoreId, userId: null); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(secondSessionStoreId)); AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); // Assert: continuation succeeded and the model produced a textual answer. @@ -75,10 +75,10 @@ private static async Task RunTurnAsync(AIAgent agent, AgentSessionStore JsonElement body = ParseBody(bodyJson); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, sessionStoreId, userId: null); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); - await sessionStore.SaveSessionAsync(agent, responseId, session, userId: null); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(responseId), session); return responseId; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs index 0cf5e18058a..8452802ae93 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs @@ -38,7 +38,7 @@ public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() // Act OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, sessionStoreId, userId: null); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); @@ -62,7 +62,7 @@ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!; - AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, secondSessionStoreId, userId: null); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(secondSessionStoreId)); AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); // Assert: continuation succeeded and the model produced a textual answer. @@ -75,10 +75,10 @@ private static async Task RunTurnAsync(AIAgent agent, AgentSessionStore JsonElement body = ParseBody(bodyJson); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, sessionStoreId, userId: null); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); - await sessionStore.SaveSessionAsync(agent, responseId, session, userId: null); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(responseId), session); return responseId; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs index aaa659a877a..8e23b52ea87 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs @@ -218,9 +218,8 @@ private async Task StartAgentHostAsync(IChatClient chatClient) string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); AgentSession session = await sessionStore.GetOrCreateSessionAsync( agent, - sessionStoreId, - userId: null, - cancellationToken: ct); + new AgentSessionStoreKey(sessionStoreId), + ct); string responseId = OpenAIResponses.CreateResponseId(); // A stable conversation id is a mutable head (write back under the same id); a previous_response_id @@ -240,20 +239,18 @@ private async Task StartAgentHostAsync(IChatClient chatClient) await sessionStore.SaveSessionAsync( agent, - saveId, + new AgentSessionStoreKey(saveId), session, - userId: null, - cancellationToken: ct); + ct); return Results.Empty; } AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, ct); await sessionStore.SaveSessionAsync( agent, - saveId, + new AgentSessionStoreKey(saveId), session, - userId: null, - cancellationToken: ct); + ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs index 8eac6eaecc2..c6d5e460547 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -30,17 +30,15 @@ public DelegatingAgentSessionStoreTests() this._innerStoreMock .Setup(x => x.GetSessionAsync( It.IsAny(), - 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(), - It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -81,23 +79,20 @@ public void Constructor_WithValidInnerStore_SetsInnerStore() public async Task GetSessionAsyncDelegatesToInnerStoreAsync() { // Arrange - const string ExpectedConversationId = "test-conversation-id"; - const string ExpectedUserId = "test-user-id"; + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); var expectedCancellationToken = new CancellationToken(); this._innerStoreMock .Setup(x => x.GetSessionAsync( It.Is(a => a == this._agentMock.Object), - It.Is(c => c == ExpectedConversationId), - It.Is(u => u == ExpectedUserId), + It.Is(key => key.Equals(expectedKey)), It.Is(ct => ct == expectedCancellationToken))) .ReturnsAsync(this._testSession); // Act var session = await this._delegatingStore.GetSessionAsync( this._agentMock.Object, - ExpectedConversationId, - ExpectedUserId, + expectedKey, expectedCancellationToken); // Assert @@ -105,8 +100,7 @@ public async Task GetSessionAsyncDelegatesToInnerStoreAsync() this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, - ExpectedConversationId, - ExpectedUserId, + expectedKey, expectedCancellationToken), Times.Once); } @@ -118,35 +112,31 @@ public async Task GetSessionAsyncDelegatesToInnerStoreAsync() public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() { // Arrange - const string ExpectedConversationId = "test-conversation-id"; - const string ExpectedUserId = "test-user-id"; + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); var expectedCancellationToken = new CancellationToken(); var expectedSession = new TestAgentSession(); this._innerStoreMock .Setup(x => x.SaveSessionAsync( It.Is(a => a == this._agentMock.Object), - It.Is(c => c == ExpectedConversationId), + It.Is(key => key.Equals(expectedKey)), It.Is(s => s == expectedSession), - It.Is(u => u == ExpectedUserId), It.Is(ct => ct == expectedCancellationToken))) .Returns(ValueTask.CompletedTask); // Act await this._delegatingStore.SaveSessionAsync( this._agentMock.Object, - ExpectedConversationId, + expectedKey, expectedSession, - ExpectedUserId, expectedCancellationToken); // Assert this._innerStoreMock.Verify( x => x.SaveSessionAsync( this._agentMock.Object, - ExpectedConversationId, + expectedKey, expectedSession, - ExpectedUserId, expectedCancellationToken), Times.Once); } @@ -158,22 +148,21 @@ await this._delegatingStore.SaveSessionAsync( public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() { // Arrange - const string ExpectedConversationId = "test-conversation-id"; + var expectedKey = new AgentSessionStoreKey("test-conversation-id"); var taskCompletionSource = new TaskCompletionSource(); var innerStoreMock = new Mock(); innerStoreMock .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .Returns(new ValueTask(taskCompletionSource.Task)); var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); // Act - var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId, userId: null); + var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, expectedKey); // Assert Assert.False(resultTask.IsCompleted); @@ -189,23 +178,20 @@ public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() public async Task GetOrCreateSessionAsyncUsesOverriddenGetSessionAsyncAsync() { // Arrange - const string ExpectedConversationId = "test-conversation-id"; - const string ExpectedUserId = "test-user-id"; + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); var store = new OverridingGetSessionStore(this._innerStoreMock.Object, this._testSession); // Act AgentSession session = await store.GetOrCreateSessionAsync( this._agentMock.Object, - ExpectedConversationId, - ExpectedUserId); + expectedKey); // Assert Assert.Same(this._testSession, session); this._innerStoreMock.Verify( x => x.GetOrCreateSessionAsync( It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); } @@ -217,7 +203,7 @@ public async Task GetOrCreateSessionAsyncUsesOverriddenGetSessionAsyncAsync() public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() { // Arrange - const string ExpectedConversationId = "test-conversation-id"; + var expectedKey = new AgentSessionStoreKey("test-conversation-id"); var expectedSession = new TestAgentSession(); var taskCompletionSource = new TaskCompletionSource(); @@ -225,9 +211,8 @@ public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() innerStoreMock .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) .Returns(new ValueTask(taskCompletionSource.Task)); @@ -236,9 +221,8 @@ public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() // Act var resultTask = delegatingStore.SaveSessionAsync( this._agentMock.Object, - ExpectedConversationId, - expectedSession, - userId: null); + expectedKey, + expectedSession); // Assert Assert.False(resultTask.IsCompleted); @@ -264,8 +248,7 @@ private sealed class OverridingGetSessionStore(AgentSessionStore innerStore, Age { public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => new(session); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs index a5587e2a525..694053273fe 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -22,26 +22,12 @@ public async Task GetSessionAsync_MissingSession_ReturnsNullAsync() var agent = new Mock(); // Act - AgentSession? session = await store.GetSessionAsync(agent.Object, "missing", userId: null); + AgentSession? session = await store.GetSessionAsync(agent.Object, new AgentSessionStoreKey("missing")); // Assert Assert.Null(session); } - [Theory] - [InlineData("")] - [InlineData(" ")] - public async Task GetSessionAsync_BlankUserId_ThrowsAsync(string userId) - { - // Arrange - var store = new InMemoryAgentSessionStore(); - var agent = new Mock(); - - // Act and assert - await Assert.ThrowsAsync( - () => store.GetSessionAsync(agent.Object, "conversation-1", userId).AsTask()); - } - [Fact] public async Task GetSessionAsync_ReturnsIndependentSnapshot_ForConcurrentBranchesAsync() { @@ -49,14 +35,15 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshot_ForConcurrentBranch // and a stored session that carries some state to copy. AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new InMemoryAgentSessionStore(); + var key = new AgentSessionStoreKey("s1").WithPartition("user", "user-1"); AgentSession original = await agent.CreateSessionAsync(); original.StateBag.SetValue("marker", "v1"); - await store.SaveSessionAsync(agent, "s1", original, userId: "user-1"); + await store.SaveSessionAsync(agent, key, original); // Act: two concurrent branches read the same stored id. - AgentSession? branchA = await store.GetSessionAsync(agent, "s1", userId: "user-1"); - AgentSession? branchB = await store.GetSessionAsync(agent, "s1", userId: "user-1"); + AgentSession? branchA = await store.GetSessionAsync(agent, key); + AgentSession? branchB = await store.GetSessionAsync(agent, key); // Assert: each branch is an independent instance carrying the same content. Assert.NotNull(branchA); @@ -69,7 +56,7 @@ 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", userId: "user-1"); + AgentSession? branchC = await store.GetSessionAsync(agent, key); Assert.NotNull(branchC); Assert.Equal("v1", branchC.StateBag.GetValue("marker")); } @@ -80,13 +67,15 @@ public async Task GetSessionAsync_DifferentUsers_AreIsolatedAsync() // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new InMemoryAgentSessionStore(); + var user1Key = new AgentSessionStoreKey("s1").WithPartition("user", "user-1"); + var user2Key = new AgentSessionStoreKey("s1").WithPartition("user", "user-2"); AgentSession session = await agent.CreateSessionAsync(); session.StateBag.SetValue("marker", "user-1"); - await store.SaveSessionAsync(agent, "s1", session, userId: "user-1"); + await store.SaveSessionAsync(agent, user1Key, session); // Act - AgentSession? matchingUser = await store.GetSessionAsync(agent, "s1", userId: "user-1"); - AgentSession? differentUser = await store.GetSessionAsync(agent, "s1", userId: "user-2"); + AgentSession? matchingUser = await store.GetSessionAsync(agent, user1Key); + AgentSession? differentUser = await store.GetSessionAsync(agent, user2Key); // Assert Assert.NotNull(matchingUser); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs index 486b07512fc..33469250f1f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -13,7 +13,6 @@ namespace Microsoft.Agents.AI.Hosting.UnitTests; public class IsolationKeyScopedAgentSessionStoreTests { private const string TestIsolationKey = "test-key"; - private const string TestConversationId = "test-conversation-id"; private readonly Mock _innerStoreMock = new(); private readonly Mock _agentMock = new(); @@ -30,26 +29,24 @@ public void RequiresInnerStore() } [Fact] - public async Task GetSessionAsync_PassesConversationAndIsolationKeySeparatelyAsync() + public async Task GetSessionAsync_AddsIsolationPartitionAsync() { // Arrange var expectedSession = new TestAgentSession(); + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); this._innerStoreMock .Setup(x => x.GetSessionAsync( this._agentMock.Object, - TestConversationId, - TestIsolationKey, + It.Is(actual => + actual.SessionId == "session-1" + && actual.Partitions["tenant"] == "tenant-1" + && actual.Partitions["isolation"] == TestIsolationKey), It.IsAny())) .ReturnsAsync(expectedSession); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - new TestAgentIsolationKeyProvider(TestIsolationKey)); + var store = this.CreateStore(TestIsolationKey); // Act - AgentSession? session = await store.GetSessionAsync( - this._agentMock.Object, - TestConversationId, - userId: null); + AgentSession? session = await store.GetSessionAsync(this._agentMock.Object, key); // Assert Assert.Same(expectedSession, session); @@ -57,54 +54,46 @@ public async Task GetSessionAsync_PassesConversationAndIsolationKeySeparatelyAsy } [Fact] - public async Task SaveSessionAsync_PassesConversationAndIsolationKeySeparatelyAsync() + public async Task SaveSessionAsync_AddsIsolationPartitionAsync() { // Arrange + var key = new AgentSessionStoreKey("session-1"); var session = new TestAgentSession(); this._innerStoreMock .Setup(x => x.SaveSessionAsync( this._agentMock.Object, - TestConversationId, + It.Is(actual => + actual.SessionId == "session-1" + && actual.Partitions["isolation"] == TestIsolationKey), session, - TestIsolationKey, It.IsAny())) .Returns(ValueTask.CompletedTask); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - new TestAgentIsolationKeyProvider(TestIsolationKey)); + var store = this.CreateStore(TestIsolationKey); // Act - await store.SaveSessionAsync( - this._agentMock.Object, - TestConversationId, - session, - userId: null); + await store.SaveSessionAsync(this._agentMock.Object, key, session); // Assert this._innerStoreMock.VerifyAll(); } [Fact] - public async Task GetOrCreateSessionAsync_PassesIsolationKeyToInnerStoreAsync() + public async Task GetOrCreateSessionAsync_ForwardsScopedKeyToSpecializedInnerStoreAsync() { // Arrange var expectedSession = new TestAgentSession(); + var key = new AgentSessionStoreKey("session-1"); this._innerStoreMock .Setup(x => x.GetOrCreateSessionAsync( this._agentMock.Object, - TestConversationId, - TestIsolationKey, + It.Is(actual => + actual.Partitions["isolation"] == TestIsolationKey), It.IsAny())) .ReturnsAsync(expectedSession); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - new TestAgentIsolationKeyProvider(TestIsolationKey)); + var store = this.CreateStore(TestIsolationKey); // Act - AgentSession session = await store.GetOrCreateSessionAsync( - this._agentMock.Object, - TestConversationId, - userId: null); + AgentSession session = await store.GetOrCreateSessionAsync(this._agentMock.Object, key); // Assert Assert.Same(expectedSession, session); @@ -115,86 +104,71 @@ public async Task GetOrCreateSessionAsync_PassesIsolationKeyToInnerStoreAsync() public async Task GetSessionAsync_StrictModeWithoutIsolationKey_ThrowsAsync() { // Arrange - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - new TestAgentIsolationKeyProvider(null), + var store = this.CreateStore( + isolationKey: null, new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); // Act var exception = await Assert.ThrowsAsync( - () => store.GetSessionAsync(this._agentMock.Object, TestConversationId, userId: null).AsTask()); - - // Assert - Assert.Contains("Agent isolation key is required", exception.Message); - } - - [Fact] - public async Task SaveSessionAsync_StrictModeWithoutIsolationKey_ThrowsAsync() - { - // Arrange - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - new TestAgentIsolationKeyProvider(null), - new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); - - // Act - var exception = await Assert.ThrowsAsync( - () => store.SaveSessionAsync( + () => store.GetSessionAsync( this._agentMock.Object, - TestConversationId, - new TestAgentSession(), - userId: null).AsTask()); + new AgentSessionStoreKey("session-1")).AsTask()); // Assert Assert.Contains("Agent isolation key is required", exception.Message); } [Fact] - public async Task GetSessionAsync_NonStrictModePreservesCallerUserIdWhenKeyIsMissingAsync() + public async Task GetSessionAsync_NonStrictModePreservesExistingPartitionsAsync() { // Arrange - const string CallerUserId = "caller-user"; + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); this._innerStoreMock .Setup(x => x.GetSessionAsync( this._agentMock.Object, - TestConversationId, - CallerUserId, + key, It.IsAny())) .ReturnsAsync((AgentSession?)null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - new TestAgentIsolationKeyProvider(null), + var store = this.CreateStore( + isolationKey: null, new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId, CallerUserId); + await store.GetSessionAsync(this._agentMock.Object, key); // Assert this._innerStoreMock.VerifyAll(); } [Fact] - public async Task GetSessionAsync_IsolationKeyOverridesCallerUserIdAsync() + public async Task GetSessionAsync_IsolationProviderReplacesExistingIsolationPartitionAsync() { // Arrange + var key = new AgentSessionStoreKey("session-1").WithPartition("isolation", "caller-value"); this._innerStoreMock .Setup(x => x.GetSessionAsync( this._agentMock.Object, - TestConversationId, - TestIsolationKey, + It.Is(actual => + actual.Partitions["isolation"] == TestIsolationKey), It.IsAny())) .ReturnsAsync((AgentSession?)null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - new TestAgentIsolationKeyProvider(TestIsolationKey)); + var store = this.CreateStore(TestIsolationKey); // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId, "caller-user"); + await store.GetSessionAsync(this._agentMock.Object, key); // Assert this._innerStoreMock.VerifyAll(); } + private IsolationKeyScopedAgentSessionStore CreateStore( + string? isolationKey, + IsolationKeyScopedAgentSessionStoreOptions? options = null) + => new( + this._innerStoreMock.Object, + new TestAgentIsolationKeyProvider(isolationKey), + options); + private sealed class TestAgentIsolationKeyProvider(string? key) : AgentIsolationKeyProvider { public override ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken = default) From c5fa9fe18a165f8583e5afec6818f069e3be859e Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:02:41 +0100 Subject: [PATCH 5/7] refactor(dotnet): keep session keys storage agnostic --- .../0039-shared-agent-session-store.md | 7 +- .../003-dotnet-hosting-protocol-helpers.md | 3 +- .../AgentSessionStoreKey.cs | 78 ++++--------------- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 1 - .../PublicAPI/net472/PublicAPI.Unshipped.txt | 1 - .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 1 - .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 1 - .../netstandard2.0/PublicAPI.Unshipped.txt | 1 - .../FileSystemAgentSessionStore.cs | 6 +- .../FoundryAgentSessionKeyEncoder.cs | 61 +++++++++++++++ .../FoundryAgentSessionStore.cs | 67 ++-------------- .../InMemoryAgentSessionStore.cs | 10 ++- .../Blob/AzureBlobAgentSessionStore.cs | 19 ++++- .../AgentSessionStoreKeyTests.cs | 30 +------ .../FileSystemAgentSessionStoreTests.cs | 23 ++++-- .../FoundryAgentSessionStoreTests.cs | 20 ++--- 16 files changed, 146 insertions(+), 183 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs diff --git a/docs/decisions/0039-shared-agent-session-store.md b/docs/decisions/0039-shared-agent-session-store.md index db4bbd4ae3c..89122d59ed8 100644 --- a/docs/decisions/0039-shared-agent-session-store.md +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -45,8 +45,7 @@ Chosen option: **Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Ab - `AgentSessionStoreKey.SessionId` identifies the logical session. - `AgentSessionStoreKey.Partitions` holds zero or more named isolation dimensions. Every partition is part of identity and implementations cannot ignore unknown partitions. -- Partition order does not affect identity. Abstractions provides a stable, opaque storage key derived - from the session id and every partition. +- Partition order does not affect identity. Physical encoding remains the responsibility of each store. - `DeleteSessionAsync` and service inspection are not part of the shared contract. The duplicate types in `Microsoft.Agents.AI.Hosting` and `Microsoft.Agents.AI.Foundry.Hosting` are removed. @@ -57,8 +56,8 @@ adds the value from `AgentIsolationKeyProvider` under the `isolation` partition partitions. Protocol-specific hosting can add named partitions such as `user`, `tenant`, or `chat` before loading the session. `AIHostAgent` uses `GetOrCreateSessionAsync` when it needs a ready session. -Azure Blob Storage and the filesystem store use `AgentSessionStoreKey.StableStorageKey`. Foundry State -Store incorporates the same session id and partition collection into its item identity. Version 1 Azure +Azure Blob Storage, filesystem storage, and Foundry State Store each encode the session id and every +partition into their own collision-safe physical key. Version 1 Azure Blob keys are not read because the package is still preview and the previous format cannot distinguish all partition combinations safely. diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 47bae157b27..746d3634bf9 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -142,7 +142,8 @@ minted `resp_*` id when the protocol creates a continuation id. No agent-side ho the convenience method already performs lookup or creation. `AgentSessionStoreKey` contains a session id plus arbitrary named partitions. Every partition contributes -to identity, independent of dictionary order. Stores must not ignore unknown partitions. Provider-specific +to identity, independent of dictionary order. Stores must not ignore unknown partitions. Physical key +encoding belongs to each store implementation. Provider-specific metadata such as Foundry tags is not part of the key or the Abstractions contract. `HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs index 1d2e1d6bac2..a3dc986a671 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs @@ -4,8 +4,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography; -using System.Text; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -28,8 +26,6 @@ namespace Microsoft.Agents.AI; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AgentSessionStoreKey : IEquatable { - private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); - private readonly int _hashCode; /// @@ -43,7 +39,7 @@ public AgentSessionStoreKey( string sessionId, IReadOnlyDictionary? partitions = null) { - this.SessionId = ValidateIdentityComponent(sessionId, nameof(sessionId)); + this.SessionId = Throw.IfNullOrWhitespace(sessionId); var partitionCopy = new SortedDictionary(StringComparer.Ordinal); if (partitions is not null) @@ -51,16 +47,14 @@ public AgentSessionStoreKey( foreach (KeyValuePair partition in partitions) { partitionCopy.Add( - ValidateIdentityComponent(partition.Key, nameof(partitions)), - ValidateIdentityComponent(partition.Value, nameof(partitions))); + Throw.IfNullOrWhitespace(partition.Key, nameof(partitions)), + Throw.IfNullOrWhitespace(partition.Value, nameof(partitions))); } } this.Partitions = new ReadOnlyDictionary(partitionCopy); - string canonicalValue = this.BuildCanonicalValue(); - this.StableStorageKey = ComputeStableStorageKey(canonicalValue); - this._hashCode = StringComparer.Ordinal.GetHashCode(canonicalValue); + this._hashCode = this.ComputeHashCode(); } /// @@ -84,8 +78,8 @@ public AgentSessionStoreKey( /// public AgentSessionStoreKey WithPartition(string name, string value) { - name = ValidateIdentityComponent(name, nameof(name)); - value = ValidateIdentityComponent(value, nameof(value)); + name = Throw.IfNullOrWhitespace(name); + value = Throw.IfNullOrWhitespace(value); if (this.Partitions.TryGetValue(name, out string? existingValue) && string.Equals(existingValue, value, StringComparison.Ordinal)) @@ -103,17 +97,6 @@ public AgentSessionStoreKey WithPartition(string name, string value) return new AgentSessionStoreKey(this.SessionId, partitions); } - /// - /// Gets a deterministic, opaque value suitable for addressing this key in persistent storage. - /// - /// - /// A versioned Base64URL-encoded SHA-256 hash of the session identifier and every partition. - /// - /// - /// This value is stable across processes and does not expose the original session or partition values. - /// - public string StableStorageKey { get; } - /// public bool Equals(AgentSessionStoreKey? other) { @@ -147,49 +130,18 @@ public bool Equals(AgentSessionStoreKey? other) /// public override int GetHashCode() => this._hashCode; - private string BuildCanonicalValue() + private int ComputeHashCode() { - StringBuilder builder = new(); - builder.Append("v1|s").Append(this.SessionId.Length).Append(':').Append(this.SessionId); - builder.Append("|p").Append(this.Partitions.Count).Append('|'); - - foreach (KeyValuePair partition in this.Partitions) + unchecked { - builder.Append('n').Append(partition.Key.Length).Append(':').Append(partition.Key); - builder.Append('v').Append(partition.Value.Length).Append(':').Append(partition.Value); - builder.Append('|'); - } - - return builder.ToString(); - } - - private static string ComputeStableStorageKey(string canonicalValue) - { - byte[] input = s_strictUtf8.GetBytes(canonicalValue); -#if NET8_0_OR_GREATER - byte[] hash = SHA256.HashData(input); -#else - byte[] hash; - using (SHA256 sha256 = SHA256.Create()) - { - hash = sha256.ComputeHash(input); - } -#endif - return $"ask1_{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; - } - - private static string ValidateIdentityComponent(string value, string paramName) - { - value = Throw.IfNullOrWhitespace(value, paramName); + int hashCode = StringComparer.Ordinal.GetHashCode(this.SessionId); + foreach (KeyValuePair partition in this.Partitions) + { + hashCode = (hashCode * 31) + StringComparer.Ordinal.GetHashCode(partition.Key); + hashCode = (hashCode * 31) + StringComparer.Ordinal.GetHashCode(partition.Value); + } - try - { - _ = s_strictUtf8.GetByteCount(value); - return value; - } - catch (EncoderFallbackException exception) - { - throw new ArgumentException("Session key values must contain valid UTF-16 text.", paramName, exception); + return hashCode; } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt index c5cae3a8bd4..55178675143 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -9,7 +9,6 @@ [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! -[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt index c5cae3a8bd4..55178675143 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -9,7 +9,6 @@ [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! -[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt index c5cae3a8bd4..55178675143 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -9,7 +9,6 @@ [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! -[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt index c5cae3a8bd4..55178675143 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -9,7 +9,6 @@ [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! -[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index c5cae3a8bd4..55178675143 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -9,7 +9,6 @@ [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! -[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.StableStorageKey.get -> string! [MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool [MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs index 5b0766c2ed3..8262225a68d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs @@ -246,10 +246,12 @@ private string GetSessionPath(AIAgent agent, AgentSessionStoreKey key) // Persistent storage requires the stable name or keyed registration carried by the hosted // wrapper. Hashing it avoids case-insensitive and platform-specific directory collisions. string agentIdentity = FoundryHostingAgent.GetSessionStorageIdentity(agent); - string agentKey = new AgentSessionStoreKey(agentIdentity).StableStorageKey; + string agentKey = FoundryAgentSessionKeyEncoder.BuildAgentStorageKey(agentIdentity); + string sessionKey = FoundryAgentSessionKeyEncoder.BuildStorageKey( + FoundryAgentSessionKeyEncoder.BuildLogicalKey(agentIdentity, key)); string dir = Path.Combine(this.RootDirectory, "a-" + agentKey); - string path = Path.Combine(dir, "k-" + key.StableStorageKey + ".json"); + string path = Path.Combine(dir, "k-" + sessionKey + ".json"); // Defense in depth: regardless of per-segment handling, the fully-resolved path must remain // under the storage root. Reject anything that escapes (CWE-22). diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs new file mode 100644 index 00000000000..7c84f666db9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Encodes agent session identities for Foundry-backed and filesystem storage implementations. +/// +internal static class FoundryAgentSessionKeyEncoder +{ + private static readonly UTF8Encoding s_strictUtf8 = + new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + internal static string BuildLogicalKey(string agentIdentity, AgentSessionStoreKey key) + { + _ = Throw.IfNull(key); + + StringBuilder builder = new(); + AppendComponent(builder, 'a', Throw.IfNullOrWhitespace(agentIdentity)); + AppendComponent(builder, 's', key.SessionId); + foreach (KeyValuePair partition in key.Partitions) + { + AppendComponent(builder, 'n', partition.Key); + AppendComponent(builder, 'v', partition.Value); + } + builder.Length--; + return builder.ToString(); + } + + internal static string BuildStorageKey(string logicalKey) + { + byte[] hash; + try + { + hash = SHA256.HashData(s_strictUtf8.GetBytes(logicalKey)); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException( + "Session keys and agent identities must contain valid UTF-16 text.", + nameof(logicalKey), + exception); + } + + return $"s-{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; + } + + internal static string BuildAgentStorageKey(string agentIdentity) + { + _ = Throw.IfNullOrWhitespace(agentIdentity); + return BuildStorageKey($"a{agentIdentity.Length}:{agentIdentity}"); + } + + private static void AppendComponent(StringBuilder builder, char prefix, string value) + => builder.Append(prefix).Append(value.Length).Append(':').Append(value).Append('|'); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs index 2bf27d9f46c..ad18c128f76 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs @@ -3,8 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -53,8 +51,6 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class FoundryAgentSessionStore : AgentSessionStore { - private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); - /// /// The default state-store name used to hold every agent session persisted by this store. /// @@ -137,11 +133,11 @@ public override async ValueTask SaveSessionAsync( JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); BinaryData sessionData = ToBinaryData(serialized); - string logicalKey = BuildLogicalKey(agentIdentity, key); + string logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey(agentIdentity, key); FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); await store.SetItemAsync( - BuildItemKey(logicalKey), + FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey), new Dictionary { [SessionField] = sessionData, @@ -159,12 +155,16 @@ await store.SetItemAsync( _ = Throw.IfNull(agent); _ = Throw.IfNull(key); - string logicalKey = BuildLogicalKey(FoundryHostingAgent.GetSessionStorageIdentity(agent), key); + string logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey( + FoundryHostingAgent.GetSessionStorageIdentity(agent), + key); FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); // GetItemAsync already answers null for an item that is not there, which is exactly the // "nothing stored" result this method contracts to return. - StateStoreItem? item = await store.GetItemAsync(BuildItemKey(logicalKey), cancellationToken).ConfigureAwait(false); + StateStoreItem? item = await store.GetItemAsync( + FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey), + cancellationToken).ConfigureAwait(false); if (!FoundryStateStoreJson.TryGetField(item, SessionField, out BinaryData? sessionData)) { return null; @@ -184,57 +184,6 @@ await store.SetItemAsync( private ValueTask GetStoreAsync(CancellationToken cancellationToken) => this._binding.GetAsync(cancellationToken); - /// - /// Builds an unambiguous readable partition key from the hosted agent identity, end user, and - /// conversation. Each component carries its length so delimiters inside values cannot collide. - /// - internal static string BuildLogicalKey(string agentIdentity, AgentSessionStoreKey key) - { - _ = Throw.IfNull(key); - - StringBuilder builder = new(); - AppendComponent(builder, 'a', Throw.IfNullOrWhitespace(agentIdentity)); - AppendComponent(builder, 's', key.SessionId); - foreach (KeyValuePair partition in key.Partitions) - { - AppendComponent(builder, 'n', partition.Key); - AppendComponent(builder, 'v', partition.Value); - } - builder.Length--; - return builder.ToString(); - } - - private static void AppendComponent(StringBuilder builder, char prefix, string? value) - { - builder.Append(prefix).Append(value?.Length ?? -1).Append(':'); - if (value is not null) - { - builder.Append(value); - } - - builder.Append('|'); - } - - /// - /// Reduces a logical key to a fixed-length item key. The platform limits an item key to 128 - /// characters, which an agent identity plus a session id and its partitions can exceed, so the - /// logical key is hashed rather than truncated: truncation would let two different sessions - /// share a key and therefore overwrite each other's session. - /// - internal static string BuildItemKey(string logicalKey) - { - byte[] hash; - try - { - hash = SHA256.HashData(s_strictUtf8.GetBytes(logicalKey)); - } - catch (EncoderFallbackException exception) - { - throw new ArgumentException("Session keys and agent identities must contain valid UTF-16 text.", nameof(logicalKey), exception); - } - return $"s-{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; - } - private static BinaryData ToBinaryData(JsonElement element) => FoundryStateStoreJson.ToBinaryData(element); private static BinaryData ToJsonString(string value) => FoundryStateStoreJson.ToJsonString(value); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs index c1c92858962..5b4df9e3da6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs @@ -31,7 +31,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class InMemoryAgentSessionStore : AgentSessionStore { - private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary<(string AgentIdentity, AgentSessionStoreKey Key), JsonElement> _sessions = new(); /// public override async ValueTask SaveSessionAsync( @@ -44,7 +44,7 @@ public override async ValueTask SaveSessionAsync( ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(session); - string storageKey = GetKey(agent, key); + var storageKey = GetKey(agent, key); this._sessions[storageKey] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -65,6 +65,8 @@ public override async ValueTask SaveSessionAsync( return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false); } - private static string GetKey(AIAgent agent, AgentSessionStoreKey key) - => $"{FoundryHostingAgent.GetSessionStorageIdentity(agent, allowInstanceId: true)}:{key.StableStorageKey}"; + private static (string AgentIdentity, AgentSessionStoreKey Key) GetKey( + AIAgent agent, + AgentSessionStoreKey key) + => (FoundryHostingAgent.GetSessionStorageIdentity(agent, allowInstanceId: true), key); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs index 9556d58e43a..ec1ce4fa9dd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; @@ -170,13 +171,29 @@ private async Task CreateContainerIfNotExistsAsync() private string GetBlobName(AgentSessionStoreKey key) { - string baseName = $"v2/{this._agentKey}/{key.StableStorageKey}.json"; + string baseName = $"v2/{this._agentKey}/{ComputeSessionKey(key)}.json"; return this._blobNamePrefix is null ? baseName : $"{this._blobNamePrefix}/{baseName}"; } + private static string ComputeSessionKey(AgentSessionStoreKey key) + { + StringBuilder builder = new(); + AppendComponent(builder, 's', key.SessionId); + foreach (KeyValuePair partition in key.Partitions) + { + AppendComponent(builder, 'n', partition.Key); + AppendComponent(builder, 'v', partition.Value); + } + + return ComputeKey(builder.ToString()); + } + + private static void AppendComponent(StringBuilder builder, char prefix, string value) + => builder.Append(prefix).Append(value.Length).Append(':').Append(value).Append('|'); + private static async Task WaitWithCancellationAsync(Task task, CancellationToken cancellationToken) { if (task.IsCompleted || !cancellationToken.CanBeCanceled) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs index e8904baf500..bfd014a8445 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs @@ -52,11 +52,10 @@ public void Equality_IgnoresPartitionInsertionOrder() // Act and assert Assert.Equal(first, second); Assert.Equal(first.GetHashCode(), second.GetHashCode()); - Assert.Equal(first.StableStorageKey, second.StableStorageKey); } [Fact] - public void StableStorageKey_DistinguishesPartitionNamesValuesAndMissingPartitions() + public void Equality_DistinguishesPartitionNamesValuesAndMissingPartitions() { // Arrange var unpartitioned = new AgentSessionStoreKey("tenant::session"); @@ -64,8 +63,8 @@ public void StableStorageKey_DistinguishesPartitionNamesValuesAndMissingPartitio var userPartition = new AgentSessionStoreKey("session").WithPartition("user", "tenant"); // Act and assert - Assert.NotEqual(unpartitioned.StableStorageKey, tenantPartition.StableStorageKey); - Assert.NotEqual(tenantPartition.StableStorageKey, userPartition.StableStorageKey); + Assert.NotEqual(unpartitioned, tenantPartition); + Assert.NotEqual(tenantPartition, userPartition); } [Fact] @@ -117,27 +116,4 @@ public void Constructor_BlankPartition_Throws(string name, string value) "session-1", new Dictionary { [name] = value })); } - - [Fact] - public void Constructor_InvalidUtf16SessionId_Throws() - { - // Arrange - string invalid = new((char)0xD800, 1); - - // Act and assert - Assert.Throws(() => new AgentSessionStoreKey(invalid)); - } - - [Fact] - public void Constructor_InvalidUtf16Partition_Throws() - { - // Arrange - string invalid = new((char)0xD800, 1); - - // Act and assert - Assert.Throws( - () => new AgentSessionStoreKey( - "session-1", - new Dictionary { ["tenant"] = invalid })); - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs index 631760d001a..589f6b0fe86 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs @@ -80,7 +80,7 @@ public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsNullAsync() var agent = new TestAgent(); string agentDirectory = AgentDirectory(store, "name:test-agent"); Directory.CreateDirectory(agentDirectory); - File.WriteAllText(Path.Combine(agentDirectory, $"k-{key.StableStorageKey}.json"), string.Empty); + File.WriteAllText(SessionPath(store, "name:test-agent", key), string.Empty); AgentSession? session = await store.GetSessionAsync(agent, key); @@ -97,7 +97,7 @@ public async Task SaveSessionAsync_CreatesRootDirectoryAndStableKeyFileAsync() await store.SaveSessionAsync(new TestAgent("{\"workflow\":\"x\"}"), key, NewSession()); - Assert.True(File.Exists(Path.Combine(AgentDirectory(store, "name:test-agent"), $"k-{key.StableStorageKey}.json"))); + Assert.True(File.Exists(SessionPath(store, "name:test-agent", key))); } [Fact] @@ -126,8 +126,8 @@ public async Task SaveSessionAsync_TwoAgentsSameKey_DoNotCollideAsync() await store.SaveSessionAsync(agentA, key, NewSession()); await store.SaveSessionAsync(agentB, key, NewSession()); - string pathA = Path.Combine(AgentDirectory(store, "name:AgentA"), $"k-{key.StableStorageKey}.json"); - string pathB = Path.Combine(AgentDirectory(store, "name:AgentB"), $"k-{key.StableStorageKey}.json"); + string pathA = SessionPath(store, "name:AgentA", key); + string pathB = SessionPath(store, "name:AgentB", key); Assert.Contains("\"a\"", File.ReadAllText(pathA), StringComparison.Ordinal); Assert.Contains("\"b\"", File.ReadAllText(pathB), StringComparison.Ordinal); } @@ -142,7 +142,7 @@ public async Task SaveSessionAsync_ArbitraryIdentifiersDoNotBecomePathSegmentsAs await store.SaveSessionAsync(new TestAgent(), key, NewSession()); string file = Assert.Single(Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories)); - Assert.Equal($"k-{key.StableStorageKey}.json", Path.GetFileName(file)); + Assert.Equal(Path.GetFileName(SessionPath(store, "name:test-agent", key)), Path.GetFileName(file)); Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, Path.GetFullPath(file), StringComparison.Ordinal); } @@ -189,7 +189,7 @@ public async Task SaveSessionAsync_ConcurrentSavesOnSameKey_DoNotCollideOnTempFi await Task.WhenAll(tasks); - Assert.True(File.Exists(Path.Combine(AgentDirectory(store, "name:test-agent"), $"k-{key.StableStorageKey}.json"))); + Assert.True(File.Exists(SessionPath(store, "name:test-agent", key))); Assert.Empty(Directory.GetFiles(store.RootDirectory, "*.tmp", SearchOption.AllDirectories)); } @@ -303,7 +303,16 @@ public void ResolveDefaultRootDirectory_NotHosted_UsesCurrentDirectory() private static string AgentDirectory(FileSystemAgentSessionStore store, string identity) => Path.Combine( store.RootDirectory, - "a-" + new AgentSessionStoreKey(identity).StableStorageKey); + "a-" + FoundryAgentSessionKeyEncoder.BuildAgentStorageKey(identity)); + + private static string SessionPath( + FileSystemAgentSessionStore store, + string identity, + AgentSessionStoreKey key) + => Path.Combine( + AgentDirectory(store, identity), + "k-" + FoundryAgentSessionKeyEncoder.BuildStorageKey( + FoundryAgentSessionKeyEncoder.BuildLogicalKey(identity, key)) + ".json"); private sealed class TestSession : AgentSession; diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs index 27156390f54..963bce0de07 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs @@ -304,7 +304,7 @@ public void BuildLogicalKey_UsesLengthPrefixedComponents( string expected) { // Act - string key = FoundryAgentSessionStore.BuildLogicalKey(agentIdentity, Key(sessionId)); + string key = FoundryAgentSessionKeyEncoder.BuildLogicalKey(agentIdentity, Key(sessionId)); // Assert Assert.Equal(expected, key); @@ -314,30 +314,30 @@ public void BuildLogicalKey_UsesLengthPrefixedComponents( public void BuildLogicalKey_DelimitersInsideComponents_DoNotCollide() { // Act - string first = FoundryAgentSessionStore.BuildLogicalKey( + string first = FoundryAgentSessionKeyEncoder.BuildLogicalKey( "name:Concierge", Key("x:c-y", "user", "alice")); - string second = FoundryAgentSessionStore.BuildLogicalKey( + string second = FoundryAgentSessionKeyEncoder.BuildLogicalKey( "name:Concierge", Key("y", "user", "alice:c-x")); // Assert Assert.NotEqual(first, second); Assert.NotEqual( - FoundryAgentSessionStore.BuildItemKey(first), - FoundryAgentSessionStore.BuildItemKey(second)); + FoundryAgentSessionKeyEncoder.BuildStorageKey(first), + FoundryAgentSessionKeyEncoder.BuildStorageKey(second)); } [Fact] public void BuildItemKey_StaysWithinThePlatformKeyLimitForAnyInput() { // Arrange - var logicalKey = FoundryAgentSessionStore.BuildLogicalKey( + var logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey( $"name:{new string('a', 200)}", Key(new string('s', 200), "user", new string('u', 200))); // Act - var itemKey = FoundryAgentSessionStore.BuildItemKey(logicalKey); + var itemKey = FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey); // Assert Assert.InRange(itemKey.Length, 1, 128); @@ -347,9 +347,9 @@ public void BuildItemKey_StaysWithinThePlatformKeyLimitForAnyInput() public void BuildItemKey_IsStableAndDistinctPerLogicalKey() { // Arrange / Act - var first = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); - var same = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); - var other = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|s6:conv-1|n4:user|v3:bob"); + var first = FoundryAgentSessionKeyEncoder.BuildStorageKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); + var same = FoundryAgentSessionKeyEncoder.BuildStorageKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); + var other = FoundryAgentSessionKeyEncoder.BuildStorageKey("a14:name:Concierge|s6:conv-1|n4:user|v3:bob"); // Assert Assert.Equal(first, same); From 925b947ca044f5c400831a709628652c43fe1e79 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:19:44 +0100 Subject: [PATCH 6/7] refactor(dotnet): promote delegating session store --- docs/decisions/0039-shared-agent-session-store.md | 3 +++ .../DelegatingAgentSessionStore.cs | 2 +- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 5 +++++ .../PublicAPI/net472/PublicAPI.Unshipped.txt | 5 +++++ .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 5 +++++ .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 5 +++++ .../PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt | 5 +++++ .../DelegatingAgentSessionStoreTests.cs | 10 +++++----- 8 files changed, 34 insertions(+), 6 deletions(-) rename dotnet/src/{Microsoft.Agents.AI.Hosting => Microsoft.Agents.AI}/DelegatingAgentSessionStore.cs (98%) rename dotnet/tests/{Microsoft.Agents.AI.Hosting.UnitTests => Microsoft.Agents.AI.UnitTests}/DelegatingAgentSessionStoreTests.cs (97%) diff --git a/docs/decisions/0039-shared-agent-session-store.md b/docs/decisions/0039-shared-agent-session-store.md index 89122d59ed8..da3605871fe 100644 --- a/docs/decisions/0039-shared-agent-session-store.md +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -51,6 +51,9 @@ Chosen option: **Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Ab The duplicate types in `Microsoft.Agents.AI.Hosting` and `Microsoft.Agents.AI.Foundry.Hosting` are removed. Both packages reference the shared type directly. +`DelegatingAgentSessionStore` lives in the `Microsoft.Agents.AI` package beside `ChatClientAgent`, providing +the common decorator base without requiring a hosting-protocol package. + The conventional Hosting implementations adopt the same behavior. `IsolationKeyScopedAgentSessionStore` adds the value from `AgentIsolationKeyProvider` under the `isolation` partition while preserving existing partitions. Protocol-specific hosting can add named partitions such as `user`, `tenant`, or `chat` before diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs similarity index 98% rename from dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs rename to dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs index 92600d3173c..023415c3250 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs @@ -7,7 +7,7 @@ using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.Hosting; +namespace Microsoft.Agents.AI; /// /// Provides an abstract base class for agent session stores that delegate operations to an inner store diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt index cc15ea45814..515a8cd92e7 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,3 +1,8 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt index cc15ea45814..515a8cd92e7 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,3 +1,8 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt index cc15ea45814..515a8cd92e7 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,3 +1,8 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt index cc15ea45814..515a8cd92e7 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,3 +1,8 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index cc15ea45814..515a8cd92e7 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,3 +1,8 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs similarity index 97% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs index c6d5e460547..b53ffb30b02 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Moq; -namespace Microsoft.Agents.AI.Hosting.UnitTests; +namespace Microsoft.Agents.AI.UnitTests; /// /// Unit tests for the class. @@ -40,7 +40,7 @@ public DelegatingAgentSessionStoreTests() It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(ValueTask.CompletedTask); + .Returns(default(ValueTask)); this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); } @@ -122,7 +122,7 @@ public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() It.Is(key => key.Equals(expectedKey)), It.Is(s => s == expectedSession), It.Is(ct => ct == expectedCancellationToken))) - .Returns(ValueTask.CompletedTask); + .Returns(default(ValueTask)); // Act await this._delegatingStore.SaveSessionAsync( @@ -205,7 +205,7 @@ public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() // Arrange var expectedKey = new AgentSessionStoreKey("test-conversation-id"); var expectedSession = new TestAgentSession(); - var taskCompletionSource = new TaskCompletionSource(); + var taskCompletionSource = new TaskCompletionSource(); var innerStoreMock = new Mock(); innerStoreMock @@ -226,7 +226,7 @@ public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() // Assert Assert.False(resultTask.IsCompleted); - taskCompletionSource.SetResult(); + taskCompletionSource.SetResult(true); Assert.True(resultTask.IsCompleted); await resultTask; } From 9bddbe99d1c109d8819bb9f1621dee22c6976d27 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:26:35 +0100 Subject: [PATCH 7/7] Restore session store service discovery Preserve GetService in the shared contract and delegate service queries through store decorators. Use discovery to avoid duplicate isolation wrappers in Hosting, A2A, and AGUI. Update public APIs, documentation, and regression coverage with scoped RS0026 pragmas. Copilot-Session: 22c59c1d-1805-41b5-b868-c01855e7ceb7 --- .../0039-shared-agent-session-store.md | 8 ++- .../AgentSessionStore.cs | 35 +++++++++++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 2 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 2 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 2 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 2 + .../netstandard2.0/PublicAPI.Unshipped.txt | 2 + .../A2AServerServiceCollectionExtensions.cs | 2 +- .../AGUIEndpointRouteBuilderExtensions.cs | 2 +- .../HostedAgentBuilderExtensions.cs | 2 +- .../DelegatingAgentSessionStore.cs | 17 +++-- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 1 + .../netstandard2.0/PublicAPI.Unshipped.txt | 1 + .../AgentSessionStoreTests.cs | 52 +++++++++++++++ ...AServerServiceCollectionExtensionsTests.cs | 1 + ...AGUIEndpointRouteBuilderExtensionsTests.cs | 1 + .../HostedAgentBuilderToolsExtensionsTests.cs | 63 +++++++++++++++++++ .../DelegatingAgentSessionStoreTests.cs | 60 ++++++++++++++++++ 21 files changed, 248 insertions(+), 10 deletions(-) diff --git a/docs/decisions/0039-shared-agent-session-store.md b/docs/decisions/0039-shared-agent-session-store.md index da3605871fe..9b63a165b14 100644 --- a/docs/decisions/0039-shared-agent-session-store.md +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -46,13 +46,17 @@ Chosen option: **Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Ab - `AgentSessionStoreKey.Partitions` holds zero or more named isolation dimensions. Every partition is part of identity and implementations cannot ignore unknown partitions. - Partition order does not affect identity. Physical encoding remains the responsibility of each store. -- `DeleteSessionAsync` and service inspection are not part of the shared contract. +- `GetService(Type, object?)` and `GetService(object?)` retain service discovery from conventional + Hosting. Stores can expose themselves, underlying implementations, or additional capabilities. +- `DeleteSessionAsync` is not part of the shared contract. The duplicate types in `Microsoft.Agents.AI.Hosting` and `Microsoft.Agents.AI.Foundry.Hosting` are removed. Both packages reference the shared type directly. `DelegatingAgentSessionStore` lives in the `Microsoft.Agents.AI` package beside `ChatClientAgent`, providing -the common decorator base without requiring a hosting-protocol package. +the common decorator base without requiring a hosting-protocol package. Its service queries check the +outer instance first, then forward to the inner store. Hosting registration uses this discovery to +recognize existing isolation even when other decorators surround it, avoiding a second isolation wrapper. The conventional Hosting implementations adopt the same behavior. `IsolationKeyScopedAgentSessionStore` adds the value from `AgentIsolationKeyProvider` under the `isolation` partition while preserving existing diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs index 47b7ed4740a..3a72dde499b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -76,4 +77,38 @@ public virtual async ValueTask GetOrCreateSessionAsync( return await this.GetSessionAsync(agent, key, cancellationToken).ConfigureAwait(false) ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } + + /// Asks the store for an object of the specified type. + /// The type of object being requested. + /// An optional key that identifies the requested service. + /// The requested object, or if it is not available. + /// is . + /// + /// Stores can expose themselves, underlying stores, or additional services through this method. + /// The default implementation returns this instance when no key is supplied and it is assignable to + /// . Otherwise, it returns . + /// +#pragma warning disable RS0026 // Preserves the existing Hosting service discovery contract and matches AIAgent. + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return serviceKey is null && serviceType.IsInstanceOfType(this) + ? this + : null; + } +#pragma warning restore RS0026 + + /// Asks the store for an object of type . + /// The type of object being requested. + /// An optional key that identifies the requested service. + /// The requested object, or the default value of if it is not available. + /// + /// This method calls so that services exposed by derived stores + /// are available through both overloads. + /// +#pragma warning disable RS0026 // Preserves the existing Hosting service discovery contract and matches AIAgent. + public TService? GetService(object? serviceKey = null) + => this.GetService(typeof(TService), serviceKey) is TService service ? service : default; +#pragma warning restore RS0026 } 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 55178675143..f57c62f59ab 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,6 +1,8 @@ #nullable enable [MAAI001]Microsoft.Agents.AI.AgentSessionStore [MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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 55178675143..f57c62f59ab 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,6 +1,8 @@ #nullable enable [MAAI001]Microsoft.Agents.AI.AgentSessionStore [MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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 55178675143..f57c62f59ab 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,6 +1,8 @@ #nullable enable [MAAI001]Microsoft.Agents.AI.AgentSessionStore [MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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 55178675143..f57c62f59ab 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,6 +1,8 @@ #nullable enable [MAAI001]Microsoft.Agents.AI.AgentSessionStore [MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask 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 55178675143..f57c62f59ab 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,6 +1,8 @@ #nullable enable [MAAI001]Microsoft.Agents.AI.AgentSessionStore [MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index 8a40a29a410..ac43d9b37da 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -187,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 is not IsolationKeyScopedAgentSessionStore) + if (agentSessionStore?.GetService() is null) { agentSessionStore ??= new NoopAgentSessionStore(); agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); 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 c768ea98748..ca900935daf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -111,7 +111,7 @@ public static IEndpointConventionBuilder MapAGUIServer( // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. var isolationKeyProvider = endpoints.ServiceProvider.GetService(); - if (agentSessionStore is not IsolationKeyScopedAgentSessionStore) + if (agentSessionStore?.GetService() is null) { agentSessionStore ??= new NoopAgentSessionStore(); agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index f190d0a65b3..dba3e2bfe7f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -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 is not IsolationKeyScopedAgentSessionStore) + if (withIsolation && store.GetService() is null) { var isolationKeyProvider = sp.GetService(); diff --git a/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs index 023415c3250..6af4bb84773 100644 --- a/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs @@ -20,9 +20,9 @@ namespace Microsoft.Agents.AI; /// underlying store. /// /// -/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner store. -/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the store -/// interface. +/// The default implementation forwards lookup and save operations to the inner store. The inherited +/// lookup-or-create method calls the outer store's lookup override before creating a session when needed. +/// Service queries check this instance before querying the inner store. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] @@ -35,8 +35,7 @@ public abstract class DelegatingAgentSessionStore : AgentSessionStore /// The underlying session store instance that will handle the core operations. /// is . /// - /// The inner session store serves as the foundation of the delegation chain. All operations not overridden by - /// derived classes will be forwarded to this store. + /// Lookup and save operations are forwarded to this store unless overridden by a derived class. /// protected DelegatingAgentSessionStore(AgentSessionStore innerStore) { @@ -55,6 +54,14 @@ protected DelegatingAgentSessionStore(AgentSessionStore innerStore) /// protected AgentSessionStore InnerStore { get; } + /// + /// + /// Returns this instance for a compatible unkeyed request. Otherwise, forwards the request to + /// , allowing services to be discovered through multiple decorators. + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + => base.GetService(serviceType, serviceKey) ?? this.InnerStore.GetService(serviceType, serviceKey); + /// public override ValueTask GetSessionAsync( AIAgent agent, diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 515a8cd92e7..52af91f0782 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt index 515a8cd92e7..52af91f0782 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 515a8cd92e7..52af91f0782 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt index 515a8cd92e7..52af91f0782 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 515a8cd92e7..52af91f0782 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void [MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs index 6c046dadad6..492e6e2fb54 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs @@ -13,6 +13,58 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests; /// public sealed class AgentSessionStoreTests { + [Fact] + public void GetService_CompatibleUnkeyedType_ReturnsStore() + { + // Arrange + var store = new TestAgentSessionStore(session: null); + + // Act and assert + Assert.Same(store, store.GetService(typeof(TestAgentSessionStore))); + Assert.Same(store, store.GetService()); + Assert.Same(store, store.GetService()); + } + + [Fact] + public void GetService_UnsupportedRequest_ReturnsDefault() + { + // Arrange + var store = new TestAgentSessionStore(session: null); + + // Act and assert + Assert.Null(store.GetService(typeof(IDisposable))); + Assert.Null(store.GetService()); + Assert.Null(store.GetService("key")); + Assert.Equal(0, store.GetService()); + } + + [Fact] + public void GetService_NullType_Throws() + { + // Arrange + var store = new TestAgentSessionStore(session: null); + + // Act and assert + Assert.Throws("serviceType", () => store.GetService(null!)); + } + + [Fact] + public void GetService_GenericOverload_UsesVirtualMethod() + { + // Arrange + var service = new object(); + var key = new object(); + var store = new Mock(); + store.Setup(s => s.GetService(typeof(object), key)).Returns(service); + + // Act + var result = store.Object.GetService(key); + + // Assert + Assert.Same(service, result); + store.Verify(s => s.GetService(typeof(object), key), Times.Once); + } + [Fact] public async Task GetOrCreateSessionAsync_StoredSession_ReturnsStoredSessionAsync() { 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 9145d82cd5d..cfb44aa4b4a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -206,6 +206,7 @@ public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyA await using var provider = services.BuildServiceProvider(); var server = provider.GetKeyedService(AgentName); Assert.NotNull(server); + mockSessionStore.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Once); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index c8a43b41ae8..5c6367ef652 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -147,6 +147,7 @@ public void MapAGUIServer_WithAgent_ResolvesSessionStoreFromDI() Assert.NotNull(result); serviceProviderMock.As() .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once); + sessionStoreMock.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Once); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs index eb482964b0b..40a3434cb09 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs @@ -424,6 +424,69 @@ public void WithSessionStoreFactory_ExplicitLifetimeOverridesDefault() Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WithSessionStore_DecoratedIsolation_PreservesConfiguredStoreAsync(bool useFactory) + { + // Arrange + var services = new ServiceCollection(); + var agent = new Mock().Object; + var builder = services.AddAIAgent("test-agent", (sp, name) => agent); + var provider = new Mock(); + provider.Setup(p => p.GetIsolationKeyAsync(It.IsAny())).ReturnsAsync("tenant-1"); + var innerStore = new Mock(); + var session = new TestAgentSession(); + innerStore.Setup(s => s.GetSessionAsync(agent, It.IsAny(), It.IsAny())) + .ReturnsAsync(session); + var isolation = new IsolationKeyScopedAgentSessionStore(innerStore.Object, provider.Object); + var decoratedStore = new TestDelegatingAgentSessionStore(new TestDelegatingAgentSessionStore(isolation)); + if (useFactory) + { + builder.WithSessionStore((sp, name) => decoratedStore); + } + else + { + builder.WithSessionStore(decoratedStore); + } + + using var servicesProvider = services.BuildServiceProvider(); + + // Act + var resolvedStore = servicesProvider.GetRequiredKeyedService("test-agent"); + var key = new AgentSessionStoreKey("session-1").WithPartition("region", "west"); + var result = await resolvedStore.GetSessionAsync(agent, key); + + // Assert + Assert.Same(decoratedStore, resolvedStore); + Assert.Same(isolation, resolvedStore.GetService()); + Assert.Same(session, result); + innerStore.Verify(s => s.GetSessionAsync( + agent, key.WithPartition("isolation", "tenant-1"), It.IsAny()), Times.Once); + } + + [Fact] + public void WithSessionStore_WithoutExistingIsolation_AddsIsolation() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("test-agent", (sp, name) => new Mock().Object); + var store = new InMemoryAgentSessionStore(); + builder.WithSessionStore(store); + using var provider = services.BuildServiceProvider(); + + // Act + var resolvedStore = provider.GetRequiredKeyedService("test-agent"); + + // Assert + Assert.IsType(resolvedStore); + Assert.Same(store, resolvedStore.GetService()); + } + + private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore); + + private sealed class TestAgentSession : AgentSession; + /// /// Dummy AITool implementation for testing. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs index b53ffb30b02..7f75fae4e74 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -72,6 +72,66 @@ public void Constructor_WithValidInnerStore_SetsInnerStore() #region Method Delegation Tests + [Fact] + public void GetService_CompatibleUnkeyedType_ReturnsOutermostStore() + { + // Arrange + var outerStore = new TestDelegatingAgentSessionStore(this._delegatingStore); + + // Act and assert + Assert.Same(outerStore, outerStore.GetService()); + Assert.Same(outerStore, outerStore.GetService()); + this._innerStoreMock.Verify(s => s.GetService(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(null)] + [InlineData("service-key")] + public void GetService_InnerService_ForwardsThroughMultipleDecorators(string? serviceKey) + { + // Arrange + var service = new Mock().Object; + this._innerStoreMock.Setup(s => s.GetService(typeof(IDisposable), serviceKey)).Returns(service); + var outerStore = new TestDelegatingAgentSessionStore(this._delegatingStore); + + // Act + var result = outerStore.GetService(serviceKey); + + // Assert + Assert.Same(service, result); + this._innerStoreMock.Verify(s => s.GetService(typeof(IDisposable), serviceKey), Times.Once); + } + + [Fact] + public void GetService_KeyedStoreRequest_ForwardsToInnerStore() + { + // Arrange + this._innerStoreMock.Setup(s => s.GetService(typeof(AgentSessionStore), "store-key")) + .Returns(this._innerStoreMock.Object); + + // Act + var result = this._delegatingStore.GetService("store-key"); + + // Assert + Assert.Same(this._innerStoreMock.Object, result); + } + + [Fact] + public void GetService_UnknownService_ReturnsNull() + { + // Act and assert + Assert.Null(this._delegatingStore.GetService()); + this._innerStoreMock.Verify(s => s.GetService(typeof(IDisposable), null), Times.Once); + } + + [Fact] + public void GetService_NullType_ThrowsWithoutQueryingInnerStore() + { + // Act and assert + Assert.Throws("serviceType", () => this._delegatingStore.GetService(null!)); + this._innerStoreMock.Verify(s => s.GetService(It.IsAny(), It.IsAny()), Times.Never); + } + /// /// Verify that GetSessionAsync delegates to inner store with correct parameters. ///