mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a987a5b6 | ||
|
|
c2167c34e3 | ||
|
|
08bad1910e | ||
|
|
3e9e6ddbe6 | ||
|
|
02b8c928db | ||
|
|
b8b07b10cb | ||
|
|
863c65c215 | ||
|
|
45d5805e60 | ||
|
|
d2a3c4bddc | ||
|
|
46c6b19ba0 | ||
|
|
5353225551 | ||
|
|
d9c973db3c | ||
|
|
9d55d40493 | ||
|
|
dd359c5c46 | ||
|
|
22b7930fae | ||
|
|
cffa6ecbda |
@@ -0,0 +1,499 @@
|
||||
# PR #5696 — Gap Analysis vs. the MSRC Session-Scoping Reports
|
||||
|
||||
This note evaluates how completely the changes in
|
||||
[PR #5696 — ".NET: Support ClaimsIdentity-based scoping of agent sessions"](https://github.com/microsoft/agent-framework/pull/5696)
|
||||
address two related MSRC reports against the .NET hosting layer:
|
||||
|
||||
- **Part A** — the *A2A persistence-default* report (the originally-filed
|
||||
variant): an A2A server, configured per the in-tree quickstart, persists
|
||||
sessions keyed only by the wire-supplied `contextId`, with no
|
||||
principal/owner dimension on the `AgentSessionStore` contract.
|
||||
- **Part B** — the *AG-UI `ThreadId` session-hijack* report: the AG-UI
|
||||
hosting endpoint trusts the client-supplied `RunAgentInput.ThreadId` as
|
||||
the sole session-lookup key.
|
||||
|
||||
Both reports are rated **Low / Defense-in-Depth** by MSRC because the
|
||||
default registered store is a no-op (`NoopAgentSessionStore`); the
|
||||
cross-user takeover only materializes when an integrator (a) registers a
|
||||
persistent store and (b) does not scope the conversation-id namespace by
|
||||
principal.
|
||||
|
||||
> **Design constraint set by the team (applies to both parts):**
|
||||
> flipping defaults so that the integrator is *required* to register a
|
||||
> `SessionIsolationKeyProvider` before persistence works is **explicitly
|
||||
> out of scope**. First-run / single-user / prototyping scenarios — where
|
||||
> there is no `ClaimsPrincipal`, no auth scheme, and no isolation
|
||||
> provider — must continue to work "out of the box". Any auto-isolation
|
||||
> mechanism must therefore degrade to a transparent no-op when no
|
||||
> principal context is configured.
|
||||
|
||||
---
|
||||
|
||||
## Part A — A2A persistence-default report
|
||||
|
||||
> Reconstructed from the prior session's notes; the verbatim text was not
|
||||
> committed to the repo. Findings re-verified against PR #5696 head.
|
||||
|
||||
### The reported issue
|
||||
|
||||
The A2A hosting layer's quickstart pattern —
|
||||
|
||||
```csharp
|
||||
services.AddAIAgent(...)
|
||||
.WithSessionStore(new InMemoryAgentSessionStore())
|
||||
.AsA2AServer();
|
||||
```
|
||||
|
||||
— produces a server in which any caller who knows (or guesses) another
|
||||
caller's `contextId` can resume that other caller's persisted thread,
|
||||
because:
|
||||
|
||||
1. `AgentSessionStore.GetSessionAsync(AIAgent agent, string conversationId, CancellationToken)`
|
||||
is the only lookup primitive; the contract carries no principal
|
||||
dimension.
|
||||
2. `A2AAgentHandler` resolves the keyed `AgentSessionStore` from DI and
|
||||
passes it directly to `AIHostAgent`, with the wire `contextId` flowing
|
||||
through unchanged.
|
||||
3. The persistent stores in-tree (`InMemoryAgentSessionStore`,
|
||||
sample-grade SQLite/Redis demos) treat `(agent, conversationId)` as a
|
||||
complete primary key.
|
||||
|
||||
Because the documented A2A quickstart leads developers straight at this
|
||||
shape, any multi-user A2A deployment that copies the quickstart inherits
|
||||
the cross-tenant takeover.
|
||||
|
||||
### How PR #5696 changes the A2A path
|
||||
|
||||
The PR introduces an isolation-key abstraction and **auto-wraps on the
|
||||
A2A code path** when an integrator opts in:
|
||||
|
||||
- `SessionIsolationKeyProvider` returns an opaque key from ambient
|
||||
context (the shipped impl reads `HttpContext.User`).
|
||||
- `IsolationKeyScopedAgentSessionStore` is a `DelegatingAgentSessionStore`
|
||||
that prefixes the inbound `conversationId` with `{escapedKey}::` before
|
||||
forwarding to the inner store. It carries a `Strict` knob:
|
||||
- `Strict = false` — when the provider returns no key (e.g. anonymous
|
||||
request, no auth scheme configured), the call falls through with the
|
||||
bare `conversationId`. This is the "out-of-box" mode.
|
||||
- `Strict = true` — a missing key throws / refuses the operation.
|
||||
- `A2AServerServiceCollectionExtensions.CreateA2AServer` resolves the
|
||||
keyed store, checks the `GetService<IsolationKeyScopedAgentSessionStore>()`
|
||||
service-locator chain, and wraps if needed with
|
||||
`Strict = isolationKeyProvider != null`.
|
||||
|
||||
Net effect on A2A:
|
||||
|
||||
| Scenario | Before PR | After PR |
|
||||
|---|---|---|
|
||||
| No persistent store registered | Ephemeral (`NoopAgentSessionStore`) | Unchanged — still ephemeral |
|
||||
| Persistent store, **no** `UseClaimsBasedSessionIsolation` | Single-namespace persistence (vulnerable in multi-user) | **Same single-namespace persistence** — wrapper installed but in `Strict = false` no-op pass-through, so the out-of-box first-run scenario is preserved |
|
||||
| Persistent store **plus** `services.UseClaimsBasedSessionIsolation(...)` | Still vulnerable (no wiring existed) | Auto-scoped per principal claim; missing claim → strict refusal |
|
||||
|
||||
### Item-by-item map of the A2A report's recommended fixes
|
||||
|
||||
| Recommended fix | Status |
|
||||
|---|---|
|
||||
| Add a principal/owner dimension to the persistence contract so multi-user safety is *expressible* without per-store rewrites | âś… Achieved via decorator (`IsolationKeyScopedAgentSessionStore`) rather than a 3-arg `AgentSessionStore` virtual |
|
||||
| Make the safe path **engage automatically** on the canonical A2A registration shape when an isolation provider is configured | âś… `CreateA2AServer` auto-wraps the resolved keyed store |
|
||||
| Preserve out-of-box single-user behaviour (no auth → still works) | ✅ `Strict = false` when no provider is registered; no behavioural change to the no-provider scenario |
|
||||
| Document the trust model on `AgentSessionStore` / `InMemoryAgentSessionStore` / the A2A quickstart so integrators understand what `(agent, conversationId)` *isn't* | ❌ Not addressed; XML on the legacy surfaces is unchanged |
|
||||
| Update the A2A end-to-end sample to model the safe pattern with a working auth scheme | ❌ Sample unchanged |
|
||||
|
||||
### Residual A2A gaps
|
||||
|
||||
- **A-G1.** Trust-model XML still missing on `AgentSessionStore`,
|
||||
`InMemoryAgentSessionStore`, and the A2A registration entry points.
|
||||
An integrator reading IntelliSense on the canonical pattern is not
|
||||
prompted toward `UseClaimsBasedSessionIsolation`.
|
||||
- **A-G2.** No A2A sample exercises the safe configuration end-to-end.
|
||||
- **A-G3.** Same `conversationId`-mutation contract caveats as Part B
|
||||
(B-G3 below): inner stores see a rewritten id; logging/telemetry sinks
|
||||
will leak the isolation key; stores with id-shape constraints must
|
||||
accept escaped keys of arbitrary length.
|
||||
|
||||
The headline A2A finding (the *handler-level* auto-wrap when a provider
|
||||
is registered) **is** addressed by this PR, within the agreed
|
||||
constraint. What remains for A2A is documentation and samples.
|
||||
|
||||
---
|
||||
|
||||
## Part B — AG-UI `ThreadId` session-hijack report
|
||||
|
||||
> The Microsoft Agent Framework AG-UI hosting endpoint (.NET) trusts the
|
||||
> client-supplied `RunAgentInput.ThreadId` as the sole key for persisted
|
||||
> session lookup. […] The `AgentSessionStore` abstract contract carries
|
||||
> only `(AIAgent agent, string conversationId)` with no principal/owner
|
||||
> parameter, making caller-scoped sessions architecturally impossible
|
||||
> without a custom implementation.
|
||||
|
||||
The MSRC severity rationale is *Low / Defense in Depth* — the default
|
||||
AG-UI store is `NoopAgentSessionStore`, so the cross-user takeover only
|
||||
materializes when the integrator (a) registers a persistent store and
|
||||
(b) does not scope the thread-ID namespace by principal. The framework's
|
||||
job here is to make the safe path obvious and to document the trust
|
||||
assumption.
|
||||
|
||||
### What the PR delivers (AG-UI-relevant pieces only)
|
||||
|
||||
- `SessionIsolationKeyProvider` abstraction
|
||||
(`dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs`).
|
||||
- `IsolationKeyScopedAgentSessionStore` decorator that prefixes
|
||||
`conversationId` with `{escapedKey}::` before forwarding to the inner
|
||||
store, with a `Strict` knob.
|
||||
- `DelegatingAgentSessionStore` base + `AgentSessionStore.GetService(...)`
|
||||
service-locator hooks so wrappers can be discovered in a chain.
|
||||
- `Microsoft.Agents.AI.Hosting.AspNetCore` package shipping
|
||||
`ClaimsIdentitySessionIsolationKeyProvider` and
|
||||
`services.UseClaimsBasedSessionIsolation(...)`.
|
||||
- Auto-wrap on the **`HostedAgentBuilderExtensions.WithSessionStore(...)`**
|
||||
path: stores registered through `WithSessionStore(...)` /
|
||||
`WithInMemorySessionStore(...)` are wrapped in
|
||||
`IsolationKeyScopedAgentSessionStore` by default
|
||||
(`withIsolation: true`). When no provider is registered, the wrapper
|
||||
is constructed with `Strict = false` so it passes the bare
|
||||
`conversationId` through — i.e. the "out-of-box first-run" constraint
|
||||
above is honoured.
|
||||
- AG-UI sample
|
||||
(`dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs`)
|
||||
gets a single commented reminder pointing at
|
||||
`UseClaimsBasedSessionIsolation`.
|
||||
|
||||
### Item-by-item map of MSRC's recommended fixes to the PR
|
||||
|
||||
#### MSRC Fix #1 — Strengthen XML doc on `MapAGUI`, `InMemoryAgentSessionStore`, `AgentSessionStore` to state the trust model when a persistent store is in play
|
||||
|
||||
**Status: ❌ Not addressed on the named surfaces.**
|
||||
|
||||
- `AGUIEndpointRouteBuilderExtensions.MapAGUI(...)` doc
|
||||
(`dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs:70-76`)
|
||||
still reads:
|
||||
> If an `AgentSessionStore` is registered in dependency injection
|
||||
> keyed by the agent's name, it will be used to persist conversation
|
||||
> sessions across requests using the AG-UI thread ID as the
|
||||
> conversation identifier. If no session store is registered, sessions
|
||||
> are ephemeral (not persisted).
|
||||
No mention of the trust assumption (thread-id = chain-resume token, not
|
||||
an authorization token; multi-user hosts must scope by principal).
|
||||
- `InMemoryAgentSessionStore` XML
|
||||
(`dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs:13-27`)
|
||||
warns about restart loss but says nothing about the multi-user
|
||||
takeover risk.
|
||||
- `AgentSessionStore` base class XML
|
||||
(`dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs`) only
|
||||
describes the persistence contract; it does not call out that
|
||||
`(agent, conversationId)` carries no principal dimension and that
|
||||
custom implementations must compose one for multi-user hosts.
|
||||
|
||||
The new types (`IsolationKeyScopedAgentSessionStore`,
|
||||
`SessionIsolationKeyProvider`, `ClaimsIdentitySessionIsolationKeyProvider`,
|
||||
`UseClaimsBasedSessionIsolation`) are themselves well documented, but
|
||||
nothing on the legacy IntelliSense path prompts the integrator to look
|
||||
for them.
|
||||
|
||||
#### MSRC Fix #2 — Compose principal into the conversation key inside the AG-UI handler when a `ClaimsPrincipal` is available, gated by a versioned opt-in (e.g. `MapAGUIOptions { ScopeSessionsByPrincipal = true }`)
|
||||
|
||||
**Status: ⚠️ Partially addressed; opt-in path exists but does not cover
|
||||
the canonical AG-UI registration shape.**
|
||||
|
||||
The PR does *not* edit the AG-UI handler to read `HttpContext.User`. It
|
||||
takes a different architectural shape: the scoping is done by a
|
||||
decorator inserted around the store. That decorator must actually be
|
||||
present in the chain that `MapAGUI` resolves, which means one of these
|
||||
must be true:
|
||||
|
||||
1. The integrator registered the store via the
|
||||
`IHostedAgentBuilder.WithSessionStore(...)` / `WithInMemorySessionStore(...)`
|
||||
helper. Those auto-wrap with `IsolationKeyScopedAgentSessionStore`
|
||||
(the new `withIsolation: true` default).
|
||||
2. The integrator manually composes
|
||||
`new IsolationKeyScopedAgentSessionStore(innerStore, provider)` and
|
||||
registers that as the keyed store.
|
||||
|
||||
But the **AG-UI handler resolves the store directly from DI**:
|
||||
|
||||
```csharp
|
||||
// AGUIEndpointRouteBuilderExtensions.cs:85-86
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
|
||||
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
|
||||
```
|
||||
|
||||
So an integrator who follows the most natural "I want persistence" line —
|
||||
|
||||
```csharp
|
||||
services.AddKeyedSingleton<AgentSessionStore>(agentName, new InMemoryAgentSessionStore());
|
||||
// + services.UseClaimsBasedSessionIsolation(...);
|
||||
```
|
||||
|
||||
— still gets **no scoping on AG-UI**, even with an isolation provider
|
||||
registered, because the keyed store goes straight into `AIHostAgent`
|
||||
without ever passing through `WithSessionStore`'s wrapping logic. That
|
||||
is precisely the precondition pattern the MSRC report calls out. (See
|
||||
gap **B-G1** below.)
|
||||
|
||||
There is also no `MapAGUIOptions` / no MapAGUI-level opt-in flag of the
|
||||
shape MSRC suggested. Discoverability of the safe path lives entirely on
|
||||
a separate `services.UseClaimsBasedSessionIsolation(...)` extension and
|
||||
on whether the integrator happened to use the right store-registration
|
||||
helper.
|
||||
|
||||
#### MSRC Fix #3 (optional) — Add a principal-aware overload to `AgentSessionStore` so custom stores can scope by principal without injecting `IHttpContextAccessor`
|
||||
|
||||
**Status: âś… Achieved via decorator instead of a virtual overload.**
|
||||
|
||||
The PR does not add a 3-arg `GetSessionAsync(agent, conversationId, scopeId, ct)`
|
||||
virtual on `AgentSessionStore`. Instead, scope composition is done
|
||||
upstream by `IsolationKeyScopedAgentSessionStore`, and the inner store
|
||||
sees a mutated `conversationId` of the form
|
||||
`{escapedKey}::{conversationId}`. This achieves the *outcome* MSRC asked
|
||||
for (custom stores no longer need `IHttpContextAccessor`) but with two
|
||||
documented-by-implication trade-offs noted in **B-G3** below.
|
||||
|
||||
### Remaining gaps for the AG-UI threat scenario
|
||||
|
||||
#### B-G1. `MapAGUI` does not auto-wrap the keyed store; safe path requires a non-obvious registration shape
|
||||
|
||||
This is the headline gap relative to the MSRC AG-UI report. The
|
||||
isolation wrapper is auto-installed only on the
|
||||
`HostedAgentBuilder.WithSessionStore(...)` path and on the
|
||||
A2A `CreateA2AServer` code path. `MapAGUI` reads the keyed
|
||||
`AgentSessionStore` from DI directly
|
||||
(`AGUIEndpointRouteBuilderExtensions.cs:85`) and never inspects whether
|
||||
an `IsolationKeyScopedAgentSessionStore` is in the chain.
|
||||
|
||||
Concrete consequence: the canonical "I want persistent AG-UI sessions"
|
||||
snippet —
|
||||
|
||||
```csharp
|
||||
services.AddKeyedSingleton<AgentSessionStore>("myAgent", new InMemoryAgentSessionStore());
|
||||
services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
endpoints.MapAGUI("myAgent", "/ag-ui");
|
||||
```
|
||||
|
||||
does *not* scope by principal. The integrator must additionally know to
|
||||
either (a) register their store via the `WithSessionStore` builder
|
||||
helper, or (b) hand-compose
|
||||
`new IsolationKeyScopedAgentSessionStore(inner, provider)` themselves.
|
||||
|
||||
**Suggested closure**: in `MapAGUI` (mirroring the A2A pattern), if the
|
||||
resolved store does not already expose
|
||||
`IsolationKeyScopedAgentSessionStore` via the new `GetService(...)`
|
||||
chain, wrap it. Use the same `Strict = isolationKeyProvider != null`
|
||||
construction the A2A path uses, so the no-provider case stays a
|
||||
transparent no-op (preserving the out-of-box constraint).
|
||||
|
||||
#### B-G2. Trust-model docs missing on the three legacy AG-UI surfaces (MSRC Fix #1)
|
||||
|
||||
`MapAGUI`, `InMemoryAgentSessionStore`, and `AgentSessionStore` need the
|
||||
explicit "thread id is a chain-resume identifier, not an authorization
|
||||
token; multi-user hosts must scope sessions by principal" wording, with
|
||||
a pointer to `UseClaimsBasedSessionIsolation` /
|
||||
`IsolationKeyScopedAgentSessionStore`. Today nothing on those surfaces
|
||||
flags the risk or points readers at the new safe-path APIs.
|
||||
|
||||
#### B-G3. Decorator approach mutates `conversationId` seen by inner stores, undocumented
|
||||
|
||||
`IsolationKeyScopedAgentSessionStore` rewrites the `conversationId` to
|
||||
`{escapedKey}::{conversationId}` before forwarding. Two things follow
|
||||
that custom-store authors would benefit from being told:
|
||||
|
||||
1. Stores that log, echo, or surface `conversationId` (telemetry,
|
||||
audit, error messages) will leak the isolation key into those sinks.
|
||||
2. Stores with constraints on conversation-id shape (length, allowed
|
||||
characters, URL-safety, hashing) must accept `\\`/`\:`-escaped keys
|
||||
of arbitrary length.
|
||||
|
||||
A short note on `IsolationKeyScopedAgentSessionStore` (and ideally on
|
||||
`AgentSessionStore` itself — "implementations should treat
|
||||
`conversationId` as opaque") would close this.
|
||||
|
||||
#### B-G4. Only one provider implementation ships; `ClaimType` lookup is single-claim only
|
||||
|
||||
The MSRC suggested fix uses
|
||||
`ClaimTypes.NameIdentifier ?? Identity.Name` as the fallback chain.
|
||||
`ClaimsIdentitySessionIsolationKeyProvider` only resolves a single
|
||||
configured `ClaimType` and does not fall back to `Identity.Name`. That
|
||||
will silently produce a `null` key (and, in the non-strict default
|
||||
configuration on the A2A auto-wrap path, a silent no-op) for valid
|
||||
authenticated identities that don't carry the configured claim. A
|
||||
second-chance fallback list, or shipping a small set of common
|
||||
providers (e.g. `Identity.Name`, an HTTP-header provider, an mTLS
|
||||
subject provider), would close the discoverability gap on the safe
|
||||
path.
|
||||
|
||||
#### B-G5. AG-UI sample is a commented reminder, not a working safe configuration
|
||||
|
||||
`samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs` adds a
|
||||
single commented-out line. The sample's runtime behaviour is unchanged;
|
||||
copy-paste users still land on the unscoped path. At least one AG-UI
|
||||
sample should compile and run with `UseClaimsBasedSessionIsolation`
|
||||
plus a working auth scheme so the safe pattern is shown end-to-end. The
|
||||
sample also needs to demonstrate registering the store through
|
||||
`WithSessionStore(...)` rather than via raw `AddKeyedSingleton` so that
|
||||
the decorator chain actually engages — unless **B-G1** is fixed first.
|
||||
|
||||
#### B-G6. Asymmetry between A2A and AG-UI auto-wrap behaviour
|
||||
|
||||
`A2AServerServiceCollectionExtensions.CreateA2AServer` was edited to
|
||||
auto-wrap any keyed store with `IsolationKeyScopedAgentSessionStore`
|
||||
when one isn't already in the chain
|
||||
(`A2AServerServiceCollectionExtensions.cs:140-146` after the PR).
|
||||
The equivalent change was *not* made on the AG-UI endpoint
|
||||
extension, so the two hosting layers now have asymmetric behaviour:
|
||||
isolation kicks in automatically on A2A but only on AG-UI when the
|
||||
integrator routes their store through the builder helpers. This
|
||||
re-creates, in a different shape, the "asymmetric defaults are a
|
||||
perpetual source of integrator confusion" anti-pattern that MSRC
|
||||
called out for the persistence default. (Note: this is **not** a
|
||||
"flip the default" ask — it's a "make the two hosting layers behave
|
||||
the same when an isolation provider is registered" ask. The
|
||||
out-of-box no-provider behaviour stays unchanged.)
|
||||
|
||||
### Summary scorecard (AG-UI threat surface)
|
||||
|
||||
| MSRC ask | Status |
|
||||
|---|---|
|
||||
| #1 Trust-model XML on `MapAGUI`, `InMemoryAgentSessionStore`, `AgentSessionStore` | ❌ Not addressed |
|
||||
| #2 Principal-scoping inside the AG-UI handler (versioned opt-in) | ⚠️ Equivalent opt-in mechanism exists (`UseClaimsBasedSessionIsolation` + decorator), but the AG-UI handler does not engage it for stores registered the canonical way |
|
||||
| #3 Optional principal-aware store overload | ✅ Achieved via decorator (with caveats — see B-G3) |
|
||||
| AG-UI sample shows safe pattern | ⚠️ Commented reminder only |
|
||||
| Symmetric behaviour with the A2A hosting layer | ❌ A2A auto-wraps in handler; AG-UI does not |
|
||||
|
||||
---
|
||||
|
||||
## Part C — Synthesis across the two reports
|
||||
|
||||
The A2A and AG-UI reports describe the **same root cause** at the
|
||||
abstraction layer (`AgentSessionStore` only knows
|
||||
`(agent, conversationId)`; the wire-supplied id is the entire lookup
|
||||
key) reached through two different hosting surfaces. Read together they
|
||||
say:
|
||||
|
||||
1. **The contract gap is real, and the PR closes it correctly.** Adding
|
||||
a principal dimension by *decoration* (`IsolationKeyScopedAgentSessionStore`)
|
||||
rather than by adding a third parameter to every `AgentSessionStore`
|
||||
override was the right call: it keeps the contract source-compatible,
|
||||
it stacks naturally with future scoping dimensions (tenant, session
|
||||
tag), and it lets a single isolation provider cover every hosting
|
||||
surface without per-surface plumbing.
|
||||
2. **"Auto-wrap when the integrator opts in" is the right shape under
|
||||
the team's design constraint.** The PR does not change defaults: with
|
||||
no `UseClaimsBasedSessionIsolation` registered, both A2A and the
|
||||
builder path remain bit-for-bit compatible. The wrapper engages only
|
||||
when an isolation provider is in the container, and even then defers
|
||||
to `Strict = false` when the provider returns no key — so the
|
||||
first-run / single-user / no-auth scenario continues to function.
|
||||
3. **Coverage is asymmetric across hosting surfaces.** The PR added the
|
||||
handler-level auto-wrap to the A2A path (`CreateA2AServer`) and to
|
||||
the agent-builder path (`WithSessionStore`), but **not** to the
|
||||
AG-UI handler (`MapAGUI`). The result: an integrator who follows
|
||||
the canonical AG-UI snippet
|
||||
(`AddKeyedSingleton<AgentSessionStore>(name, store)` +
|
||||
`endpoints.MapAGUI(name, "/ag-ui")`) plus
|
||||
`UseClaimsBasedSessionIsolation(...)` still gets an unscoped store.
|
||||
This is the headline residual issue (**B-G1 / B-G6**) and is also
|
||||
the reason the AG-UI report exists as a separate finding even after
|
||||
the A2A path is fixed.
|
||||
4. **Documentation and samples lag the runtime fix on both surfaces.**
|
||||
`AgentSessionStore`, `InMemoryAgentSessionStore`, the A2A quickstart,
|
||||
and the AG-UI quickstart all still describe the persistence contract
|
||||
without flagging the trust assumption that `(agent, conversationId)`
|
||||
carries no principal dimension and that multi-user hosts must compose
|
||||
one. The new types are well-documented, but nothing on the legacy
|
||||
IntelliSense path points readers at them. A second-time reader of
|
||||
either MSRC report could read the entire surface and miss the safe
|
||||
path (**A-G1 / B-G2**).
|
||||
5. **One shared caveat applies to both surfaces.** Because scoping is
|
||||
done by rewriting `conversationId` to `{escapedKey}::{conversationId}`
|
||||
before forwarding, any inner store that logs, echoes, audits, or
|
||||
constrains the shape of `conversationId` is silently affected. This
|
||||
needs to be a documented contract on `IsolationKeyScopedAgentSessionStore`
|
||||
(and ideally a one-line "treat `conversationId` as opaque" note on
|
||||
`AgentSessionStore` itself). It applies identically to A2A
|
||||
(**A-G3**) and AG-UI (**B-G3**).
|
||||
|
||||
### Combined scorecard
|
||||
|
||||
| MSRC ask (collapsed across both reports) | Status |
|
||||
|---|---|
|
||||
| Make principal-scoping *expressible* without rewriting every store | âś… Decorator (`IsolationKeyScopedAgentSessionStore`) |
|
||||
| Auto-engage scoping on the **A2A** canonical registration shape when an isolation provider is present | âś… `CreateA2AServer` wraps |
|
||||
| Auto-engage scoping on the **AG-UI** canonical registration shape when an isolation provider is present | ❌ `MapAGUI` does not wrap |
|
||||
| Preserve out-of-box single-user/no-auth behaviour | âś… `Strict = false` when no provider; `NoopAgentSessionStore` default unchanged |
|
||||
| Trust-model XML on `AgentSessionStore`, `InMemoryAgentSessionStore`, `MapAGUI`, A2A entry points | ❌ Not addressed |
|
||||
| Document the `conversationId`-mutation contract the decorator imposes | ❌ Not addressed |
|
||||
| Ship enough provider variants to be discoverable beyond a single Claims case | ⚠️ Only `ClaimsIdentitySessionIsolationKeyProvider`, single-claim, no `Identity.Name` fallback |
|
||||
| Working safe-path samples for both A2A and AG-UI | ❌ AG-UI sample is a commented reminder; no A2A sample update |
|
||||
|
||||
---
|
||||
|
||||
## Part D — Next steps (respecting "out-of-box default must keep working")
|
||||
|
||||
All of the following preserve the design constraint: **with no
|
||||
isolation provider registered, behaviour must not change** — same
|
||||
default store (`NoopAgentSessionStore`), same wire contract, same
|
||||
prototype-friendly first-run experience. None of them require an
|
||||
integrator to register auth or an isolation provider before persistence
|
||||
works.
|
||||
|
||||
Listed in priority order, with the closure mapping back to the gap IDs
|
||||
above.
|
||||
|
||||
1. **Mirror the A2A auto-wrap inside `MapAGUI`.** In the AG-UI endpoint
|
||||
extension, after resolving the keyed `AgentSessionStore`, walk the
|
||||
`GetService<IsolationKeyScopedAgentSessionStore>()` chain; if it is
|
||||
absent, wrap with `Strict = isolationKeyProvider != null`. The
|
||||
`Strict = false` branch keeps the "no provider registered → bare
|
||||
`conversationId` passes through" behaviour the constraint requires.
|
||||
The `Strict = true` branch engages only when the integrator has
|
||||
already opted in by calling `UseClaimsBasedSessionIsolation(...)`.
|
||||
*Closes B-G1 and B-G6 (asymmetry); produces no out-of-box behaviour
|
||||
change.*
|
||||
|
||||
2. **Add the trust-model XML paragraph to the five legacy surfaces.**
|
||||
`AgentSessionStore`, `InMemoryAgentSessionStore`, `MapAGUI`, the A2A
|
||||
`CreateA2AServer` / `AsA2AServer` entry points. The text needs to
|
||||
say: (a) `conversationId` / `ThreadId` / `contextId` arrives from
|
||||
the wire and is not an authorization token; (b) `(agent, conversationId)`
|
||||
has no principal dimension, so persistent stores are single-namespace
|
||||
by default; (c) multi-user hosts should compose one via
|
||||
`UseClaimsBasedSessionIsolation` or a custom
|
||||
`SessionIsolationKeyProvider`. Pure docs change, no behaviour
|
||||
impact. *Closes A-G1, B-G2.*
|
||||
|
||||
3. **Document the `conversationId`-mutation contract** on
|
||||
`IsolationKeyScopedAgentSessionStore` and add a one-liner on
|
||||
`AgentSessionStore` saying inner stores must treat `conversationId`
|
||||
as opaque (don't parse, don't impose length/charset constraints,
|
||||
expect logs/telemetry to surface it verbatim). *Closes A-G3, B-G3.*
|
||||
|
||||
4. **Convert the AG-UI sample into a working safe configuration** (and
|
||||
add an equivalent slice to one A2A sample). The sample should
|
||||
register an auth scheme, call `UseClaimsBasedSessionIsolation`, and
|
||||
register the store via the canonical endpoint shape — so once
|
||||
step 1 lands, copy-paste users land on the safe path automatically.
|
||||
The unsafe path must still be reachable (constraint), but the
|
||||
in-tree sample shouldn't model it. *Closes A-G2 follow-up, B-G5.*
|
||||
|
||||
5. **Widen the provider story.** Either extend
|
||||
`ClaimsIdentitySessionIsolationKeyProvider` to take an ordered
|
||||
fallback chain (e.g. `ClaimTypes.NameIdentifier` → `Identity.Name`),
|
||||
or ship one or two additional providers (HTTP-header,
|
||||
mTLS-subject) so the safe path is discoverable beyond a single
|
||||
claims-based deployment. Whichever shape is chosen must continue to
|
||||
return `null` for anonymous requests so the `Strict = false`
|
||||
pass-through semantics keep holding. *Closes B-G4.*
|
||||
|
||||
### What is explicitly **not** proposed
|
||||
|
||||
- Flipping the default registered store from `NoopAgentSessionStore` to
|
||||
any persistent default — out of scope per the constraint.
|
||||
- Requiring `UseClaimsBasedSessionIsolation` (or any
|
||||
`SessionIsolationKeyProvider`) before persistence works — out of
|
||||
scope per the constraint.
|
||||
- Adding a third `scopeId` parameter to `AgentSessionStore` virtuals
|
||||
(would force every existing custom store to be recompiled and
|
||||
re-implemented). The decorator approach already covers this case;
|
||||
the only ask is a documented contract on inner stores.
|
||||
- Treating an authenticated request without the configured claim as a
|
||||
hard error in the no-isolation-provider scenario — only applies when
|
||||
the integrator opted into `Strict = true` by registering a provider.
|
||||
@@ -590,6 +590,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
|
||||
@@ -101,6 +101,10 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -49,6 +49,10 @@ var agent = new AzureOpenAIClient(
|
||||
AGUIServerSerializerContext.Default.Options)
|
||||
]);
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// Register the agent with the host and configure it to use an in-memory session store
|
||||
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
|
||||
builder
|
||||
|
||||
@@ -28,6 +28,10 @@ builder.AddDevUI();
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
@@ -148,6 +152,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenApi();
|
||||
|
||||
+1
@@ -27,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -28,6 +28,23 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
|
||||
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
|
||||
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
|
||||
/// dimension, so when a persistent store is registered any caller who knows or
|
||||
/// guesses another caller's <c>contextId</c> can resume that other caller's
|
||||
/// persisted thread. Hosts that serve more than one user must compose a principal
|
||||
/// dimension into the lookup key — typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
|
||||
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
|
||||
/// conversation identifier, which is appropriate for first-run / single-user /
|
||||
/// prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
@@ -46,6 +63,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -65,6 +89,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -83,6 +114,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -114,6 +152,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -140,9 +185,17 @@ public static class A2AServerServiceCollectionExtensions
|
||||
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
|
||||
|
||||
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
|
||||
}
|
||||
|
||||
var hostAgent = new AIHostAgent(
|
||||
innerAgent: agent,
|
||||
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
|
||||
sessionStore: agentSessionStore);
|
||||
|
||||
agentHandler = new A2AAgentHandler(hostAgent, runMode);
|
||||
}
|
||||
|
||||
+20
@@ -73,6 +73,26 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
|
||||
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The AG-UI <c>RunAgentInput.ThreadId</c> arrives
|
||||
/// from the wire and is treated as a chain-resume identifier — <em>not</em> as an
|
||||
/// authorization token. The <see cref="AgentSessionStore"/> contract carries no
|
||||
/// principal/owner dimension, so when a persistent store is registered any caller
|
||||
/// who knows or guesses another caller's <c>ThreadId</c> 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
|
||||
/// keyed <see cref="AgentSessionStore"/> in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
|
||||
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
|
||||
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
|
||||
/// isolation provider is registered, behavior is unchanged — the bare
|
||||
/// <c>ThreadId</c> is used as the conversation identifier, which is appropriate
|
||||
/// for first-run / single-user / prototyping scenarios but unsafe for
|
||||
/// multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapAGUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
|
||||
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
|
||||
/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
|
||||
/// from the ambient <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
|
||||
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// will then enforce strict or pass-through behavior based on its configuration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
|
||||
/// to provide access to the current <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||
private readonly string _claimType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">
|
||||
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
|
||||
/// </exception>
|
||||
public ClaimsIdentitySessionIsolationKeyProvider(
|
||||
IHttpContextAccessor? httpContextAccessor,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
|
||||
this._httpContextAccessor = httpContextAccessor;
|
||||
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the session isolation key from the current user's claims.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// 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 <see langword="null"/> if the claim
|
||||
/// is not present or the HTTP context is unavailable.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
|
||||
/// of the specified type exist, the first match is returned.
|
||||
/// </remarks>
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Claim? claim = this._httpContextAccessor?
|
||||
.HttpContext?
|
||||
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
|
||||
return new ValueTask<string?>(claim?.Value);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the claim type to extract from the user's identity for session isolation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
|
||||
/// the user's name or unique identifier claim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common alternatives include:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
|
||||
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn)</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Hosting ASP.NET Core</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for hosting agents in an ASP.NET Core context.</Description>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring AI hosting services in an <see cref="IServiceCollection"/>.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
|
||||
/// to generate session isolation keys.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
|
||||
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
|
||||
/// <remarks>
|
||||
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
|
||||
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
|
||||
/// </remarks>
|
||||
public static IServiceCollection UseClaimsBasedSessionIsolation(
|
||||
this IServiceCollection services,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new();
|
||||
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Scoped);
|
||||
services.Add(descriptor);
|
||||
|
||||
return services;
|
||||
|
||||
object CreateIsolationKeyProvider(IServiceProvider serviceProvider)
|
||||
{
|
||||
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
|
||||
|
||||
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
@@ -9,9 +11,39 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// Defines the contract for storing and retrieving agent conversation threads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
|
||||
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
|
||||
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
|
||||
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
|
||||
/// token, and the <c>(agent, conversationId)</c> 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 <c>conversationId</c> can resume
|
||||
/// that other caller's persisted thread. The framework provides
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
|
||||
/// <c>conversationId</c> to include an isolation key resolved from a
|
||||
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
|
||||
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c>). 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Implementer guidance.</strong> Implementations should treat
|
||||
/// <c>conversationId</c> 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
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
|
||||
/// Be aware that any logging, telemetry, or audit sink that surfaces
|
||||
/// <c>conversationId</c> will also surface the isolation prefix when a
|
||||
/// scoping decorator is in the chain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class AgentSessionStore
|
||||
{
|
||||
@@ -43,4 +75,35 @@ public abstract class AgentSessionStore
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
/// <param name="serviceType">The type of object being requested.</param>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public virtual object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
_ = Throw.IfNull(serviceType);
|
||||
|
||||
return serviceKey is null && serviceType.IsInstanceOfType(this)
|
||||
? this
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of type <typeparamref name="TService"/>.</summary>
|
||||
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public TService? GetService<TService>(object? serviceKey = null)
|
||||
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for agent session stores that delegate operations to an inner store
|
||||
/// instance while allowing for extensibility and customization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="DelegatingAgentSessionStore"/> implements the decorator pattern for <see cref="AgentSessionStore"/>s,
|
||||
/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an
|
||||
/// underlying store.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class DelegatingAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStore"/> class with the specified inner
|
||||
/// store.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying session store instance that will handle the core operations.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
protected DelegatingAgentSessionStore(AgentSessionStore innerStore)
|
||||
{
|
||||
this.InnerStore = Throw.IfNull(innerStore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inner session store instance that receives delegated operations.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The underlying <see cref="AgentSessionStore"/> instance that handles core storage operations.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Derived classes can use this property to access the inner session store for custom delegation scenarios
|
||||
/// or to forward operations with additional processing.
|
||||
/// </remarks>
|
||||
protected AgentSessionStore InnerStore { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
@@ -16,12 +17,11 @@ public static class HostedAgentBuilderExtensions
|
||||
/// Configures the host agent builder to use an in-memory session store for agent session management.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the in-memory session store.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same <paramref name="builder"/> instance, configured to use an in-memory session store.</returns>
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton<AgentSessionStore>(builder.Name, new InMemoryAgentSessionStore());
|
||||
return builder;
|
||||
}
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true)
|
||||
=> builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation);
|
||||
|
||||
/// <summary>
|
||||
/// Registers the specified agent session store with the host agent builder, enabling session-specific storage for
|
||||
@@ -29,12 +29,11 @@ public static class HostedAgentBuilderExtensions
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the session store. Cannot be null.</param>
|
||||
/// <param name="store">The agent session store instance to register. Cannot be null.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, allowing for method chaining.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton(builder.Name, store);
|
||||
return builder;
|
||||
}
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true)
|
||||
=> builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the host agent builder to use a custom session store implementation for agent sessions.
|
||||
@@ -44,16 +43,36 @@ public static class HostedAgentBuilderExtensions
|
||||
/// name.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
|
||||
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
Throw.IfNullOrEmpty(keyString);
|
||||
return createAgentSessionStore(sp, keyString) ??
|
||||
|
||||
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<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
|
||||
|
||||
// Best efforts options getting
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
|
||||
if (options is null)
|
||||
{
|
||||
var optionsProvider = sp.GetService<IOptions<IsolationKeyScopedAgentSessionStoreOptions>>();
|
||||
options = optionsProvider?.Value;
|
||||
}
|
||||
|
||||
store = new IsolationKeyScopedAgentSessionStore(store, isolationKeyProvider, options ?? new());
|
||||
}
|
||||
|
||||
return store;
|
||||
}, lifetime);
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
|
||||
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
|
||||
/// per logical partition (e.g., user, tenant, or composite key).
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
{
|
||||
private readonly SessionIsolationKeyProvider? _keyProvider;
|
||||
private readonly bool _strict;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
|
||||
/// <param name="keyProvider">
|
||||
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="innerStore"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public IsolationKeyScopedAgentSessionStore(
|
||||
AgentSessionStore innerStore,
|
||||
SessionIsolationKeyProvider? keyProvider,
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = null)
|
||||
: base(innerStore)
|
||||
{
|
||||
this._keyProvider = keyProvider;
|
||||
options ??= new IsolationKeyScopedAgentSessionStoreOptions();
|
||||
this._strict = options.Strict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The isolation key string, or <see langword="null"/> if no key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The provider returned <see langword="null"/> and strict mode is enabled.
|
||||
/// </exception>
|
||||
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = this._keyProvider != null
|
||||
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
if (this._strict && key == null)
|
||||
{
|
||||
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
|
||||
/// </summary>
|
||||
/// <param name="key">The raw isolation key.</param>
|
||||
/// <returns>The escaped isolation key.</returns>
|
||||
/// <remarks>
|
||||
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
|
||||
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
|
||||
/// </remarks>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
|
||||
/// </summary>
|
||||
/// <param name="bareConversationId">The original conversation ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
|
||||
/// if no isolation key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (key == null)
|
||||
{
|
||||
return bareConversationId;
|
||||
}
|
||||
|
||||
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
|
||||
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see langword="false"/>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool Strict { get; set; } = true;
|
||||
}
|
||||
@@ -24,6 +24,20 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
|
||||
/// such as Redis, SQL Server, or Azure Cosmos DB.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Multi-user warning.</strong> This store keys threads by
|
||||
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
|
||||
/// the conversation identifier originates from the wire (for example, an AG-UI
|
||||
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), 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
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
|
||||
/// scoped per principal. See the trust-model remarks on
|
||||
/// <see cref="AgentSessionStore"/> for the full background.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
|
||||
/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
|
||||
/// implement the key resolution logic appropriate to their hosting environment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
|
||||
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
|
||||
/// to unscoped storage based on its configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class SessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the session isolation key for the current request or execution context.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the isolation key string,
|
||||
/// or <see langword="null"/> if no key is available in the current context.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims,
|
||||
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
|
||||
/// the caller to decide on strict vs. pass-through behavior.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderTests
|
||||
{
|
||||
private const string TestUserId = "test-user-id";
|
||||
private const string CustomClaimType = "custom-claim-type";
|
||||
private const string CustomClaimValue = "custom-claim-value";
|
||||
|
||||
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
|
||||
/// </summary>
|
||||
public ClaimsIdentitySessionIsolationKeyProviderTests()
|
||||
{
|
||||
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts null IHttpContextAccessor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotEmpty()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotWhitespace()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionIsolationKeyAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestUserId, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType });
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(CustomClaimValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim("other-claim", "value");
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor returns null HttpContext.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor itself is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string FirstValue = "first-value";
|
||||
const string SecondValue = "second-value";
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
|
||||
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(FirstValue, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupHttpContextWithClaim(string claimType, string claimValue)
|
||||
{
|
||||
var claims = new[] { new Claim(claimType, claimValue) };
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
User = principal
|
||||
};
|
||||
|
||||
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DelegatingAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
public class DelegatingAgentSessionStoreTests
|
||||
{
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly TestDelegatingAgentSessionStore _delegatingStore;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public DelegatingAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
// Setup inner store mock
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () => new TestDelegatingAgentSessionStore(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor sets the inner store correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithValidInnerStore_SetsInnerStore()
|
||||
{
|
||||
// Act
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Method Delegation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act
|
||||
var session = await this._delegatingStore.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, session);
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
var expectedSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<AgentSession>(s => s == expectedSession),
|
||||
It.Is<CancellationToken>(ct => ct == expectedCancellationToken)))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
// Act
|
||||
await this._delegatingStore.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
ExpectedConversationId,
|
||||
expectedSession,
|
||||
expectedCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync awaits the inner store's result before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncAwaitsInnerStoreResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var taskCompletionSource = new TaskCompletionSource<AgentSession>();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<AgentSession>(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult(this._testSession);
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
Assert.Same(this._testSession, await resultTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync awaits the inner store's completion before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedSession = new TestAgentSession();
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask(taskCompletionSource.Task));
|
||||
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object);
|
||||
|
||||
// Act
|
||||
var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession);
|
||||
|
||||
// Assert
|
||||
Assert.False(resultTask.IsCompleted);
|
||||
taskCompletionSource.SetResult();
|
||||
Assert.True(resultTask.IsCompleted);
|
||||
await resultTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting the exact type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForExactType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting a base type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForBaseType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting AgentSessionStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForAgentSessionStoreType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(AgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains to inner store when type is not satisfied by outer store.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through multiple delegation layers.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can find a store in the middle of the delegation chain.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when the requested type is not found in the chain.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when a service key is provided but not matched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenServiceKeyProvided()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceThrowsWhenServiceTypeIsNull() =>
|
||||
Assert.Throws<ArgumentNullException>("serviceType", () => this._delegatingStore.GetService(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsItself()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<TestDelegatingAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method chains to inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method returns null when type not found.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Implementation
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of DelegatingAgentSessionStore for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore)
|
||||
{
|
||||
public new AgentSessionStore InnerStore => base.InnerStore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Another delegating store implementation for testing multi-layer chains.
|
||||
/// </summary>
|
||||
private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore);
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
#endregion
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreTests
|
||||
{
|
||||
private const string TestIsolationKey = "test-key";
|
||||
private const string TestConversationId = "test-conversation-id";
|
||||
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public IsolationKeyScopedAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () =>
|
||||
new IsolationKeyScopedAgentSessionStore(null!, provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null);
|
||||
Assert.NotNull(store);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(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(
|
||||
this._agentMock.Object,
|
||||
TestConversationId,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync returns the session from the inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SaveSessionAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act
|
||||
await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave);
|
||||
|
||||
// Assert
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"{TestIsolationKey}::{TestConversationId}",
|
||||
sessionToSave,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(
|
||||
this._innerStoreMock.Object,
|
||||
provider,
|
||||
new IsolationKeyScopedAgentSessionStoreOptions { Strict = true });
|
||||
var sessionToSave = new TestAgentSession();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escaping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that colons in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithColon = "key:with:colons";
|
||||
var provider = new TestSessionIsolationKeyProvider(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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that backslashes in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBackslashesInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBackslash = @"domain\key";
|
||||
var provider = new TestSessionIsolationKeyProvider(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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that both backslashes and colons in the isolation key are escaped correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string KeyWithBoth = @"domain\key:role";
|
||||
var provider = new TestSessionIsolationKeyProvider(KeyWithBoth);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
await store.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert - backslashes escaped first, then colons
|
||||
this._innerStoreMock.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
this._agentMock.Object,
|
||||
$"domain\\\\key\\:role::{TestConversationId}",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Isolation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that different isolation keys result in different scoped conversation IDs.
|
||||
/// </summary>
|
||||
[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<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIAgent, string, CancellationToken>((_, conversationId, _) =>
|
||||
{
|
||||
if (capturedConversationId1 == null)
|
||||
{
|
||||
capturedConversationId1 = conversationId;
|
||||
}
|
||||
else
|
||||
{
|
||||
capturedConversationId2 = conversationId;
|
||||
}
|
||||
})
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act - Key 1
|
||||
var provider1 = new TestSessionIsolationKeyProvider(Key1);
|
||||
var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1);
|
||||
await store1.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Act - Key 2
|
||||
var provider2 = new TestSessionIsolationKeyProvider(Key2);
|
||||
var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2);
|
||||
await store2.GetSessionAsync(this._agentMock.Object, TestConversationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1);
|
||||
Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2);
|
||||
Assert.NotEqual(capturedConversationId1, capturedConversationId2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetService Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<IsolationKeyScopedAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(store, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through to find inner store types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var concreteInnerStore = new ConcreteAgentSessionStore();
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(concreteInnerStore, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
|
||||
/// </summary>
|
||||
public class SessionIsolationKeyProviderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return a non-null isolation key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedKey = "test-key";
|
||||
var provider = new TestSessionIsolationKeyProvider(ExpectedKey);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ExpectedKey, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return null when no key is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that cancellation token is passed through to the provider implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestCancellableSessionIsolationKeyProvider();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(
|
||||
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
|
||||
}
|
||||
|
||||
#region Test Implementations
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation that respects cancellation tokens.
|
||||
/// </summary>
|
||||
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
return "key";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user