diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
index 59df24c045..cee29456f4 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,59 @@ 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 CreateFoundryHostedAgentSessionAsync(
+ 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 (hostedSessionId is not null)
+ {
+ // Non-null values are treated as an explicit pin attempt; whitespace is rejected by Set.
+ typed.FoundryHostedAgentSessionId = hostedSessionId;
+ }
+
+ return typed;
+ }
+
///
/// Creates a server-side conversation session that appears in the Foundry Project UI.
///
@@ -240,23 +293,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 +316,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 +372,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 +405,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..7338b73c18
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs
@@ -0,0 +1,77 @@
+// 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 FoundryHostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";
+
+ extension(AgentSession session)
+ {
+ ///
+ /// 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;
+ }
+
+ 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
new file mode 100644
index 0000000000..8f6f00674e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs
@@ -0,0 +1,143 @@
+// 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 FoundryHostedAgentUserIdentityHeaderName = "x-ms-user-identity";
+
+ ///
+ /// Well-known key used to carry a per-call
+ /// hosted-agent session id.
+ ///
+ internal const string FoundryHostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId";
+
+ ///
+ /// Well-known key used to carry the per-call
+ /// user identity value.
+ ///
+ 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
+ ///
+ /// 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 WithFoundryHostedAgentSessionId(this ChatOptions options, string hostedSessionId)
+ {
+ _ = Throw.IfNull(options);
+ _ = Throw.IfNullOrWhitespace(hostedSessionId);
+
+ options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
+ options.AdditionalProperties[FoundryHostedAgentSessionIdKey] = 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 .
+ ///
+ ///
+ /// 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
+ /// .
+ ///
+ ///
+ /// 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 WithFoundryHostedAgentUserIdentity(this ChatOptions options, string userIdentity)
+ {
+ _ = Throw.IfNull(options);
+ _ = Throw.IfNullOrWhitespace(userIdentity);
+
+ options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
+ options.AdditionalProperties[FoundryHostedAgentUserIdentityKey] = userIdentity;
+ return 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(FoundryHostedAgentSessionIdKey, out var raw))
+ {
+ return null;
+ }
+
+ return raw as string;
+ }
+
+ /// 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(FoundryHostedAgentUserIdentityKey, 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..7b4dabd4f1
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs
@@ -0,0 +1,175 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+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);
+ 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);
+ }
+ }
+
+ ///
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var prepared = Prepare(session, options);
+ try
+ {
+ 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);
+ }
+ }
+
+ private static PreparedRun Prepare(AgentSession? session, AgentRunOptions? options)
+ {
+ ChatOptions? chatOptions = options is ChatClientAgentRunOptions cro ? cro.ChatOptions : null;
+
+ string? sessionHostedId = session?.FoundryHostedAgentSessionId;
+ string? optionsHostedId = chatOptions?.GetFoundryHostedAgentSessionId();
+
+ 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);
+
+ // 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);
+ }
+
+ private static ChatClientAgentRunOptions EnsureChatOptions(AgentRunOptions? options, out ChatOptions chatOptions)
+ {
+ if (options is ChatClientAgentRunOptions 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();
+ 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)
+ {
+ 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.FoundryHostedAgentSessionId = 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..8d5597ca7c
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedSessionIdCapturePolicy.cs
@@ -0,0 +1,68 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+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.
+///
+///
+/// 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";
+
+ 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))
+ {
+ 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/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/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..3ea40e425a
--- /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.CreateFoundryHostedAgentSessionAsync();
+ Assert.Null(session.FoundryHostedAgentSessionId);
+
+ 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.FoundryHostedAgentSessionId;
+ 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.FoundryHostedAgentSessionId);
+ }
+ 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.CreateFoundryHostedAgentSessionAsync(hostedSessionId: hostedSessionId);
+ Assert.Equal(hostedSessionId, session.FoundryHostedAgentSessionId);
+
+ 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.FoundryHostedAgentSessionId);
+ }
+ 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.CreateFoundryHostedAgentSessionAsync();
+ string aliceUserId = await this.RunAndReadUserIdAsync(agent, aliceSession, "alice-it");
+ 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.CreateFoundryHostedAgentSessionAsync(hostedSessionId: hostedSessionId);
+ Assert.NotSame(aliceSession, bobSession);
+ 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.FoundryHostedAgentSessionId);
+ Assert.Equal(hostedSessionId, bobSession.FoundryHostedAgentSessionId);
+
+ // 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.CreateFoundryHostedAgentSessionAsync();
+ string? hostedSessionId = null;
+
+ try
+ {
+ // Act
+ string first = await this.RunAndReadUserIdAsync(agent, session, "stable-user-it");
+ hostedSessionId = session.FoundryHostedAgentSessionId;
+ string second = await this.RunAndReadUserIdAsync(agent, session, "stable-user-it");
+
+ // Assert
+ Assert.False(string.IsNullOrWhiteSpace(hostedSessionId));
+ Assert.Equal(hostedSessionId, session.FoundryHostedAgentSessionId);
+ 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().WithFoundryHostedAgentUserIdentity(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..9828f0350c 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:
+
+- `CreateFoundryHostedAgentSessionAsync` sticky hosted `agent_session_id` (service-managed and
+ admin `CreateSession` / `DeleteSession` pin)
+- per-call `ChatOptions.WithFoundryHostedAgentUserIdentity` (`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 |
@@ -222,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/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'
)
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..3dc2e738f4
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs
@@ -0,0 +1,481 @@
+// 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 WithFoundryHostedAgentSessionId_WritesOptionsCarrier()
+ {
+ var options = new ChatOptions();
+ options.WithFoundryHostedAgentSessionId("sess-1");
+ Assert.Equal("sess-1", options.GetFoundryHostedAgentSessionId());
+ }
+
+ [Fact]
+ public void WithFoundryHostedAgentUserIdentity_WritesOptionsCarrier()
+ {
+ var options = new ChatOptions();
+ options.WithFoundryHostedAgentUserIdentity("alice");
+ Assert.Equal("alice", options.GetFoundryHostedAgentUserIdentity());
+ }
+
+ [Fact]
+ public async Task CreateFoundryHostedAgentSessionAsync_PinsHostedAndConversationIdsAsync()
+ {
+ FoundryAgent agent = CreateFoundryAgent();
+ ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync(
+ hostedSessionId: "sess-1",
+ conversationId: "conv-1");
+
+ Assert.Equal("sess-1", session.FoundryHostedAgentSessionId);
+ Assert.Equal("conv-1", session.ConversationId);
+ Assert.True(session.StateBag.TryGetValue(FoundryAgentSessionExtensions.FoundryHostedAgentSessionIdKey, out var raw));
+ Assert.Equal("sess-1", raw);
+ }
+
+ [Fact]
+ public async Task CreateFoundryHostedAgentSessionAsync_WithoutIds_LeavesBothEmptyAsync()
+ {
+ FoundryAgent agent = CreateFoundryAgent();
+ ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync();
+
+ Assert.Null(session.FoundryHostedAgentSessionId);
+ Assert.Null(session.ConversationId);
+ }
+
+ [Fact]
+ public async Task CreateFoundryHostedAgentSessionAsync_WhitespaceHostedId_ThrowsAsync()
+ {
+ FoundryAgent agent = CreateFoundryAgent();
+
+ await Assert.ThrowsAsync(
+ () => agent.CreateFoundryHostedAgentSessionAsync(hostedSessionId: " "));
+ }
+
+ [Fact]
+ public async Task Conflict_SessionAndOptionsHostedIdsDiffer_ThrowsAsync()
+ {
+ var inner = new ProbeAgent();
+ var agent = new FoundryHostedRequestAgent(inner);
+ var session = new TestSession();
+ session.FoundryHostedAgentSessionId = "sess-A";
+ var runOptions = new ChatClientAgentRunOptions(
+ new ChatOptions().WithFoundryHostedAgentSessionId("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.FoundryHostedAgentSessionId = "sess-A";
+ var runOptions = new ChatClientAgentRunOptions(
+ new ChatOptions().WithFoundryHostedAgentSessionId("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.FoundryHostedAgentSessionId = "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().WithFoundryHostedAgentSessionId("sess-options"));
+
+ await agent.RunAsync("hi", session, runOptions);
+ Assert.Equal("sess-options", session.FoundryHostedAgentSessionId);
+ }
+
+ [Fact]
+ public async Task UserIdentity_DifferentPerCall_OnSameSession_IsAllowedAsync()
+ {
+ // Pipeline still allows different identities on one AgentSession (request-scoped header).
+ // 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);
+ var session = new TestSession();
+ session.FoundryHostedAgentSessionId = "sess-shared";
+
+ await agent.RunAsync(
+ "hi",
+ session,
+ new ChatClientAgentRunOptions(new ChatOptions().WithFoundryHostedAgentUserIdentity("alice")));
+ await agent.RunAsync(
+ "hi",
+ session,
+ new ChatClientAgentRunOptions(new ChatOptions().WithFoundryHostedAgentUserIdentity("bob")));
+
+ Assert.Equal(["alice", "bob"], seen);
+ Assert.Equal("sess-shared", session.FoundryHostedAgentSessionId);
+ }
+
+ [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().WithFoundryHostedAgentUserIdentity("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()
+ {
+ 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.FoundryHostedAgentSessionId = "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()
+ {
+ 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.FoundryHostedAgentSessionId = "sess-pinned";
+
+ var runOptions = new ChatClientAgentRunOptions(
+ new ChatOptions()
+ .WithFoundryHostedAgentUserIdentity("alice")
+ .WithClientHeader("x-client-end-user-id", "alice-app"));
+
+ // 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.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.FoundryHostedAgentSessionId);
+ }
+
+ [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.FoundryHostedAgentSessionId = "sess-pinned";
+
+ await agent.RunAsync("hi", session);
+
+ Assert.Equal("sess-pinned", session.FoundryHostedAgentSessionId);
+ Assert.Contains("\"agent_session_id\":\"sess-pinned\"", handler.Requests[0].Body, StringComparison.Ordinal);
+ }
+
+ [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.FoundryHostedAgentSessionId);
+ 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 @@
+