From 0617be7f94b4d9bdfcc886c7ca83411cf74afb2e Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Thu, 13 Aug 2026 11:14:47 +0100
Subject: [PATCH 1/6] .NET: Add Foundry session and user identity pass-through
Let user agents pin hosted agent_session_id on AgentSession and
send x-ms-user-identity per call for Foundry hosted agents.
---
.../FoundryAgent.cs | 100 ++++-
.../FoundryAgentSessionExtensions.cs | 58 +++
.../FoundryChatOptionsExtensions.cs | 118 ++++++
.../FoundryHostedRequestAgent.cs | 152 ++++++++
.../HostedSessionIdCapturePolicy.cs | 54 +++
.../HostedSessionIdCaptureScope.cs | 27 ++
.../UserIdentityPolicy.cs | 43 +++
.../UserIdentityScope.cs | 21 ++
.../FoundryAgentTests.cs | 1 +
.../FoundryHostedRequestTests.cs | 357 ++++++++++++++++++
...crosoft.Agents.AI.Foundry.UnitTests.csproj | 1 +
11 files changed, 916 insertions(+), 16 deletions(-)
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCapturePolicy.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCaptureScope.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityScope.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
index 59df24c045..f15609b205 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
@@ -135,7 +135,7 @@ internal FoundryAgent(
/// reference here.
///
internal FoundryAgent(ChatClientAgent innerAgent)
- : base(WireClientHeaders(Throw.IfNull(innerAgent)))
+ : base(WireFoundryRequestContext(Throw.IfNull(innerAgent)))
{
}
@@ -162,6 +162,58 @@ internal FoundryAgent(ChatClientAgent innerAgent)
public ValueTask CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
=> this.GetInnerChatClientAgent().CreateSessionAsync(conversationId, cancellationToken);
+ ///
+ /// Creates a local optionally pinned to a Foundry hosted-agent
+ /// session id (sandbox) and/or a server conversation id.
+ ///
+ ///
+ /// Optional existing hosted-agent session id to pin on the session. The id identifies a Foundry
+ /// infrastructure managed sandbox (compute and persistent $HOME), not Agent Framework local
+ /// state. See
+ /// Sessions and conversations.
+ /// When set, it is stored in under
+ /// and subsequent runs that
+ /// reuse this session send agent_session_id automatically. When omitted, Foundry may create
+ /// a session on the first run and the returned id becomes sticky on this session.
+ ///
+ ///
+ /// Optional existing conversation id for server-side message history continuity. Conversation
+ /// history and hosted-agent session (sandbox) are separate Foundry concepts; see
+ /// Sessions and conversations.
+ ///
+ /// The to monitor for cancellation requests.
+ /// A with the optional pins applied.
+ ///
+ ///
+ /// The hosted-agent session itself is owned and lifecycle managed by Foundry Agent Service
+ /// (provisioning, idle suspend, TTL). This method only builds a local Agent Framework session
+ /// object and optionally attaches an existing platform session id. It does not call the Foundry
+ /// admin API to provision a sandbox. To create a platform session first, use the agent
+ /// administration client and pass the resulting id as .
+ ///
+ ///
+ /// For the platform model of sessions versus conversations, see
+ /// Hosted agents: sessions and conversations.
+ ///
+ ///
+ public async Task CreateHostedSessionAsync(
+ string? hostedSessionId = null,
+ string? conversationId = null,
+ CancellationToken cancellationToken = default)
+ {
+ AgentSession session = conversationId is null
+ ? await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
+ : await this.CreateSessionAsync(conversationId, cancellationToken).ConfigureAwait(false);
+
+ var typed = (ChatClientAgentSession)session;
+ if (!string.IsNullOrWhiteSpace(hostedSessionId))
+ {
+ typed.SetHostedAgentSessionId(hostedSessionId!);
+ }
+
+ return typed;
+ }
+
///
/// Creates a server-side conversation session that appears in the Foundry Project UI.
///
@@ -240,23 +292,21 @@ private static AIAgent CreateResponsesChatClientAgent(
chatClient = clientFactory(chatClient);
}
- return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, loggerFactory, services));
+ return WireFoundryRequestContext(new ChatClientAgent(chatClient, agentOptions, loggerFactory, services));
}
///
- /// Registers on the agent's underlying chat client (if it
- /// exposes ) and wraps the agent in a
- /// so per-call x-client-* headers stamped via
- /// reach
- /// the wire. Idempotent: if the chain already contains a ,
- /// the original instance is returned unchanged.
+ /// Registers Foundry per-call pipeline policies and wraps the agent so request-scoped
+ /// headers/body fields reach the wire:
+ ///
+ /// - x-client-* via /
+ /// - x-ms-user-identity and sticky agent_session_id via
+ ///
+ /// Idempotent per decorator type.
///
- private static AIAgent WireClientHeaders(ChatClientAgent innerAgent)
+ private static AIAgent WireFoundryRequestContext(ChatClientAgent innerAgent)
{
- if (innerAgent.GetService() is not null)
- {
- return innerAgent;
- }
+ AIAgent agent = innerAgent;
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (innerAgent.ChatClient.GetService() is { } policies)
@@ -265,10 +315,28 @@ private static AIAgent WireClientHeaders(ChatClientAgent innerAgent)
policies,
ClientHeadersPolicy.Instance,
PipelinePosition.PerCall);
+ OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
+ policies,
+ UserIdentityPolicy.Instance,
+ PipelinePosition.PerCall);
+ OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
+ policies,
+ HostedSessionIdCapturePolicy.Instance,
+ PipelinePosition.PerCall);
}
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
- return new ClientHeadersAgent(innerAgent);
+ if (agent.GetService() is null)
+ {
+ agent = new ClientHeadersAgent(agent);
+ }
+
+ if (agent.GetService() is null)
+ {
+ agent = new FoundryHostedRequestAgent(agent);
+ }
+
+ return agent;
}
///
@@ -303,7 +371,7 @@ private static AIAgent CreateInnerAgentFromAgentEndpoint(
ChatOptions = new() { Tools = tools },
};
- return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
+ return WireFoundryRequestContext(new ChatClientAgent(chatClient, agentOptions, services: services));
}
///
@@ -336,7 +404,7 @@ private static AIAgent CreateInnerAgentFromAgentEndpointReusingProjectClient(
ChatOptions = new() { Tools = tools },
};
- return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
+ return WireFoundryRequestContext(new ChatClientAgent(chatClient, agentOptions, services: services));
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
new file mode 100644
index 0000000000..6e631e853a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Agents.AI.Foundry;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// Foundry-specific extension methods for .
+///
+///
+///
+/// The hosted-agent session id (sandbox / agent_session_id) is stored in
+/// under . That keeps
+/// Foundry-specific state off the sealed type while still
+/// serializing with the session.
+///
+///
+/// This is not . Per-call
+/// overrides use
+/// .
+///
+///
+[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
+public static class FoundryAgentSessionExtensions
+{
+ ///
+ /// Well-known key for the sticky hosted-agent session id.
+ ///
+ public const string HostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";
+
+ ///
+ /// Gets the sticky hosted-agent session id from , or
+ /// if none is stored.
+ ///
+ ///
+ /// Prefer creating/pinning via
+ /// .
+ /// This getter is for reading the id after the platform assigns one (or after an explicit pin).
+ ///
+ public static string? GetHostedAgentSessionId(this AgentSession session)
+ {
+ _ = Throw.IfNull(session);
+ return session.StateBag.TryGetValue(HostedAgentSessionIdKey, out var value)
+ ? value
+ : null;
+ }
+
+ /// Sets the sticky hosted-agent session id on .
+ internal static void SetHostedAgentSessionId(this AgentSession session, string hostedSessionId)
+ {
+ _ = Throw.IfNull(session);
+ _ = Throw.IfNullOrWhitespace(hostedSessionId);
+ session.StateBag.SetValue(HostedAgentSessionIdKey, hostedSessionId);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
new file mode 100644
index 0000000000..44a148db85
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Foundry;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Foundry-specific extension methods for .
+///
+///
+///
+/// Use these helpers to attach per-call Foundry request fields:
+///
+/// - sends agent_session_id on the Responses body.
+/// - sends x-ms-user-identity on the request.
+///
+///
+///
+/// Hosted-agent session ids supplied via participate in the same
+/// conflict rule as : if the already
+/// holds a different hosted id in its , the run throws
+/// . Prefer pinning at session creation via
+/// .
+///
+///
+[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
+public static class FoundryChatOptionsExtensions
+{
+ /// HTTP header name for delegated application user identity.
+ public const string UserIdentityHeaderName = "x-ms-user-identity";
+
+ ///
+ /// Well-known key used to carry a per-call
+ /// hosted-agent session id.
+ ///
+ internal const string HostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";
+
+ ///
+ /// Well-known key used to carry the per-call
+ /// user identity value.
+ ///
+ internal const string UserIdentityKey = "Microsoft.Agents.AI.Foundry.UserIdentity";
+
+ ///
+ /// Attaches a hosted-agent session id to the per-call carrier.
+ ///
+ ///
+ /// Only valid when the run's session has no hosted id yet, or already has this same id.
+ /// Prefer
+ /// to pin at session creation.
+ ///
+ public static ChatOptions WithHostedAgentSessionId(this ChatOptions options, string hostedSessionId)
+ {
+ _ = Throw.IfNull(options);
+ _ = Throw.IfNullOrWhitespace(hostedSessionId);
+
+ options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
+ options.AdditionalProperties[HostedAgentSessionIdKey] = hostedSessionId;
+ return options;
+ }
+
+ ///
+ /// Attaches a delegated user identity value that will be sent as the
+ /// x-ms-user-identity request header.
+ ///
+ /// The per-call chat options to mutate.
+ /// Opaque application user identifier. Must be non-empty.
+ /// for fluent chaining.
+ ///
+ /// User identity is always request-scoped. It is never stored on .
+ /// The same session (same sandbox) may be used with different identities across runs.
+ ///
+ public static ChatOptions WithUserIdentity(this ChatOptions options, string userIdentity)
+ {
+ _ = Throw.IfNull(options);
+ _ = Throw.IfNullOrWhitespace(userIdentity);
+
+ options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
+ options.AdditionalProperties[UserIdentityKey] = userIdentity;
+ return options;
+ }
+
+ /// Reads the per-call hosted-agent session id stamped by .
+ internal static string? GetHostedAgentSessionId(this ChatOptions options)
+ {
+ if (options.AdditionalProperties is null)
+ {
+ return null;
+ }
+
+ if (!options.AdditionalProperties.TryGetValue(HostedAgentSessionIdKey, out var raw))
+ {
+ return null;
+ }
+
+ return raw as string;
+ }
+
+ /// Reads the per-call user identity stamped by .
+ internal static string? GetUserIdentity(this ChatOptions options)
+ {
+ if (options.AdditionalProperties is null)
+ {
+ return null;
+ }
+
+ if (!options.AdditionalProperties.TryGetValue(UserIdentityKey, out var raw))
+ {
+ return null;
+ }
+
+ return raw as string;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
new file mode 100644
index 0000000000..5839609fab
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
@@ -0,0 +1,152 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.ClientModel.Primitives;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using OpenAI.Responses;
+
+#pragma warning disable OPENAI001
+#pragma warning disable SCME0001
+#pragma warning disable MEAI001
+
+namespace Microsoft.Agents.AI.Foundry;
+
+///
+/// Delegating agent that applies Foundry hosted-agent request context per run:
+/// resolves the sticky hosted-agent session id, injects agent_session_id into the
+/// Responses body, stamps x-ms-user-identity, and writes the platform-returned session
+/// id back onto the .
+///
+internal sealed class FoundryHostedRequestAgent : DelegatingAIAgent
+{
+ public FoundryHostedRequestAgent(AIAgent innerAgent)
+ : base(innerAgent)
+ {
+ }
+
+ ///
+ protected override async Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var prepared = Prepare(session, options);
+ var response = await this.InnerAgent.RunAsync(messages, session, prepared.Options, cancellationToken).ConfigureAwait(false);
+ ApplySessionSticky(session, prepared.SessionIdBox);
+ return response;
+ }
+
+ ///
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var prepared = Prepare(session, options);
+
+ await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, prepared.Options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+
+ ApplySessionSticky(session, prepared.SessionIdBox);
+ }
+
+ private static PreparedRun Prepare(AgentSession? session, AgentRunOptions? options)
+ {
+ ChatOptions? chatOptions = options is ChatClientAgentRunOptions cro ? cro.ChatOptions : null;
+
+ string? sessionHostedId = session?.GetHostedAgentSessionId();
+ string? optionsHostedId = chatOptions?.GetHostedAgentSessionId();
+
+ if (!string.IsNullOrWhiteSpace(sessionHostedId)
+ && !string.IsNullOrWhiteSpace(optionsHostedId)
+ && !string.Equals(sessionHostedId, optionsHostedId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ """
+ The hosted-agent session id provided via ChatOptions is different from the id stored on the provided AgentSession.
+ Only one hosted-agent session id can be used for a run.
+ """);
+ }
+
+ string? resolvedHostedId = !string.IsNullOrWhiteSpace(optionsHostedId) ? optionsHostedId : sessionHostedId;
+ var sessionIdBox = new StrongBox(resolvedHostedId);
+ HostedSessionIdCaptureScope.Current = sessionIdBox;
+
+ // Always ensure ChatOptions + factory so (a) an existing id is sent on every service call
+ // and (b) a platform-created id captured mid-run is sent on later function-loop calls.
+ var effectiveOptions = EnsureChatOptions(options, out chatOptions);
+ AttachHostedSessionIdFactory(chatOptions, sessionIdBox);
+
+ string? userIdentity = chatOptions.GetUserIdentity();
+ if (!string.IsNullOrWhiteSpace(userIdentity))
+ {
+ UserIdentityScope.Current = userIdentity;
+ }
+
+ return new PreparedRun(effectiveOptions, sessionIdBox);
+ }
+
+ private static ChatClientAgentRunOptions EnsureChatOptions(AgentRunOptions? options, out ChatOptions chatOptions)
+ {
+ if (options is ChatClientAgentRunOptions existing)
+ {
+ existing.ChatOptions ??= new ChatOptions();
+ chatOptions = existing.ChatOptions;
+ return existing;
+ }
+
+ chatOptions = new ChatOptions();
+ return new ChatClientAgentRunOptions(chatOptions);
+ }
+
+ private static void AttachHostedSessionIdFactory(ChatOptions chatOptions, StrongBox sessionIdBox)
+ {
+ var previousFactory = chatOptions.RawRepresentationFactory;
+ chatOptions.RawRepresentationFactory = client =>
+ {
+ object? previous = previousFactory?.Invoke(client);
+ if (previous is not null and not CreateResponseOptions)
+ {
+ return previous;
+ }
+
+ var responseOptions = previous as CreateResponseOptions ?? new CreateResponseOptions();
+ if (!string.IsNullOrWhiteSpace(sessionIdBox.Value))
+ {
+ responseOptions.Patch.Set("$.agent_session_id"u8, sessionIdBox.Value);
+ }
+
+ return responseOptions;
+ };
+ }
+
+ private static void ApplySessionSticky(AgentSession? session, StrongBox sessionIdBox)
+ {
+ if (session is null || string.IsNullOrWhiteSpace(sessionIdBox.Value))
+ {
+ return;
+ }
+
+ session.SetHostedAgentSessionId(sessionIdBox.Value!);
+ }
+
+ private sealed class PreparedRun
+ {
+ public PreparedRun(ChatClientAgentRunOptions options, StrongBox sessionIdBox)
+ {
+ this.Options = options;
+ this.SessionIdBox = sessionIdBox;
+ }
+
+ public ChatClientAgentRunOptions Options { get; }
+ public StrongBox SessionIdBox { get; }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCapturePolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCapturePolicy.cs
new file mode 100644
index 0000000000..b15c820aa6
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCapturePolicy.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ClientModel.Primitives;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Foundry;
+
+///
+/// Pipeline policy that captures the x-agent-session-id response header into
+/// so subsequent service calls in the same run (and the
+/// session sticky update after the run) see the platform-assigned hosted-agent session id.
+///
+internal sealed class HostedSessionIdCapturePolicy : PipelinePolicy
+{
+ internal const string SessionIdHeader = "x-agent-session-id";
+
+ public static HostedSessionIdCapturePolicy Instance { get; } = new HostedSessionIdCapturePolicy();
+
+ private HostedSessionIdCapturePolicy()
+ {
+ }
+
+ public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ ProcessNext(message, pipeline, currentIndex);
+ Capture(message);
+ }
+
+ public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
+ Capture(message);
+ }
+
+ private static void Capture(PipelineMessage message)
+ {
+ if (message.Response is null)
+ {
+ return;
+ }
+
+ if (HostedSessionIdCaptureScope.Current is not { } box)
+ {
+ return;
+ }
+
+ if (message.Response.Headers.TryGetValue(SessionIdHeader, out string? sessionId)
+ && !string.IsNullOrWhiteSpace(sessionId))
+ {
+ box.Value = sessionId.Trim();
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCaptureScope.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCaptureScope.cs
new file mode 100644
index 0000000000..d4706d1022
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCaptureScope.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+namespace Microsoft.Agents.AI.Foundry;
+
+///
+/// AsyncLocal carrier for the mutable hosted-agent session id box shared by
+/// (request body injection) and
+/// (response header capture).
+///
+///
+/// Uses so writes inside the transport pipeline remain visible to the
+/// agent decorator after the inner call returns (and on later service calls in a function loop).
+///
+internal static class HostedSessionIdCaptureScope
+{
+ private static readonly AsyncLocal?> s_current = new();
+
+ /// Gets or sets the per-async-flow hosted session id box.
+ public static StrongBox? Current
+ {
+ get => s_current.Value;
+ set => s_current.Value = value;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs
new file mode 100644
index 0000000000..a71655e2bc
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ClientModel.Primitives;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Foundry;
+
+///
+/// Pipeline policy that stamps x-ms-user-identity from
+/// onto outbound OpenAI Responses requests.
+///
+internal sealed class UserIdentityPolicy : PipelinePolicy
+{
+ public static UserIdentityPolicy Instance { get; } = new UserIdentityPolicy();
+
+ private UserIdentityPolicy()
+ {
+ }
+
+ public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ Stamp(message);
+ ProcessNext(message, pipeline, currentIndex);
+ }
+
+ public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ Stamp(message);
+ return ProcessNextAsync(message, pipeline, currentIndex);
+ }
+
+ private static void Stamp(PipelineMessage message)
+ {
+ var identity = UserIdentityScope.Current;
+ if (string.IsNullOrWhiteSpace(identity))
+ {
+ return;
+ }
+
+ message.Request.Headers.Set("x-ms-user-identity", identity);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityScope.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityScope.cs
new file mode 100644
index 0000000000..b7e5630eb7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityScope.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading;
+
+namespace Microsoft.Agents.AI.Foundry;
+
+///
+/// AsyncLocal carrier for the per-call x-ms-user-identity value from
+/// to .
+///
+internal static class UserIdentityScope
+{
+ private static readonly AsyncLocal s_current = new();
+
+ /// Gets or sets the per-async-flow user identity value.
+ public static string? Current
+ {
+ get => s_current.Value;
+ set => s_current.Value = value;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
index 1d88809e9f..454ab36b16 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
@@ -168,6 +168,7 @@ public void Constructor_PreWiresClientHeadersAgent()
// Assert: ClientHeadersAgent decorator is present in the delegating chain.
Assert.NotNull(agent.GetService());
+ Assert.NotNull(agent.GetService());
}
[Fact]
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
new file mode 100644
index 0000000000..26a369bdba
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
@@ -0,0 +1,357 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.ClientModel;
+using System.ClientModel.Primitives;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using OpenAI;
+using OpenAI.Responses;
+
+#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
+
+namespace Microsoft.Agents.AI.Foundry.UnitTests;
+
+///
+/// Tests for hosted-agent session sticky behavior and per-call user identity.
+///
+public sealed class FoundryHostedRequestTests
+{
+ [Fact]
+ public void WithHostedAgentSessionId_WritesOptionsCarrier()
+ {
+ var options = new ChatOptions();
+ options.WithHostedAgentSessionId("sess-1");
+ Assert.Equal("sess-1", options.GetHostedAgentSessionId());
+ }
+
+ [Fact]
+ public void WithUserIdentity_WritesOptionsCarrier()
+ {
+ var options = new ChatOptions();
+ options.WithUserIdentity("alice");
+ Assert.Equal("alice", options.GetUserIdentity());
+ }
+
+ [Fact]
+ public async Task CreateHostedSessionAsync_PinsHostedAndConversationIdsAsync()
+ {
+ FoundryAgent agent = CreateFoundryAgent();
+ ChatClientAgentSession session = await agent.CreateHostedSessionAsync(
+ hostedSessionId: "sess-1",
+ conversationId: "conv-1");
+
+ Assert.Equal("sess-1", session.GetHostedAgentSessionId());
+ Assert.Equal("conv-1", session.ConversationId);
+ Assert.True(session.StateBag.TryGetValue(FoundryAgentSessionExtensions.HostedAgentSessionIdKey, out var raw));
+ Assert.Equal("sess-1", raw);
+ }
+
+ [Fact]
+ public async Task CreateHostedSessionAsync_WithoutIds_LeavesBothEmptyAsync()
+ {
+ FoundryAgent agent = CreateFoundryAgent();
+ ChatClientAgentSession session = await agent.CreateHostedSessionAsync();
+
+ Assert.Null(session.GetHostedAgentSessionId());
+ Assert.Null(session.ConversationId);
+ }
+
+ [Fact]
+ public async Task Conflict_SessionAndOptionsHostedIdsDiffer_ThrowsAsync()
+ {
+ var inner = new ProbeAgent();
+ var agent = new FoundryHostedRequestAgent(inner);
+ var session = new TestSession();
+ session.SetHostedAgentSessionId("sess-A");
+ var runOptions = new ChatClientAgentRunOptions(
+ new ChatOptions().WithHostedAgentSessionId("sess-B"));
+
+ InvalidOperationException ex = await Assert.ThrowsAsync(
+ () => agent.RunAsync("hi", session, runOptions));
+ Assert.Contains("hosted-agent session id", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task SameHostedId_OnSessionAndOptions_DoesNotThrowAsync()
+ {
+ var inner = new ProbeAgent();
+ var agent = new FoundryHostedRequestAgent(inner);
+ var session = new TestSession();
+ session.SetHostedAgentSessionId("sess-A");
+ var runOptions = new ChatClientAgentRunOptions(
+ new ChatOptions().WithHostedAgentSessionId("sess-A"));
+
+ await agent.RunAsync("hi", session, runOptions);
+ Assert.Equal(1, inner.RunCount);
+ }
+
+ [Fact]
+ public async Task Sticky_SessionHostedId_IsInjectedIntoCreateResponseOptionsAsync()
+ {
+ CreateResponseOptions? seen = null;
+ var inner = new ProbeAgent(onRun: options =>
+ {
+ if (options is ChatClientAgentRunOptions { ChatOptions.RawRepresentationFactory: { } factory })
+ {
+ seen = factory(null!) as CreateResponseOptions;
+ }
+ });
+ var agent = new FoundryHostedRequestAgent(inner);
+ var session = new TestSession();
+ session.SetHostedAgentSessionId("sess-sticky");
+
+ await agent.RunAsync("hi", session);
+
+ Assert.NotNull(seen);
+ Assert.True(seen!.Patch.Contains("$.agent_session_id"u8));
+ }
+
+ [Fact]
+ public async Task OptionsHostedId_WhenSessionEmpty_IsInjectedAndStickyAfterRunAsync()
+ {
+ var inner = new ProbeAgent();
+ var agent = new FoundryHostedRequestAgent(inner);
+ var session = new TestSession();
+ var runOptions = new ChatClientAgentRunOptions(
+ new ChatOptions().WithHostedAgentSessionId("sess-options"));
+
+ await agent.RunAsync("hi", session, runOptions);
+ Assert.Equal("sess-options", session.GetHostedAgentSessionId());
+ }
+
+ [Fact]
+ public async Task UserIdentity_DifferentPerCall_OnSameSession_IsAllowedAsync()
+ {
+ var seen = new List();
+ var inner = new ProbeAgent(onRun: _ => seen.Add(UserIdentityScope.Current));
+ var agent = new FoundryHostedRequestAgent(inner);
+ var session = new TestSession();
+ session.SetHostedAgentSessionId("sess-shared");
+
+ await agent.RunAsync(
+ "hi",
+ session,
+ new ChatClientAgentRunOptions(new ChatOptions().WithUserIdentity("alice")));
+ await agent.RunAsync(
+ "hi",
+ session,
+ new ChatClientAgentRunOptions(new ChatOptions().WithUserIdentity("bob")));
+
+ Assert.Equal(["alice", "bob"], seen);
+ Assert.Equal("sess-shared", session.GetHostedAgentSessionId());
+ }
+
+ [Fact]
+ public async Task EndToEnd_UserIdentity_AndHostedSessionId_ReachWireAsync()
+ {
+ using var handler = new RecordingHandler(
+ MinimalResponseJson(),
+ responseHeaders: new Dictionary
+ {
+ ["x-agent-session-id"] = "sess-from-platform",
+ });
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+ var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
+ var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
+ IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
+
+#pragma warning disable MEAI001
+ var policies = chatClient.GetService();
+ Assert.NotNull(policies);
+ OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies!, ClientHeadersPolicy.Instance);
+ OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies!, UserIdentityPolicy.Instance);
+ OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies!, HostedSessionIdCapturePolicy.Instance);
+#pragma warning restore MEAI001
+
+ var chatAgent = new ChatClientAgent(chatClient);
+ AIAgent agent = new FoundryHostedRequestAgent(new ClientHeadersAgent(chatAgent));
+ AgentSession session = await chatAgent.CreateSessionAsync();
+ session.SetHostedAgentSessionId("sess-pinned");
+
+ var runOptions = new ChatClientAgentRunOptions(
+ new ChatOptions()
+ .WithUserIdentity("alice")
+ .WithClientHeader("x-client-end-user-id", "alice-app"));
+
+ await agent.RunAsync("hi", session, runOptions);
+
+ Assert.True(handler.Requests.Count > 0);
+ var req = handler.Requests[0];
+ Assert.Equal("alice", req.Headers[FoundryChatOptionsExtensions.UserIdentityHeaderName]);
+ Assert.Equal("alice-app", req.Headers["x-client-end-user-id"]);
+ Assert.Contains("\"agent_session_id\":\"sess-pinned\"", req.Body, StringComparison.Ordinal);
+ Assert.Equal("sess-from-platform", session.GetHostedAgentSessionId());
+ }
+
+ [Fact]
+ public async Task EndToEnd_ServiceManaged_CapturesHostedSessionIdOntoSessionAsync()
+ {
+ using var handler = new RecordingHandler(
+ MinimalResponseJson(),
+ responseHeaders: new Dictionary
+ {
+ ["x-agent-session-id"] = "sess-created",
+ });
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+ var openAIClient = new OpenAIClient(
+ new ApiKeyCredential("fake"),
+ new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
+ IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
+
+#pragma warning disable MEAI001
+ var policies = chatClient.GetService()!;
+ OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, HostedSessionIdCapturePolicy.Instance);
+#pragma warning restore MEAI001
+
+ var chatAgent = new ChatClientAgent(chatClient);
+ AIAgent agent = new FoundryHostedRequestAgent(chatAgent);
+ AgentSession session = await chatAgent.CreateSessionAsync();
+
+ await agent.RunAsync("hi", session);
+
+ Assert.Equal("sess-created", session.GetHostedAgentSessionId());
+ Assert.DoesNotContain("agent_session_id", handler.Requests[0].Body, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Constructor_PreWiresFoundryHostedRequestAgent()
+ {
+ FoundryAgent agent = CreateFoundryAgent();
+ Assert.NotNull(agent.GetService());
+ Assert.NotNull(agent.GetService());
+ }
+
+ private static FoundryAgent CreateFoundryAgent() =>
+ new(
+ new Uri("https://test.services.ai.azure.com/api/projects/test-project"),
+ new FakeAuthenticationTokenProvider(),
+ model: "gpt-4o-mini",
+ instructions: "Test");
+
+ private static string MinimalResponseJson() => """
+ {
+ "id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
+ "model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
+ }
+ """;
+
+ private sealed class TestSession : AgentSession;
+
+ private sealed class ProbeAgent : AIAgent
+ {
+ private readonly Action? _onRun;
+
+ public ProbeAgent(Action? onRun = null)
+ {
+ this._onRun = onRun;
+ }
+
+ public int RunCount { get; private set; }
+
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ this.RunCount++;
+ this._onRun?.Invoke(options);
+ return Task.FromResult(new AgentResponse());
+ }
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ this.RunCount++;
+ this._onRun?.Invoke(options);
+ await Task.Yield();
+ yield break;
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
+ new(new TestSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session,
+ JsonSerializerOptions? jsonSerializerOptions,
+ CancellationToken cancellationToken = default) =>
+ new(JsonDocument.Parse("{}").RootElement);
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState,
+ JsonSerializerOptions? jsonSerializerOptions,
+ CancellationToken cancellationToken = default) =>
+ new(new TestSession());
+ }
+
+ private sealed class RecordingHandler : HttpClientHandler
+ {
+ private readonly string _body;
+ private readonly Dictionary _responseHeaders;
+
+ public RecordingHandler(string body, Dictionary? responseHeaders = null)
+ {
+ this._body = body;
+ this._responseHeaders = responseHeaders ?? new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ public List Requests { get; } = [];
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var h in request.Headers)
+ {
+ headers[h.Key] = string.Join(",", h.Value);
+ }
+
+ string body;
+ if (request.Content is null)
+ {
+ body = string.Empty;
+ }
+ else
+ {
+#if NET
+ body = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+#else
+ body = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
+#endif
+ }
+
+ this.Requests.Add(new RecordedRequest(headers, body));
+
+ var resp = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
+ RequestMessage = request,
+ };
+ foreach (var kvp in this._responseHeaders)
+ {
+ resp.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value);
+ }
+
+ return resp;
+ }
+ }
+
+ private sealed class RecordedRequest(Dictionary headers, string body)
+ {
+ public Dictionary Headers { get; } = headers;
+ public string Body { get; } = body;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
index 7862225653..55bded4ea9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
@@ -21,6 +21,7 @@
+
From 9eada879cebdacc4a62f4e2dbaf48d7bf480c314 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Thu, 13 Aug 2026 16:00:33 +0100
Subject: [PATCH 2/6] .NET: Add live ITs for Foundry session and user identity
Cover service-managed and admin-pinned hosted sandboxes, sticky
hosted session id, and per-call x-ms-user-identity isolation with
separate AgentSessions sharing one sandbox. Echo container avoids
model quota for identity assertions.
---
.../FoundryAgentSessionExtensions.cs | 4 +-
.../FoundryHostedRequestAgent.cs | 1 -
.../Program.cs | 7 +
.../UserIdentityEchoAgent.cs | 73 +++++
.../UserIdentityHostedAgentFixture.cs | 13 +
.../HostedSessionAndUserIdentityTests.cs | 297 ++++++++++++++++++
.../README.md | 15 +
.../scripts/it-bootstrap-agents.ps1 | 1 +
8 files changed, 408 insertions(+), 3 deletions(-)
create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/UserIdentityEchoAgent.cs
create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/UserIdentityHostedAgentFixture.cs
create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
index 6e631e853a..264b5f5ca6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
@@ -18,9 +18,9 @@ namespace Microsoft.Agents.AI;
/// serializing with the session.
///
///
-/// This is not . Per-call
+/// This is not . Per-call
/// overrides use
-/// .
+/// .
///
///
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
index 5839609fab..d2bcba6f5b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
-using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
index 6f491c7290..34e74a04a7 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
@@ -45,6 +45,7 @@
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
"agent-skills" => CreateAgentSkillsAgent(projectClient, deployment),
+ "user-identity" => CreateUserIdentityAgent(projectClient, deployment),
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
};
@@ -210,6 +211,12 @@ static AIAgent CreateAzureSearchRagAgent(AIProjectClient client, string deployme
return results;
};
+// user-identity scenario: returns USER-ID: without calling a model so the
+// assertion works even when the subscription has no OpenAI chat deployment. The hosting layer
+// writes HostedSessionContext from x-agent-user-id before RunCoreAsync.
+static AIAgent CreateUserIdentityAgent(AIProjectClient _, string __) =>
+ new UserIdentityEchoAgent();
+
// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume.
// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample.
static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) =>
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/UserIdentityEchoAgent.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/UserIdentityEchoAgent.cs
new file mode 100644
index 0000000000..91cbcf5f9d
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/UserIdentityEchoAgent.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Foundry.Hosting;
+using Microsoft.Extensions.AI;
+
+namespace Foundry.Hosting.IntegrationTests.TestContainer;
+
+///
+/// Minimal agent that echoes the platform user isolation key as USER-ID:<key>.
+/// Does not call a model, so identity ITs do not depend on OpenAI quota or catalog access.
+///
+#pragma warning disable MAAI001 // HostedSessionContext / experimental surface
+internal sealed class UserIdentityEchoAgent : AIAgent
+{
+ public override string Name => "user-identity-agent";
+
+ public override string Description =>
+ "Echoes the platform user isolation key for user-identity IT assertions.";
+
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var text = BuildReply(session);
+ return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, text)));
+ }
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var text = BuildReply(session);
+ yield return new AgentResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents = [new TextContent(text)],
+ };
+
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => new(new InMemorySession());
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions));
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(new InMemorySession());
+
+ private static string BuildReply(AgentSession? session)
+ {
+ var userId = session?.GetHostedContext()?.UserId;
+ var token = string.IsNullOrWhiteSpace(userId) ? "USER-ID:missing" : $"USER-ID:{userId}";
+ return $"ready\n{token}";
+ }
+
+ private sealed class InMemorySession : AgentSession;
+}
+#pragma warning restore MAAI001
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/UserIdentityHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/UserIdentityHostedAgentFixture.cs
new file mode 100644
index 0000000000..f85b691909
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/UserIdentityHostedAgentFixture.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Foundry.Hosting.IntegrationTests.Fixtures;
+
+///
+/// Provisions a hosted agent that runs the test container in IT_SCENARIO=user-identity mode.
+/// The container echoes the platform user isolation key so client tests can assert that
+/// x-ms-user-identity produces distinct effective users on the same hosted session.
+///
+public sealed class UserIdentityHostedAgentFixture : HostedAgentFixture
+{
+ protected override string ScenarioName => "user-identity";
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs
new file mode 100644
index 0000000000..0e81420c35
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs
@@ -0,0 +1,297 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+#pragma warning disable AAIP001 // Agent session admin APIs are experimental
+#pragma warning disable MEAI001 // FoundryChatOptionsExtensions / OpenAIRequestPolicies are experimental
+
+using System;
+using System.ClientModel.Primitives;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using AgentConformance.IntegrationTests.Support;
+using Azure.AI.Extensions.OpenAI;
+using Azure.AI.Projects.Agents;
+using Foundry.Hosting.IntegrationTests.Fixtures;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Foundry;
+using Microsoft.Extensions.AI;
+using Shared.IntegrationTests;
+
+namespace Foundry.Hosting.IntegrationTests;
+
+///
+/// Live tests for client-side Foundry hosted session sticky behavior and per-call
+/// x-ms-user-identity pass-through against a real hosted agent.
+///
+///
+///
+/// These tests build a against the fixture's agent endpoint so the
+/// production request pipeline (FoundryHostedRequestAgent, session sticky, user-identity
+/// header) is exercised. The fixture's default is a plain
+/// chat-client agent and is intentionally not used here.
+///
+///
+/// Requires the caller credential to be allowed to send x-ms-user-identity (delegation).
+/// Without that permission the user-identity tests fail at the platform with 403 rather than an
+/// assertion mismatch.
+///
+///
+[Trait("Category", "FoundryHostedAgents")]
+public sealed class HostedSessionAndUserIdentityTests(UserIdentityHostedAgentFixture fixture)
+ : IClassFixture
+{
+ private const string FoundryFeaturesHeader = "Foundry-Features";
+ private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview,AgentEndpoints=V1Preview";
+ private static readonly Regex s_userIdToken = new(@"USER-ID:(\S+)", RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+ private readonly UserIdentityHostedAgentFixture _fixture = fixture;
+
+ [Fact(Skip = "Requires live Foundry hosted agent image, bootstrap it-user-identity, and delegation permission for x-ms-user-identity.")]
+ public async Task ServiceManagedSession_BecomesStickyAndIsReusedAsync()
+ {
+ // Arrange
+ FoundryAgent agent = this.CreateFoundryAgent();
+ ChatClientAgentSession session = await agent.CreateHostedSessionAsync();
+ Assert.Null(session.GetHostedAgentSessionId());
+
+ string? hostedSessionId = null;
+ try
+ {
+ // Act: first run lets Foundry create the sandbox; sticky id is written from the response.
+ var first = await agent.RunAsync("Reply with the single word ready.", session);
+ Assert.False(string.IsNullOrWhiteSpace(first.Text));
+
+ hostedSessionId = session.GetHostedAgentSessionId();
+ Assert.False(string.IsNullOrWhiteSpace(hostedSessionId));
+
+ // Act: second run reuses the same AgentSession and must keep the same sticky id.
+ var second = await agent.RunAsync("Reply with the single word again.", session);
+ Assert.False(string.IsNullOrWhiteSpace(second.Text));
+
+ // Assert
+ Assert.Equal(hostedSessionId, session.GetHostedAgentSessionId());
+ }
+ finally
+ {
+ await this.TryDeleteSessionAsync(hostedSessionId);
+ }
+ }
+
+ [Fact(Skip = "Requires live Foundry hosted agent image, bootstrap it-user-identity, and AgentAdministration CreateSession.")]
+ public async Task UserManagedSession_PinIsStickyAndMatchesAdminSessionAsync()
+ {
+ // Arrange: provision sandbox via admin API (Python using_deployed_agent path).
+ AgentAdministrationClient admin = this.CreateAdminClient();
+ ProjectAgentSession platformSession = await admin.CreateSessionAsync(
+ this._fixture.AgentName,
+ new VersionRefIndicator(this._fixture.AgentVersion));
+
+ string hostedSessionId = platformSession.AgentSessionId;
+ await WaitForSessionActiveAsync(admin, this._fixture.AgentName, hostedSessionId);
+
+ FoundryAgent agent = this.CreateFoundryAgent();
+ ChatClientAgentSession session = await agent.CreateHostedSessionAsync(hostedSessionId: hostedSessionId);
+ Assert.Equal(hostedSessionId, session.GetHostedAgentSessionId());
+
+ try
+ {
+ // Act
+ var response = await agent.RunAsync("Reply with the single word pinned.", session);
+
+ // Assert
+ Assert.False(string.IsNullOrWhiteSpace(response.Text));
+ Assert.Equal(hostedSessionId, session.GetHostedAgentSessionId());
+ }
+ finally
+ {
+ await this.TryDeleteSessionAsync(hostedSessionId);
+ }
+ }
+
+ [Fact(Skip = "Requires live Foundry hosted agent image, bootstrap it-user-identity, and delegation permission for x-ms-user-identity.")]
+ public async Task SameHostedSandbox_DifferentAgentSessionsAndUserIdentities_YieldsDistinctUsersAsync()
+ {
+ // Arrange: two different AgentSession instances share one Foundry hosted sandbox id.
+ // ConversationId is per AgentSession (chat trail). HostedAgentSessionId is the sandbox.
+ // Reusing one AgentSession across identities reuses previous_response_id and 404s under
+ // per-user response partitioning; separate AgentSessions avoid that while keeping the sandbox.
+ FoundryAgent agent = this.CreateFoundryAgent();
+ string? hostedSessionId = null;
+
+ try
+ {
+ // Act: alice creates the sandbox via service-managed sticky capture.
+ ChatClientAgentSession aliceSession = await agent.CreateHostedSessionAsync();
+ string aliceUserId = await this.RunAndReadUserIdAsync(agent, aliceSession, "alice-it");
+ hostedSessionId = aliceSession.GetHostedAgentSessionId();
+ Assert.False(string.IsNullOrWhiteSpace(hostedSessionId));
+ string? aliceConversationId = aliceSession.ConversationId;
+
+ // Act: bob gets a fresh AgentSession pinned to the same hosted sandbox.
+ ChatClientAgentSession bobSession = await agent.CreateHostedSessionAsync(hostedSessionId: hostedSessionId);
+ Assert.NotSame(aliceSession, bobSession);
+ Assert.Equal(hostedSessionId, bobSession.GetHostedAgentSessionId());
+
+ string bobUserId = await this.RunAndReadUserIdAsync(agent, bobSession, "bob-it");
+
+ // Assert: hosted sandbox stays the same on both sessions after bob's response.
+ Assert.Equal(hostedSessionId, aliceSession.GetHostedAgentSessionId());
+ Assert.Equal(hostedSessionId, bobSession.GetHostedAgentSessionId());
+
+ // Assert: conversation trails stay independent (must not share ConversationId).
+ string? bobConversationId = bobSession.ConversationId;
+ Assert.False(
+ aliceConversationId is not null
+ && bobConversationId is not null
+ && string.Equals(aliceConversationId, bobConversationId, StringComparison.Ordinal),
+ $"ConversationId must differ across AgentSessions. alice='{aliceConversationId}', bob='{bobConversationId}'.");
+ if (aliceConversationId is not null || bobConversationId is not null)
+ {
+ Assert.NotEqual(aliceConversationId, bobConversationId);
+ }
+
+ // Assert: platform user keys differ for alice vs bob.
+ Assert.NotEqual("missing", aliceUserId);
+ Assert.NotEqual("missing", bobUserId);
+ Assert.NotEqual(aliceUserId, bobUserId);
+ }
+ finally
+ {
+ await this.TryDeleteSessionAsync(hostedSessionId);
+ }
+ }
+
+ [Fact(Skip = "Requires live Foundry hosted agent image, bootstrap it-user-identity, and delegation permission for x-ms-user-identity.")]
+ public async Task SameSession_SameUserIdentity_YieldsStablePlatformUserIdAsync()
+ {
+ // Arrange
+ FoundryAgent agent = this.CreateFoundryAgent();
+ ChatClientAgentSession session = await agent.CreateHostedSessionAsync();
+ string? hostedSessionId = null;
+
+ try
+ {
+ // Act
+ string first = await this.RunAndReadUserIdAsync(agent, session, "stable-user-it");
+ hostedSessionId = session.GetHostedAgentSessionId();
+ string second = await this.RunAndReadUserIdAsync(agent, session, "stable-user-it");
+
+ // Assert
+ Assert.False(string.IsNullOrWhiteSpace(hostedSessionId));
+ Assert.Equal(hostedSessionId, session.GetHostedAgentSessionId());
+ Assert.NotEqual("missing", first);
+ Assert.Equal(first, second);
+ }
+ finally
+ {
+ await this.TryDeleteSessionAsync(hostedSessionId);
+ }
+ }
+
+ private async Task RunAndReadUserIdAsync(FoundryAgent agent, AgentSession session, string userIdentity)
+ {
+ var options = new ChatClientAgentRunOptions(
+ new ChatOptions().WithUserIdentity(userIdentity));
+
+ var response = await agent.RunAsync(
+ "Acknowledge the request briefly.",
+ session,
+ options);
+
+ Assert.False(string.IsNullOrWhiteSpace(response.Text));
+ Match match = s_userIdToken.Match(response.Text);
+ Assert.True(match.Success, $"Expected USER-ID: token in response text. Actual: {response.Text}");
+ return match.Groups[1].Value;
+ }
+
+ ///
+ /// Builds a against this fixture's hosted agent endpoint with the
+ /// preview feature headers required for hosted agent traffic.
+ ///
+ private FoundryAgent CreateFoundryAgent()
+ {
+ var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
+ var credential = TestAzureCliCredentials.CreateAzureCliCredential();
+ Uri agentEndpoint = new($"{endpoint.ToString().TrimEnd('/')}/agents/{this._fixture.AgentName}/endpoint/protocols/openai");
+
+ var options = new ProjectOpenAIClientOptions
+ {
+ AgentName = this._fixture.AgentName,
+ };
+ options.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
+
+ return new FoundryAgent(agentEndpoint, credential, options);
+ }
+
+ private AgentAdministrationClient CreateAdminClient()
+ {
+ var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
+ var credential = TestAzureCliCredentials.CreateAzureCliCredential();
+ var adminOptions = new AgentAdministrationClientOptions();
+ adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
+ return new AgentAdministrationClient(endpoint, credential, adminOptions);
+ }
+
+ private async Task TryDeleteSessionAsync(string? hostedSessionId)
+ {
+ if (string.IsNullOrWhiteSpace(hostedSessionId))
+ {
+ return;
+ }
+
+ try
+ {
+ AgentAdministrationClient admin = this.CreateAdminClient();
+ await admin.DeleteSessionAsync(this._fixture.AgentName, hostedSessionId);
+ }
+ catch
+ {
+ // Best-effort cleanup; platform TTL reclaims orphaned sessions.
+ }
+ }
+
+ private static async Task WaitForSessionActiveAsync(
+ AgentAdministrationClient admin,
+ string agentName,
+ string sessionId,
+ TimeSpan? timeout = null)
+ {
+ TimeSpan limit = timeout ?? TimeSpan.FromMinutes(3);
+ DateTimeOffset deadline = DateTimeOffset.UtcNow + limit;
+ ProjectAgentSession session = await admin.GetSessionAsync(agentName, sessionId);
+
+ while (session.Status != AgentSessionStatus.Active
+ && session.Status != AgentSessionStatus.Failed
+ && session.Status != AgentSessionStatus.Deleted
+ && session.Status != AgentSessionStatus.Expired)
+ {
+ if (DateTimeOffset.UtcNow > deadline)
+ {
+ throw new TimeoutException(
+ $"Hosted session '{sessionId}' for agent '{agentName}' did not become Active within {limit.TotalSeconds:F0}s. Last status: {session.Status}.");
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(500), CancellationToken.None);
+ session = await admin.GetSessionAsync(agentName, sessionId);
+ }
+
+ Assert.Equal(AgentSessionStatus.Active, session.Status);
+ }
+
+ /// Pipeline policy that stamps the Foundry preview feature header.
+ private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
+ {
+ public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ message.Request.Headers.Set(FoundryFeaturesHeader, features);
+ ProcessNext(message, pipeline, currentIndex);
+ }
+
+ public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ message.Request.Headers.Set(FoundryFeaturesHeader, features);
+ return ProcessNextAsync(message, pipeline, currentIndex);
+ }
+ }
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
index 234dee03a5..d25ef0f2d0 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
@@ -31,6 +31,21 @@ the agent definition by each fixture, drives a `switch` in the test container's
`Program.cs` to wire up the scenario specific behavior (tools, toolbox, custom storage,
etc.).
+### Session sticky and user-identity scenario
+
+`HostedSessionAndUserIdentityTests` (fixture `UserIdentityHostedAgentFixture`, agent
+`it-user-identity`) exercises the client-side `FoundryAgent` APIs:
+
+- `CreateHostedSessionAsync` sticky hosted `agent_session_id` (service-managed and
+ admin `CreateSession` / `DeleteSession` pin)
+- per-call `ChatOptions.WithUserIdentity` (`x-ms-user-identity`) producing distinct
+ platform user keys inside the container
+
+The container scenario injects `USER-ID:` via
+`EchoPlatformUserIdContextProvider`, reading `HostedSessionContext.UserId` (from
+`x-agent-user-id`). The caller credential must be allowed to delegate via
+`x-ms-user-identity` or those tests fail with HTTP 403.
+
## Required environment variables
| Variable | Source | Purpose |
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
index 1b200e4296..33f4345af2 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
@@ -52,6 +52,7 @@ $Scenarios = @(
'azure-search-rag',
'session-files',
'agent-skills',
+ 'user-identity',
'unsupported-protocol'
)
From 235bb47d481370e65cb8a8e227e41cd4ac3715a2 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Fri, 14 Aug 2026 10:23:55 +0100
Subject: [PATCH 3/6] .NET: Reject Foundry hosted session switch when sticky
Persist sticky id in finally, clone run options before factory wrap,
validate whitespace pin on CreateHostedSessionAsync, and throw on
unexpected hosted session id change in the response. Docs: distinct
AgentSessions per user identity may share one sandbox.
---
.../FoundryAgent.cs | 5 +-
.../FoundryChatOptionsExtensions.cs | 11 ++-
.../FoundryHostedRequestAgent.cs | 36 +++++---
.../HostedSessionIdCapturePolicy.cs | 16 +++-
.../FoundryHostedRequestTests.cs | 90 ++++++++++++++++++-
5 files changed, 141 insertions(+), 17 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
index f15609b205..5da399f55c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
@@ -206,9 +206,10 @@ public async Task CreateHostedSessionAsync(
: await this.CreateSessionAsync(conversationId, cancellationToken).ConfigureAwait(false);
var typed = (ChatClientAgentSession)session;
- if (!string.IsNullOrWhiteSpace(hostedSessionId))
+ if (hostedSessionId is not null)
{
- typed.SetHostedAgentSessionId(hostedSessionId!);
+ // Non-null values are treated as an explicit pin attempt; whitespace is rejected by Set.
+ typed.SetHostedAgentSessionId(hostedSessionId);
}
return typed;
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
index 44a148db85..76215f52fc 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
@@ -71,8 +71,17 @@ public static ChatOptions WithHostedAgentSessionId(this ChatOptions options, str
/// Opaque application user identifier. Must be non-empty.
/// for fluent chaining.
///
+ ///
/// User identity is always request-scoped. It is never stored on .
- /// The same session (same sandbox) may be used with different identities across runs.
+ ///
+ ///
+ /// Different identities should use distinct instances. Reusing one
+ /// across identities also reuses its conversation /
+ /// previous-response trail, which is partitioned per user and can fail with a not-found error.
+ /// Those separate sessions may still be pinned to the same hosted sandbox id via
+ /// or
+ /// .
+ ///
///
public static ChatOptions WithUserIdentity(this ChatOptions options, string userIdentity)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
index d2bcba6f5b..4c593f466e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
@@ -35,9 +35,15 @@ protected override async Task RunCoreAsync(
CancellationToken cancellationToken = default)
{
var prepared = Prepare(session, options);
- var response = await this.InnerAgent.RunAsync(messages, session, prepared.Options, cancellationToken).ConfigureAwait(false);
- ApplySessionSticky(session, prepared.SessionIdBox);
- return response;
+ try
+ {
+ return await this.InnerAgent.RunAsync(messages, session, prepared.Options, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Persist any platform-captured hosted session id even when later agent processing fails.
+ ApplySessionSticky(session, prepared.SessionIdBox);
+ }
}
///
@@ -48,13 +54,18 @@ protected override async IAsyncEnumerable RunCoreStreamingA
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var prepared = Prepare(session, options);
-
- await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, prepared.Options, cancellationToken).ConfigureAwait(false))
+ try
{
- yield return update;
+ await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, prepared.Options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+ finally
+ {
+ // finally also runs when the consumer disposes the enumerator early.
+ ApplySessionSticky(session, prepared.SessionIdBox);
}
-
- ApplySessionSticky(session, prepared.SessionIdBox);
}
private static PreparedRun Prepare(AgentSession? session, AgentRunOptions? options)
@@ -97,9 +108,12 @@ private static ChatClientAgentRunOptions EnsureChatOptions(AgentRunOptions? opti
{
if (options is ChatClientAgentRunOptions existing)
{
- existing.ChatOptions ??= new ChatOptions();
- chatOptions = existing.ChatOptions;
- return existing;
+ // Clone so per-run RawRepresentationFactory wrapping does not mutate caller-owned options
+ // or stack factories when the same instance is reused across runs.
+ var clone = (ChatClientAgentRunOptions)existing.Clone();
+ clone.ChatOptions ??= new ChatOptions();
+ chatOptions = clone.ChatOptions;
+ return clone;
}
chatOptions = new ChatOptions();
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCapturePolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCapturePolicy.cs
index b15c820aa6..8d5597ca7c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCapturePolicy.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCapturePolicy.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -11,6 +12,10 @@ namespace Microsoft.Agents.AI.Foundry;
/// so subsequent service calls in the same run (and the
/// session sticky update after the run) see the platform-assigned hosted-agent session id.
///
+///
+/// When the scope already holds a pinned id, a different response id is rejected as an unexpected
+/// Foundry hosted session switch rather than silently overwriting the sticky value.
+///
internal sealed class HostedSessionIdCapturePolicy : PipelinePolicy
{
internal const string SessionIdHeader = "x-agent-session-id";
@@ -48,7 +53,16 @@ private static void Capture(PipelineMessage message)
if (message.Response.Headers.TryGetValue(SessionIdHeader, out string? sessionId)
&& !string.IsNullOrWhiteSpace(sessionId))
{
- box.Value = sessionId.Trim();
+ sessionId = sessionId.Trim();
+ if (!string.IsNullOrWhiteSpace(box.Value)
+ && !string.Equals(box.Value, sessionId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Unexpected Foundry hosted session switch. The run is pinned to hosted session '{box.Value}' " +
+ $"but the response returned '{sessionId}'.");
+ }
+
+ box.Value = sessionId;
}
}
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
index 26a369bdba..4c4247c827 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
@@ -63,6 +63,15 @@ public async Task CreateHostedSessionAsync_WithoutIds_LeavesBothEmptyAsync()
Assert.Null(session.ConversationId);
}
+ [Fact]
+ public async Task CreateHostedSessionAsync_WhitespaceHostedId_ThrowsAsync()
+ {
+ FoundryAgent agent = CreateFoundryAgent();
+
+ await Assert.ThrowsAsync(
+ () => agent.CreateHostedSessionAsync(hostedSessionId: " "));
+ }
+
[Fact]
public async Task Conflict_SessionAndOptionsHostedIdsDiffer_ThrowsAsync()
{
@@ -129,6 +138,9 @@ public async Task OptionsHostedId_WhenSessionEmpty_IsInjectedAndStickyAfterRunAs
[Fact]
public async Task UserIdentity_DifferentPerCall_OnSameSession_IsAllowedAsync()
{
+ // Pipeline still allows different identities on one AgentSession (request-scoped header).
+ // Live hosted agents should prefer distinct AgentSessions per identity to avoid conversation
+ // trail / previous_response_id partitioning issues; sandbox id may still be shared.
var seen = new List();
var inner = new ProbeAgent(onRun: _ => seen.Add(UserIdentityScope.Current));
var agent = new FoundryHostedRequestAgent(inner);
@@ -148,6 +160,43 @@ await agent.RunAsync(
Assert.Equal("sess-shared", session.GetHostedAgentSessionId());
}
+ [Fact]
+ public async Task ReusedRunOptions_DoesNotStackRawRepresentationFactoriesAsync()
+ {
+ CreateResponseOptions? first = null;
+ CreateResponseOptions? second = null;
+ int run = 0;
+ var inner = new ProbeAgent(onRun: options =>
+ {
+ if (options is not ChatClientAgentRunOptions { ChatOptions.RawRepresentationFactory: { } factory })
+ {
+ return;
+ }
+
+ var created = factory(null!) as CreateResponseOptions;
+ if (run++ == 0)
+ {
+ first = created;
+ }
+ else
+ {
+ second = created;
+ }
+ });
+ var agent = new FoundryHostedRequestAgent(inner);
+ var session = new TestSession();
+ session.SetHostedAgentSessionId("sess-shared");
+ var reused = new ChatClientAgentRunOptions(new ChatOptions());
+
+ await agent.RunAsync("hi", session, reused);
+ await agent.RunAsync("hi", session, reused);
+
+ Assert.NotNull(first);
+ Assert.NotNull(second);
+ Assert.NotSame(first, second);
+ Assert.Null(reused.ChatOptions!.RawRepresentationFactory);
+ }
+
[Fact]
public async Task EndToEnd_UserIdentity_AndHostedSessionId_ReachWireAsync()
{
@@ -182,14 +231,51 @@ public async Task EndToEnd_UserIdentity_AndHostedSessionId_ReachWireAsync()
.WithUserIdentity("alice")
.WithClientHeader("x-client-end-user-id", "alice-app"));
- await agent.RunAsync("hi", session, runOptions);
+ // Response returns a different hosted session id than the pin → unexpected switch.
+ InvalidOperationException ex = await Assert.ThrowsAsync(
+ () => agent.RunAsync("hi", session, runOptions));
+ Assert.Contains("Unexpected Foundry hosted session switch", ex.Message, StringComparison.Ordinal);
Assert.True(handler.Requests.Count > 0);
var req = handler.Requests[0];
Assert.Equal("alice", req.Headers[FoundryChatOptionsExtensions.UserIdentityHeaderName]);
Assert.Equal("alice-app", req.Headers["x-client-end-user-id"]);
Assert.Contains("\"agent_session_id\":\"sess-pinned\"", req.Body, StringComparison.Ordinal);
- Assert.Equal("sess-from-platform", session.GetHostedAgentSessionId());
+ // Sticky pin must not be overwritten by the conflicting response id.
+ Assert.Equal("sess-pinned", session.GetHostedAgentSessionId());
+ }
+
+ [Fact]
+ public async Task EndToEnd_PinnedHostedSessionId_MatchingResponseKeepsStickyAsync()
+ {
+ using var handler = new RecordingHandler(
+ MinimalResponseJson(),
+ responseHeaders: new Dictionary
+ {
+ ["x-agent-session-id"] = "sess-pinned",
+ });
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+ var openAIClient = new OpenAIClient(
+ new ApiKeyCredential("fake"),
+ new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
+ IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
+
+#pragma warning disable MEAI001
+ var policies = chatClient.GetService()!;
+ OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, HostedSessionIdCapturePolicy.Instance);
+#pragma warning restore MEAI001
+
+ var chatAgent = new ChatClientAgent(chatClient);
+ AIAgent agent = new FoundryHostedRequestAgent(chatAgent);
+ AgentSession session = await chatAgent.CreateSessionAsync();
+ session.SetHostedAgentSessionId("sess-pinned");
+
+ await agent.RunAsync("hi", session);
+
+ Assert.Equal("sess-pinned", session.GetHostedAgentSessionId());
+ Assert.Contains("\"agent_session_id\":\"sess-pinned\"", handler.Requests[0].Body, StringComparison.Ordinal);
}
[Fact]
From 9c3d704b7f1faccba05a1b00ad7245209c8cfd09 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Fri, 14 Aug 2026 10:27:57 +0100
Subject: [PATCH 4/6] .NET: Clear nested user identity and preserve run options
Always assign UserIdentityScope including null so nested runs do not
inherit a parent identity. When upgrading plain AgentRunOptions, keep
background, format, and additional properties on the specialized clone.
---
.../FoundryHostedRequestAgent.cs | 22 ++++++++---
.../FoundryHostedRequestTests.cs | 38 +++++++++++++++++++
2 files changed, 54 insertions(+), 6 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
index 4c593f466e..59ceeb0b1e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
@@ -95,11 +95,9 @@ The hosted-agent session id provided via ChatOptions is different from the id st
var effectiveOptions = EnsureChatOptions(options, out chatOptions);
AttachHostedSessionIdFactory(chatOptions, sessionIdBox);
- string? userIdentity = chatOptions.GetUserIdentity();
- if (!string.IsNullOrWhiteSpace(userIdentity))
- {
- UserIdentityScope.Current = userIdentity;
- }
+ // Always assign (including null) so a nested Foundry run that omits WithUserIdentity does not
+ // inherit a parent AsyncLocal identity and stamp the wrong x-ms-user-identity header.
+ UserIdentityScope.Current = chatOptions.GetUserIdentity();
return new PreparedRun(effectiveOptions, sessionIdBox);
}
@@ -117,7 +115,19 @@ private static ChatClientAgentRunOptions EnsureChatOptions(AgentRunOptions? opti
}
chatOptions = new ChatOptions();
- return new ChatClientAgentRunOptions(chatOptions);
+ var specialized = new ChatClientAgentRunOptions(chatOptions);
+ if (options is not null)
+ {
+ // Preserve base AgentRunOptions fields when upgrading a plain options instance.
+#pragma warning disable MEAI001 // ResponseContinuationToken is experimental
+ specialized.ContinuationToken = options.ContinuationToken;
+#pragma warning restore MEAI001
+ specialized.AllowBackgroundResponses = options.AllowBackgroundResponses;
+ specialized.ResponseFormat = options.ResponseFormat;
+ specialized.AdditionalProperties = options.AdditionalProperties?.Clone();
+ }
+
+ return specialized;
}
private static void AttachHostedSessionIdFactory(ChatOptions chatOptions, StrongBox sessionIdBox)
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
index 4c4247c827..57da69d35a 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
@@ -160,6 +160,44 @@ await agent.RunAsync(
Assert.Equal("sess-shared", session.GetHostedAgentSessionId());
}
+ [Fact]
+ public async Task UserIdentity_OmittedAfterParent_ClearsAsyncLocalScopeAsync()
+ {
+ var seen = new List();
+ var inner = new ProbeAgent(onRun: _ => seen.Add(UserIdentityScope.Current));
+ var agent = new FoundryHostedRequestAgent(inner);
+ var session = new TestSession();
+
+ await agent.RunAsync(
+ "hi",
+ session,
+ new ChatClientAgentRunOptions(new ChatOptions().WithUserIdentity("alice")));
+ await agent.RunAsync("hi", session, new ChatClientAgentRunOptions(new ChatOptions()));
+
+ Assert.Equal(["alice", null], seen);
+ }
+
+ [Fact]
+ public async Task PlainAgentRunOptions_PreservesBasePropertiesAsync()
+ {
+ AgentRunOptions? seen = null;
+ var inner = new ProbeAgent(onRun: o => seen = o);
+ var agent = new FoundryHostedRequestAgent(inner);
+#pragma warning disable MEAI001
+ var plain = new AgentRunOptions
+ {
+ AllowBackgroundResponses = true,
+ ResponseFormat = ChatResponseFormat.Text,
+ };
+#pragma warning restore MEAI001
+
+ await agent.RunAsync("hi", new TestSession(), plain);
+
+ var cro = Assert.IsType(seen);
+ Assert.True(cro.AllowBackgroundResponses);
+ Assert.Same(ChatResponseFormat.Text, cro.ResponseFormat);
+ }
+
[Fact]
public async Task ReusedRunOptions_DoesNotStackRawRepresentationFactoriesAsync()
{
From 5a396cf2f90e888e12fb4425eb8d2e076427e799 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Fri, 14 Aug 2026 10:32:31 +0100
Subject: [PATCH 5/6] .NET: Clarify previous_response_id user binding in docs
Align WithUserIdentity guidance with Foundry Learn multiplex docs:
response chains are bound to the creating user even inside a shared
hosted sandbox.
---
.../FoundryChatOptionsExtensions.cs | 13 ++++++++-----
.../FoundryHostedRequestTests.cs | 4 ++--
2 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
index 76215f52fc..47cfc61368 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
@@ -75,11 +75,14 @@ public static ChatOptions WithHostedAgentSessionId(this ChatOptions options, str
/// User identity is always request-scoped. It is never stored on .
///
///
- /// Different identities should use distinct instances. Reusing one
- /// across identities also reuses its conversation /
- /// previous-response trail, which is partitioned per user and can fail with a not-found error.
- /// Those separate sessions may still be pinned to the same hosted sandbox id via
- /// or
+ /// Per Foundry hosted-agent isolation, a Responses chain created under one user cannot be
+ /// continued by another user via previous_response_id, even when both calls share the
+ /// same hosted sandbox (agent_session_id). See
+ /// Multiplex multiple users in one hosted agent session.
+ /// Reusing one across identities typically reuses that chain, so the
+ /// second identity's run fails at the platform (observed as a response not-found error). Prefer
+ /// a distinct per identity; those sessions may still share one hosted
+ /// sandbox pin via or
/// .
///
///
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
index 57da69d35a..20a496cb15 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
@@ -139,8 +139,8 @@ public async Task OptionsHostedId_WhenSessionEmpty_IsInjectedAndStickyAfterRunAs
public async Task UserIdentity_DifferentPerCall_OnSameSession_IsAllowedAsync()
{
// Pipeline still allows different identities on one AgentSession (request-scoped header).
- // Live hosted agents should prefer distinct AgentSessions per identity to avoid conversation
- // trail / previous_response_id partitioning issues; sandbox id may still be shared.
+ // On a live hosted agent, Foundry binds previous_response_id chains to the creating user, so
+ // prefer distinct AgentSessions per identity; sandbox id may still be shared.
var seen = new List();
var inner = new ProbeAgent(onRun: _ => seen.Add(UserIdentityScope.Current));
var agent = new FoundryHostedRequestAgent(inner);
From e9190e337f6a894e6ee0c0c5449cda5e92f8b495 Mon Sep 17 00:00:00 2001
From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com>
Date: Fri, 14 Aug 2026 13:46:05 +0100
Subject: [PATCH 6/6] refactor(foundry): clarify hosted agent APIs
---
.../FoundryAgent.cs | 6 +-
.../FoundryAgentSessionExtensions.cs | 67 +++++++++++-------
.../FoundryChatOptionsExtensions.cs | 53 ++++++++------
.../FoundryHostedRequestAgent.cs | 12 ++--
.../HostedSessionAndUserIdentityTests.cs | 34 ++++-----
.../README.md | 5 +-
.../FoundryHostedRequestTests.cs | 70 +++++++++----------
7 files changed, 139 insertions(+), 108 deletions(-)
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
index 5da399f55c..cee29456f4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
@@ -172,7 +172,7 @@ public ValueTask CreateSessionAsync(string conversationId, Cancell
/// state. See
/// Sessions and conversations.
/// When set, it is stored in under
- /// and subsequent runs that
+ /// and subsequent runs that
/// reuse this session send agent_session_id automatically. When omitted, Foundry may create
/// a session on the first run and the returned id becomes sticky on this session.
///
@@ -196,7 +196,7 @@ public ValueTask CreateSessionAsync(string conversationId, Cancell
/// Hosted agents: sessions and conversations.
///
///
- public async Task CreateHostedSessionAsync(
+ public async Task CreateFoundryHostedAgentSessionAsync(
string? hostedSessionId = null,
string? conversationId = null,
CancellationToken cancellationToken = default)
@@ -209,7 +209,7 @@ public async Task CreateHostedSessionAsync(
if (hostedSessionId is not null)
{
// Non-null values are treated as an explicit pin attempt; whitespace is rejected by Set.
- typed.SetHostedAgentSessionId(hostedSessionId);
+ typed.FoundryHostedAgentSessionId = hostedSessionId;
}
return typed;
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
index 264b5f5ca6..7338b73c18 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
@@ -13,14 +13,14 @@ namespace Microsoft.Agents.AI;
///
///
/// The hosted-agent session id (sandbox / agent_session_id) is stored in
-/// under . That keeps
+/// under . That keeps
/// Foundry-specific state off the sealed type while still
/// serializing with the session.
///
///
/// This is not . Per-call
/// overrides use
-/// .
+/// .
///
///
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
@@ -29,30 +29,49 @@ public static class FoundryAgentSessionExtensions
///
/// Well-known key for the sticky hosted-agent session id.
///
- public const string HostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";
+ public const string FoundryHostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";
- ///
- /// Gets the sticky hosted-agent session id from , or
- /// if none is stored.
- ///
- ///
- /// Prefer creating/pinning via
- /// .
- /// This getter is for reading the id after the platform assigns one (or after an explicit pin).
- ///
- public static string? GetHostedAgentSessionId(this AgentSession session)
+ extension(AgentSession session)
{
- _ = Throw.IfNull(session);
- return session.StateBag.TryGetValue(HostedAgentSessionIdKey, out var value)
- ? value
- : null;
- }
+ ///
+ /// Gets the sticky Microsoft Foundry hosted-agent session id associated with this
+ /// Agent Framework session.
+ ///
+ ///
+ /// The Foundry agent_session_id, or when no hosted sandbox
+ /// has been pinned or captured yet.
+ ///
+ ///
+ ///
+ /// This id identifies the Foundry-managed hosted-agent sandbox: its compute, persisted
+ /// $HOME, and files. It is separate from
+ /// , which identifies conversation
+ /// history.
+ ///
+ ///
+ /// Prefer creating or pinning through
+ /// .
+ /// The property is populated automatically when Foundry creates a sandbox on first use.
+ /// See
+ /// Manage hosted agent sessions.
+ ///
+ ///
+ public string? FoundryHostedAgentSessionId
+ {
+ get
+ {
+ _ = Throw.IfNull(session);
+ return session.StateBag.TryGetValue(FoundryHostedAgentSessionIdKey, out var value)
+ ? value
+ : null;
+ }
- /// Sets the sticky hosted-agent session id on .
- internal static void SetHostedAgentSessionId(this AgentSession session, string hostedSessionId)
- {
- _ = Throw.IfNull(session);
- _ = Throw.IfNullOrWhitespace(hostedSessionId);
- session.StateBag.SetValue(HostedAgentSessionIdKey, hostedSessionId);
+ internal set
+ {
+ _ = Throw.IfNull(session);
+ _ = Throw.IfNullOrWhitespace(value);
+ session.StateBag.SetValue(FoundryHostedAgentSessionIdKey, value);
+ }
+ }
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
index 47cfc61368..8f6f00674e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
@@ -15,51 +15,59 @@ namespace Microsoft.Extensions.AI;
///
/// Use these helpers to attach per-call Foundry request fields:
///
-/// - sends agent_session_id on the Responses body.
-/// - sends x-ms-user-identity on the request.
+/// - sends agent_session_id on the Responses body.
+/// - sends x-ms-user-identity on the request.
///
///
///
-/// Hosted-agent session ids supplied via participate in the same
+/// Hosted-agent session ids supplied via participate in the same
/// conflict rule as : if the already
/// holds a different hosted id in its , the run throws
/// . Prefer pinning at session creation via
-/// .
+/// .
///
///
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
public static class FoundryChatOptionsExtensions
{
/// HTTP header name for delegated application user identity.
- public const string UserIdentityHeaderName = "x-ms-user-identity";
+ public const string FoundryHostedAgentUserIdentityHeaderName = "x-ms-user-identity";
///
/// Well-known key used to carry a per-call
/// hosted-agent session id.
///
- internal const string HostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";
+ internal const string FoundryHostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";
///
/// Well-known key used to carry the per-call
/// user identity value.
///
- internal const string UserIdentityKey = "Microsoft.Agents.AI.Foundry.UserIdentity";
+ internal const string FoundryHostedAgentUserIdentityKey = "Microsoft.Agents.AI.Foundry.UserIdentity";
///
/// Attaches a hosted-agent session id to the per-call carrier.
///
///
+ ///
/// Only valid when the run's session has no hosted id yet, or already has this same id.
- /// Prefer
+ /// Prefer
+ ///
/// to pin at session creation.
+ ///
+ ///
+ /// The value is stored in . Replacing that
+ /// dictionary after calling this method removes the value; populate or replace the dictionary
+ /// first, then call this method.
+ ///
///
- public static ChatOptions WithHostedAgentSessionId(this ChatOptions options, string hostedSessionId)
+ public static ChatOptions WithFoundryHostedAgentSessionId(this ChatOptions options, string hostedSessionId)
{
_ = Throw.IfNull(options);
_ = Throw.IfNullOrWhitespace(hostedSessionId);
options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
- options.AdditionalProperties[HostedAgentSessionIdKey] = hostedSessionId;
+ options.AdditionalProperties[FoundryHostedAgentSessionIdKey] = hostedSessionId;
return options;
}
@@ -82,29 +90,34 @@ public static ChatOptions WithHostedAgentSessionId(this ChatOptions options, str
/// Reusing one across identities typically reuses that chain, so the
/// second identity's run fails at the platform (observed as a response not-found error). Prefer
/// a distinct per identity; those sessions may still share one hosted
- /// sandbox pin via or
- /// .
+ /// sandbox pin via or
+ /// .
+ ///
+ ///
+ /// The value is stored in . Replacing that
+ /// dictionary after calling this method removes the value; populate or replace the dictionary
+ /// first, then call this method.
///
///
- public static ChatOptions WithUserIdentity(this ChatOptions options, string userIdentity)
+ public static ChatOptions WithFoundryHostedAgentUserIdentity(this ChatOptions options, string userIdentity)
{
_ = Throw.IfNull(options);
_ = Throw.IfNullOrWhitespace(userIdentity);
options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
- options.AdditionalProperties[UserIdentityKey] = userIdentity;
+ options.AdditionalProperties[FoundryHostedAgentUserIdentityKey] = userIdentity;
return options;
}
- /// Reads the per-call hosted-agent session id stamped by .
- internal static string? GetHostedAgentSessionId(this ChatOptions options)
+ /// Reads the per-call hosted-agent session id stamped by .
+ internal static string? GetFoundryHostedAgentSessionId(this ChatOptions options)
{
if (options.AdditionalProperties is null)
{
return null;
}
- if (!options.AdditionalProperties.TryGetValue(HostedAgentSessionIdKey, out var raw))
+ if (!options.AdditionalProperties.TryGetValue(FoundryHostedAgentSessionIdKey, out var raw))
{
return null;
}
@@ -112,15 +125,15 @@ public static ChatOptions WithUserIdentity(this ChatOptions options, string user
return raw as string;
}
- /// Reads the per-call user identity stamped by .
- internal static string? GetUserIdentity(this ChatOptions options)
+ /// Reads the per-call user identity stamped by .
+ internal static string? GetFoundryHostedAgentUserIdentity(this ChatOptions options)
{
if (options.AdditionalProperties is null)
{
return null;
}
- if (!options.AdditionalProperties.TryGetValue(UserIdentityKey, out var raw))
+ if (!options.AdditionalProperties.TryGetValue(FoundryHostedAgentUserIdentityKey, out var raw))
{
return null;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
index 59ceeb0b1e..7b4dabd4f1 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
@@ -72,8 +72,8 @@ private static PreparedRun Prepare(AgentSession? session, AgentRunOptions? optio
{
ChatOptions? chatOptions = options is ChatClientAgentRunOptions cro ? cro.ChatOptions : null;
- string? sessionHostedId = session?.GetHostedAgentSessionId();
- string? optionsHostedId = chatOptions?.GetHostedAgentSessionId();
+ string? sessionHostedId = session?.FoundryHostedAgentSessionId;
+ string? optionsHostedId = chatOptions?.GetFoundryHostedAgentSessionId();
if (!string.IsNullOrWhiteSpace(sessionHostedId)
&& !string.IsNullOrWhiteSpace(optionsHostedId)
@@ -95,9 +95,9 @@ The hosted-agent session id provided via ChatOptions is different from the id st
var effectiveOptions = EnsureChatOptions(options, out chatOptions);
AttachHostedSessionIdFactory(chatOptions, sessionIdBox);
- // Always assign (including null) so a nested Foundry run that omits WithUserIdentity does not
- // inherit a parent AsyncLocal identity and stamp the wrong x-ms-user-identity header.
- UserIdentityScope.Current = chatOptions.GetUserIdentity();
+ // Always assign (including null) so a nested Foundry run that omits the per-call Foundry
+ // user identity does not inherit a parent AsyncLocal value and stamp the wrong header.
+ UserIdentityScope.Current = chatOptions.GetFoundryHostedAgentUserIdentity();
return new PreparedRun(effectiveOptions, sessionIdBox);
}
@@ -158,7 +158,7 @@ private static void ApplySessionSticky(AgentSession? session, StrongBox
return;
}
- session.SetHostedAgentSessionId(sessionIdBox.Value!);
+ session.FoundryHostedAgentSessionId = sessionIdBox.Value!;
}
private sealed class PreparedRun
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs
index 0e81420c35..3ea40e425a 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs
@@ -52,8 +52,8 @@ public async Task ServiceManagedSession_BecomesStickyAndIsReusedAsync()
{
// Arrange
FoundryAgent agent = this.CreateFoundryAgent();
- ChatClientAgentSession session = await agent.CreateHostedSessionAsync();
- Assert.Null(session.GetHostedAgentSessionId());
+ ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync();
+ Assert.Null(session.FoundryHostedAgentSessionId);
string? hostedSessionId = null;
try
@@ -62,7 +62,7 @@ public async Task ServiceManagedSession_BecomesStickyAndIsReusedAsync()
var first = await agent.RunAsync("Reply with the single word ready.", session);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
- hostedSessionId = session.GetHostedAgentSessionId();
+ hostedSessionId = session.FoundryHostedAgentSessionId;
Assert.False(string.IsNullOrWhiteSpace(hostedSessionId));
// Act: second run reuses the same AgentSession and must keep the same sticky id.
@@ -70,7 +70,7 @@ public async Task ServiceManagedSession_BecomesStickyAndIsReusedAsync()
Assert.False(string.IsNullOrWhiteSpace(second.Text));
// Assert
- Assert.Equal(hostedSessionId, session.GetHostedAgentSessionId());
+ Assert.Equal(hostedSessionId, session.FoundryHostedAgentSessionId);
}
finally
{
@@ -91,8 +91,8 @@ public async Task UserManagedSession_PinIsStickyAndMatchesAdminSessionAsync()
await WaitForSessionActiveAsync(admin, this._fixture.AgentName, hostedSessionId);
FoundryAgent agent = this.CreateFoundryAgent();
- ChatClientAgentSession session = await agent.CreateHostedSessionAsync(hostedSessionId: hostedSessionId);
- Assert.Equal(hostedSessionId, session.GetHostedAgentSessionId());
+ ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync(hostedSessionId: hostedSessionId);
+ Assert.Equal(hostedSessionId, session.FoundryHostedAgentSessionId);
try
{
@@ -101,7 +101,7 @@ public async Task UserManagedSession_PinIsStickyAndMatchesAdminSessionAsync()
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
- Assert.Equal(hostedSessionId, session.GetHostedAgentSessionId());
+ Assert.Equal(hostedSessionId, session.FoundryHostedAgentSessionId);
}
finally
{
@@ -122,22 +122,22 @@ public async Task SameHostedSandbox_DifferentAgentSessionsAndUserIdentities_Yiel
try
{
// Act: alice creates the sandbox via service-managed sticky capture.
- ChatClientAgentSession aliceSession = await agent.CreateHostedSessionAsync();
+ ChatClientAgentSession aliceSession = await agent.CreateFoundryHostedAgentSessionAsync();
string aliceUserId = await this.RunAndReadUserIdAsync(agent, aliceSession, "alice-it");
- hostedSessionId = aliceSession.GetHostedAgentSessionId();
+ hostedSessionId = aliceSession.FoundryHostedAgentSessionId;
Assert.False(string.IsNullOrWhiteSpace(hostedSessionId));
string? aliceConversationId = aliceSession.ConversationId;
// Act: bob gets a fresh AgentSession pinned to the same hosted sandbox.
- ChatClientAgentSession bobSession = await agent.CreateHostedSessionAsync(hostedSessionId: hostedSessionId);
+ ChatClientAgentSession bobSession = await agent.CreateFoundryHostedAgentSessionAsync(hostedSessionId: hostedSessionId);
Assert.NotSame(aliceSession, bobSession);
- Assert.Equal(hostedSessionId, bobSession.GetHostedAgentSessionId());
+ Assert.Equal(hostedSessionId, bobSession.FoundryHostedAgentSessionId);
string bobUserId = await this.RunAndReadUserIdAsync(agent, bobSession, "bob-it");
// Assert: hosted sandbox stays the same on both sessions after bob's response.
- Assert.Equal(hostedSessionId, aliceSession.GetHostedAgentSessionId());
- Assert.Equal(hostedSessionId, bobSession.GetHostedAgentSessionId());
+ Assert.Equal(hostedSessionId, aliceSession.FoundryHostedAgentSessionId);
+ Assert.Equal(hostedSessionId, bobSession.FoundryHostedAgentSessionId);
// Assert: conversation trails stay independent (must not share ConversationId).
string? bobConversationId = bobSession.ConversationId;
@@ -167,19 +167,19 @@ public async Task SameSession_SameUserIdentity_YieldsStablePlatformUserIdAsync()
{
// Arrange
FoundryAgent agent = this.CreateFoundryAgent();
- ChatClientAgentSession session = await agent.CreateHostedSessionAsync();
+ ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync();
string? hostedSessionId = null;
try
{
// Act
string first = await this.RunAndReadUserIdAsync(agent, session, "stable-user-it");
- hostedSessionId = session.GetHostedAgentSessionId();
+ hostedSessionId = session.FoundryHostedAgentSessionId;
string second = await this.RunAndReadUserIdAsync(agent, session, "stable-user-it");
// Assert
Assert.False(string.IsNullOrWhiteSpace(hostedSessionId));
- Assert.Equal(hostedSessionId, session.GetHostedAgentSessionId());
+ Assert.Equal(hostedSessionId, session.FoundryHostedAgentSessionId);
Assert.NotEqual("missing", first);
Assert.Equal(first, second);
}
@@ -192,7 +192,7 @@ public async Task SameSession_SameUserIdentity_YieldsStablePlatformUserIdAsync()
private async Task RunAndReadUserIdAsync(FoundryAgent agent, AgentSession session, string userIdentity)
{
var options = new ChatClientAgentRunOptions(
- new ChatOptions().WithUserIdentity(userIdentity));
+ new ChatOptions().WithFoundryHostedAgentUserIdentity(userIdentity));
var response = await agent.RunAsync(
"Acknowledge the request briefly.",
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
index d25ef0f2d0..9828f0350c 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
@@ -36,9 +36,9 @@ etc.).
`HostedSessionAndUserIdentityTests` (fixture `UserIdentityHostedAgentFixture`, agent
`it-user-identity`) exercises the client-side `FoundryAgent` APIs:
-- `CreateHostedSessionAsync` sticky hosted `agent_session_id` (service-managed and
+- `CreateFoundryHostedAgentSessionAsync` sticky hosted `agent_session_id` (service-managed and
admin `CreateSession` / `DeleteSession` pin)
-- per-call `ChatOptions.WithUserIdentity` (`x-ms-user-identity`) producing distinct
+- per-call `ChatOptions.WithFoundryHostedAgentUserIdentity` (`x-ms-user-identity`) producing distinct
platform user keys inside the container
The container scenario injects `USER-ID:` via
@@ -237,4 +237,3 @@ human-only operation; CI only adds and deletes versions under existing agents.
The scenarios marked (placeholder) are already wired into the test container `Program.cs`,
but their assertions stay skipped pending live validation and stabilization of the relevant
`Microsoft.Agents.AI.Foundry.Hosting` API surfaces.
-
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
index 20a496cb15..3dc2e738f4 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
@@ -24,52 +24,52 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests;
public sealed class FoundryHostedRequestTests
{
[Fact]
- public void WithHostedAgentSessionId_WritesOptionsCarrier()
+ public void WithFoundryHostedAgentSessionId_WritesOptionsCarrier()
{
var options = new ChatOptions();
- options.WithHostedAgentSessionId("sess-1");
- Assert.Equal("sess-1", options.GetHostedAgentSessionId());
+ options.WithFoundryHostedAgentSessionId("sess-1");
+ Assert.Equal("sess-1", options.GetFoundryHostedAgentSessionId());
}
[Fact]
- public void WithUserIdentity_WritesOptionsCarrier()
+ public void WithFoundryHostedAgentUserIdentity_WritesOptionsCarrier()
{
var options = new ChatOptions();
- options.WithUserIdentity("alice");
- Assert.Equal("alice", options.GetUserIdentity());
+ options.WithFoundryHostedAgentUserIdentity("alice");
+ Assert.Equal("alice", options.GetFoundryHostedAgentUserIdentity());
}
[Fact]
- public async Task CreateHostedSessionAsync_PinsHostedAndConversationIdsAsync()
+ public async Task CreateFoundryHostedAgentSessionAsync_PinsHostedAndConversationIdsAsync()
{
FoundryAgent agent = CreateFoundryAgent();
- ChatClientAgentSession session = await agent.CreateHostedSessionAsync(
+ ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync(
hostedSessionId: "sess-1",
conversationId: "conv-1");
- Assert.Equal("sess-1", session.GetHostedAgentSessionId());
+ Assert.Equal("sess-1", session.FoundryHostedAgentSessionId);
Assert.Equal("conv-1", session.ConversationId);
- Assert.True(session.StateBag.TryGetValue(FoundryAgentSessionExtensions.HostedAgentSessionIdKey, out var raw));
+ Assert.True(session.StateBag.TryGetValue(FoundryAgentSessionExtensions.FoundryHostedAgentSessionIdKey, out var raw));
Assert.Equal("sess-1", raw);
}
[Fact]
- public async Task CreateHostedSessionAsync_WithoutIds_LeavesBothEmptyAsync()
+ public async Task CreateFoundryHostedAgentSessionAsync_WithoutIds_LeavesBothEmptyAsync()
{
FoundryAgent agent = CreateFoundryAgent();
- ChatClientAgentSession session = await agent.CreateHostedSessionAsync();
+ ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync();
- Assert.Null(session.GetHostedAgentSessionId());
+ Assert.Null(session.FoundryHostedAgentSessionId);
Assert.Null(session.ConversationId);
}
[Fact]
- public async Task CreateHostedSessionAsync_WhitespaceHostedId_ThrowsAsync()
+ public async Task CreateFoundryHostedAgentSessionAsync_WhitespaceHostedId_ThrowsAsync()
{
FoundryAgent agent = CreateFoundryAgent();
await Assert.ThrowsAsync(
- () => agent.CreateHostedSessionAsync(hostedSessionId: " "));
+ () => agent.CreateFoundryHostedAgentSessionAsync(hostedSessionId: " "));
}
[Fact]
@@ -78,9 +78,9 @@ public async Task Conflict_SessionAndOptionsHostedIdsDiffer_ThrowsAsync()
var inner = new ProbeAgent();
var agent = new FoundryHostedRequestAgent(inner);
var session = new TestSession();
- session.SetHostedAgentSessionId("sess-A");
+ session.FoundryHostedAgentSessionId = "sess-A";
var runOptions = new ChatClientAgentRunOptions(
- new ChatOptions().WithHostedAgentSessionId("sess-B"));
+ new ChatOptions().WithFoundryHostedAgentSessionId("sess-B"));
InvalidOperationException ex = await Assert.ThrowsAsync(
() => agent.RunAsync("hi", session, runOptions));
@@ -93,9 +93,9 @@ public async Task SameHostedId_OnSessionAndOptions_DoesNotThrowAsync()
var inner = new ProbeAgent();
var agent = new FoundryHostedRequestAgent(inner);
var session = new TestSession();
- session.SetHostedAgentSessionId("sess-A");
+ session.FoundryHostedAgentSessionId = "sess-A";
var runOptions = new ChatClientAgentRunOptions(
- new ChatOptions().WithHostedAgentSessionId("sess-A"));
+ new ChatOptions().WithFoundryHostedAgentSessionId("sess-A"));
await agent.RunAsync("hi", session, runOptions);
Assert.Equal(1, inner.RunCount);
@@ -114,7 +114,7 @@ public async Task Sticky_SessionHostedId_IsInjectedIntoCreateResponseOptionsAsyn
});
var agent = new FoundryHostedRequestAgent(inner);
var session = new TestSession();
- session.SetHostedAgentSessionId("sess-sticky");
+ session.FoundryHostedAgentSessionId = "sess-sticky";
await agent.RunAsync("hi", session);
@@ -129,10 +129,10 @@ public async Task OptionsHostedId_WhenSessionEmpty_IsInjectedAndStickyAfterRunAs
var agent = new FoundryHostedRequestAgent(inner);
var session = new TestSession();
var runOptions = new ChatClientAgentRunOptions(
- new ChatOptions().WithHostedAgentSessionId("sess-options"));
+ new ChatOptions().WithFoundryHostedAgentSessionId("sess-options"));
await agent.RunAsync("hi", session, runOptions);
- Assert.Equal("sess-options", session.GetHostedAgentSessionId());
+ Assert.Equal("sess-options", session.FoundryHostedAgentSessionId);
}
[Fact]
@@ -145,19 +145,19 @@ public async Task UserIdentity_DifferentPerCall_OnSameSession_IsAllowedAsync()
var inner = new ProbeAgent(onRun: _ => seen.Add(UserIdentityScope.Current));
var agent = new FoundryHostedRequestAgent(inner);
var session = new TestSession();
- session.SetHostedAgentSessionId("sess-shared");
+ session.FoundryHostedAgentSessionId = "sess-shared";
await agent.RunAsync(
"hi",
session,
- new ChatClientAgentRunOptions(new ChatOptions().WithUserIdentity("alice")));
+ new ChatClientAgentRunOptions(new ChatOptions().WithFoundryHostedAgentUserIdentity("alice")));
await agent.RunAsync(
"hi",
session,
- new ChatClientAgentRunOptions(new ChatOptions().WithUserIdentity("bob")));
+ new ChatClientAgentRunOptions(new ChatOptions().WithFoundryHostedAgentUserIdentity("bob")));
Assert.Equal(["alice", "bob"], seen);
- Assert.Equal("sess-shared", session.GetHostedAgentSessionId());
+ Assert.Equal("sess-shared", session.FoundryHostedAgentSessionId);
}
[Fact]
@@ -171,7 +171,7 @@ public async Task UserIdentity_OmittedAfterParent_ClearsAsyncLocalScopeAsync()
await agent.RunAsync(
"hi",
session,
- new ChatClientAgentRunOptions(new ChatOptions().WithUserIdentity("alice")));
+ new ChatClientAgentRunOptions(new ChatOptions().WithFoundryHostedAgentUserIdentity("alice")));
await agent.RunAsync("hi", session, new ChatClientAgentRunOptions(new ChatOptions()));
Assert.Equal(["alice", null], seen);
@@ -223,7 +223,7 @@ public async Task ReusedRunOptions_DoesNotStackRawRepresentationFactoriesAsync()
});
var agent = new FoundryHostedRequestAgent(inner);
var session = new TestSession();
- session.SetHostedAgentSessionId("sess-shared");
+ session.FoundryHostedAgentSessionId = "sess-shared";
var reused = new ChatClientAgentRunOptions(new ChatOptions());
await agent.RunAsync("hi", session, reused);
@@ -262,11 +262,11 @@ public async Task EndToEnd_UserIdentity_AndHostedSessionId_ReachWireAsync()
var chatAgent = new ChatClientAgent(chatClient);
AIAgent agent = new FoundryHostedRequestAgent(new ClientHeadersAgent(chatAgent));
AgentSession session = await chatAgent.CreateSessionAsync();
- session.SetHostedAgentSessionId("sess-pinned");
+ session.FoundryHostedAgentSessionId = "sess-pinned";
var runOptions = new ChatClientAgentRunOptions(
new ChatOptions()
- .WithUserIdentity("alice")
+ .WithFoundryHostedAgentUserIdentity("alice")
.WithClientHeader("x-client-end-user-id", "alice-app"));
// Response returns a different hosted session id than the pin → unexpected switch.
@@ -276,11 +276,11 @@ public async Task EndToEnd_UserIdentity_AndHostedSessionId_ReachWireAsync()
Assert.True(handler.Requests.Count > 0);
var req = handler.Requests[0];
- Assert.Equal("alice", req.Headers[FoundryChatOptionsExtensions.UserIdentityHeaderName]);
+ Assert.Equal("alice", req.Headers[FoundryChatOptionsExtensions.FoundryHostedAgentUserIdentityHeaderName]);
Assert.Equal("alice-app", req.Headers["x-client-end-user-id"]);
Assert.Contains("\"agent_session_id\":\"sess-pinned\"", req.Body, StringComparison.Ordinal);
// Sticky pin must not be overwritten by the conflicting response id.
- Assert.Equal("sess-pinned", session.GetHostedAgentSessionId());
+ Assert.Equal("sess-pinned", session.FoundryHostedAgentSessionId);
}
[Fact]
@@ -308,11 +308,11 @@ public async Task EndToEnd_PinnedHostedSessionId_MatchingResponseKeepsStickyAsyn
var chatAgent = new ChatClientAgent(chatClient);
AIAgent agent = new FoundryHostedRequestAgent(chatAgent);
AgentSession session = await chatAgent.CreateSessionAsync();
- session.SetHostedAgentSessionId("sess-pinned");
+ session.FoundryHostedAgentSessionId = "sess-pinned";
await agent.RunAsync("hi", session);
- Assert.Equal("sess-pinned", session.GetHostedAgentSessionId());
+ Assert.Equal("sess-pinned", session.FoundryHostedAgentSessionId);
Assert.Contains("\"agent_session_id\":\"sess-pinned\"", handler.Requests[0].Body, StringComparison.Ordinal);
}
@@ -344,7 +344,7 @@ public async Task EndToEnd_ServiceManaged_CapturesHostedSessionIdOntoSessionAsyn
await agent.RunAsync("hi", session);
- Assert.Equal("sess-created", session.GetHostedAgentSessionId());
+ Assert.Equal("sess-created", session.FoundryHostedAgentSessionId);
Assert.DoesNotContain("agent_session_id", handler.Requests[0].Body, StringComparison.Ordinal);
}