From 824ab8ec5bea9ebb6d4eb35ad2df0446d9c60aa8 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:47:49 +0100 Subject: [PATCH] .NET: Make Foundry user identity session sticky --- .../UserIsolationAgent/Program.cs | 10 +-- .../UserIsolationAgent/README.md | 10 ++- .../FoundryAgent.cs | 21 ++++- .../FoundryAgentSessionExtensions.cs | 40 ++++++++- .../FoundryChatOptionsExtensions.cs | 71 +-------------- .../FoundryHostedRequestAgent.cs | 12 +-- .../UserIdentityPolicy.cs | 4 +- .../UserIdentityScope.cs | 4 +- .../HostedSessionAndUserIdentityTests.cs | 49 ++++++----- .../README.md | 4 +- .../FoundryHostedRequestTests.cs | 87 +++++++++++-------- 11 files changed, 164 insertions(+), 148 deletions(-) diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/Program.cs index b26b02f01dd..7150e882f7c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/Program.cs @@ -11,7 +11,6 @@ using DotNetEnv; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry; -using Microsoft.Extensions.AI; Env.TraversePath().Load(); @@ -96,22 +95,19 @@ User Isolation Agent Sample if (!userSessions.TryGetValue(userId, out ChatClientAgentSession? userSession)) { userSession = await agent.CreateFoundryHostedAgentSessionAsync( - hostedSessionId: hostedSessionId); + hostedSessionId: hostedSessionId, + userIdentity: userId); userSessions.Add(userId, userSession); Console.WriteLine($"Created an independent conversation for '{userId}'."); } - var runOptions = new ChatClientAgentRunOptions( - new ChatOptions().WithFoundryHostedAgentUserIdentity(userId)); - Console.ForegroundColor = ConsoleColor.Yellow; Console.Write($"Agent for {userId}> "); Console.ResetColor(); await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( input, - userSession, - runOptions)) + userSession)) { Console.Write(update); } diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/README.md index 8788c3ce7db..9a9abe52b97 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/README.md @@ -4,9 +4,9 @@ This client demonstrates delegated user identity for a middle tier that serves s users through one Foundry hosted session. The sample creates one shared hosted session. Each user receives a separate Agent Framework -`AgentSession`, and every invocation sends that user's stable identifier through -`ChatOptions.WithFoundryHostedAgentUserIdentity`. Agent Framework places the value in the -`x-ms-user-identity` request header. +`AgentSession` created with that user's stable identifier. Agent Framework stores the identifier +with the session and automatically places it in the `x-ms-user-identity` request header on every +invocation that reuses the session. ## What Foundry isolates @@ -25,6 +25,10 @@ The sample deliberately creates one `AgentSession` per user. Reusing one `AgentS users would also reuse its conversation continuation identifier. Foundry rejects that cross user continuation rather than exposing the first user's history. +The delegated identity is fixed when `CreateFoundryHostedAgentSessionAsync` creates the local +`AgentSession`. To serve another user, create another `AgentSession`; it may still reference the same +hosted sandbox. + ## What the application must isolate Foundry isolates the conversation history it manages. It does not automatically partition arbitrary diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index cee29456f44..893c68b77c2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -164,7 +164,7 @@ public ValueTask CreateSessionAsync(string conversationId, Cancell /// /// Creates a local optionally pinned to a Foundry hosted-agent - /// session id (sandbox) and/or a server conversation id. + /// session id (sandbox), a server conversation id, and a delegated application user identity. /// /// /// Optional existing hosted-agent session id to pin on the session. The id identifies a Foundry @@ -181,8 +181,14 @@ public ValueTask CreateSessionAsync(string conversationId, Cancell /// history and hosted-agent session (sandbox) are separate Foundry concepts; see /// Sessions and conversations. /// + /// + /// Optional opaque application user identifier to associate with the session. When set, it is + /// stored in and sent as x-ms-user-identity on every + /// run that reuses this session. Create a separate for each user; + /// separate sessions may share the same . + /// /// The to monitor for cancellation requests. - /// A with the optional pins applied. + /// A with the optional bindings applied. /// /// /// The hosted-agent session itself is owned and lifecycle managed by Foundry Agent Service @@ -199,6 +205,7 @@ public ValueTask CreateSessionAsync(string conversationId, Cancell public async Task CreateFoundryHostedAgentSessionAsync( string? hostedSessionId = null, string? conversationId = null, + string? userIdentity = null, CancellationToken cancellationToken = default) { AgentSession session = conversationId is null @@ -212,6 +219,12 @@ public async Task CreateFoundryHostedAgentSessionAsync( typed.FoundryHostedAgentSessionId = hostedSessionId; } + if (userIdentity is not null) + { + // Non-null values are treated as an explicit identity binding; whitespace is rejected by Set. + typed.FoundryHostedAgentUserIdentity = userIdentity; + } + return typed; } @@ -297,11 +310,11 @@ private static AIAgent CreateResponsesChatClientAgent( } /// - /// Registers Foundry per-call pipeline policies and wraps the agent so request-scoped + /// Registers Foundry pipeline policies and wraps the agent so request context /// headers/body fields reach the wire: /// /// x-client-* via / - /// x-ms-user-identity and sticky agent_session_id via + /// sticky x-ms-user-identity and agent_session_id via /// /// Idempotent per decorator type. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs index 7338b73c186..3383001f484 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs @@ -18,6 +18,10 @@ namespace Microsoft.Agents.AI; /// serializing with the session. /// /// +/// The delegated user identity is stored in the same state bag. A session created for one user must +/// not be reused for another user, even when both sessions share the same hosted-agent session id. +/// +/// /// This is not . Per-call /// overrides use /// . @@ -31,6 +35,8 @@ public static class FoundryAgentSessionExtensions /// public const string FoundryHostedAgentSessionIdKey = "Microsoft.Agents.AI.Foundry.HostedAgentSessionId"; + private const string FoundryHostedAgentUserIdentityKey = "Microsoft.Agents.AI.Foundry.UserIdentity"; + extension(AgentSession session) { /// @@ -50,7 +56,7 @@ public static class FoundryAgentSessionExtensions /// /// /// Prefer creating or pinning through - /// . + /// . /// The property is populated automatically when Foundry creates a sandbox on first use. /// See /// Manage hosted agent sessions. @@ -73,5 +79,37 @@ internal set session.StateBag.SetValue(FoundryHostedAgentSessionIdKey, value); } } + + /// + /// Gets the delegated application user identity associated with this Agent Framework session. + /// + /// + /// The opaque application user identifier sent as x-ms-user-identity, or + /// when the session has no delegated identity. + /// + /// + /// The identity is fixed when the session is created through + /// . + /// Reusing this session automatically sends the same identity on every run. Create a separate + /// for each user; separate sessions may share the same + /// FoundryHostedAgentSessionId. + /// + public string? FoundryHostedAgentUserIdentity + { + get + { + _ = Throw.IfNull(session); + return session.StateBag.TryGetValue(FoundryHostedAgentUserIdentityKey, out var value) + ? value + : null; + } + + internal set + { + _ = Throw.IfNull(session); + _ = Throw.IfNullOrWhitespace(value); + session.StateBag.SetValue(FoundryHostedAgentUserIdentityKey, value); + } + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs index 8f6f00674e7..b91e3fbba26 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatOptionsExtensions.cs @@ -13,18 +13,15 @@ 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. -/// +/// Use to send agent_session_id on the +/// Responses body for a single run. /// /// /// 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)] @@ -39,12 +36,6 @@ public static class FoundryChatOptionsExtensions /// 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. /// @@ -52,7 +43,7 @@ public static class FoundryChatOptionsExtensions /// /// Only valid when the run's session has no hosted id yet, or already has this same id. /// Prefer - /// + /// /// to pin at session creation. /// /// @@ -71,44 +62,6 @@ public static ChatOptions WithFoundryHostedAgentSessionId(this ChatOptions optio 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) { @@ -124,20 +77,4 @@ public static ChatOptions WithFoundryHostedAgentUserIdentity(this ChatOptions op 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 index 7b4dabd4f1e..576cb770138 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryHostedRequestAgent.cs @@ -15,10 +15,10 @@ namespace Microsoft.Agents.AI.Foundry; /// -/// Delegating agent that applies Foundry hosted-agent request context per run: +/// Delegating agent that applies Foundry hosted-agent request context: /// 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 . +/// Responses body, stamps the session's x-ms-user-identity, and writes the +/// platform-returned session id back onto the . /// internal sealed class FoundryHostedRequestAgent : DelegatingAIAgent { @@ -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 the per-call Foundry - // user identity does not inherit a parent AsyncLocal value and stamp the wrong header. - UserIdentityScope.Current = chatOptions.GetFoundryHostedAgentUserIdentity(); + // Always assign (including null) so a nested Foundry run without a session identity does + // not inherit a parent AsyncLocal value and stamp the wrong header. + UserIdentityScope.Current = session?.FoundryHostedAgentUserIdentity; return new PreparedRun(effectiveOptions, sessionIdBox); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs index a71655e2bc1..4fa86673df4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs @@ -7,8 +7,8 @@ namespace Microsoft.Agents.AI.Foundry; /// -/// Pipeline policy that stamps x-ms-user-identity from -/// onto outbound OpenAI Responses requests. +/// Pipeline policy that stamps the current session's x-ms-user-identity from +/// onto outbound OpenAI Responses requests. /// internal sealed class UserIdentityPolicy : PipelinePolicy { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityScope.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityScope.cs index b7e5630eb7b..0e116251308 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityScope.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityScope.cs @@ -5,14 +5,14 @@ namespace Microsoft.Agents.AI.Foundry; /// -/// AsyncLocal carrier for the per-call x-ms-user-identity value from +/// AsyncLocal carrier for the session's 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. + /// Gets or sets the user identity value for the current asynchronous flow. public static string? Current { get => s_current.Value; diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs index 3ea40e425ab..871e5442045 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedSessionAndUserIdentityTests.cs @@ -15,14 +15,13 @@ 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. +/// Live tests for client-side Foundry hosted session and delegated user identity sticky +/// behavior against a real hosted agent. /// /// /// @@ -122,18 +121,21 @@ public async Task SameHostedSandbox_DifferentAgentSessionsAndUserIdentities_Yiel 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"); + ChatClientAgentSession aliceSession = await agent.CreateFoundryHostedAgentSessionAsync( + userIdentity: "alice-it"); + string aliceUserId = await this.RunAndReadUserIdAsync(agent, aliceSession); 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); + ChatClientAgentSession bobSession = await agent.CreateFoundryHostedAgentSessionAsync( + hostedSessionId: hostedSessionId, + userIdentity: "bob-it"); Assert.NotSame(aliceSession, bobSession); Assert.Equal(hostedSessionId, bobSession.FoundryHostedAgentSessionId); - string bobUserId = await this.RunAndReadUserIdAsync(agent, bobSession, "bob-it"); + string bobUserId = await this.RunAndReadUserIdAsync(agent, bobSession); // Assert: hosted sandbox stays the same on both sessions after bob's response. Assert.Equal(hostedSessionId, aliceSession.FoundryHostedAgentSessionId); @@ -154,7 +156,9 @@ aliceConversationId is not null // Assert: platform user keys differ for alice vs bob. Assert.NotEqual("missing", aliceUserId); Assert.NotEqual("missing", bobUserId); - Assert.NotEqual(aliceUserId, bobUserId); + Assert.False( + string.Equals(aliceUserId, bobUserId, StringComparison.Ordinal), + "Expected different platform user keys for separate delegated identities."); } finally { @@ -163,25 +167,34 @@ aliceConversationId is not null } [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() + public async Task SerializedSession_SameUserIdentity_YieldsStablePlatformUserIdAsync() { // Arrange FoundryAgent agent = this.CreateFoundryAgent(); - ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync(); + ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync( + userIdentity: "stable-user-it"); string? hostedSessionId = null; try { // Act - string first = await this.RunAndReadUserIdAsync(agent, session, "stable-user-it"); + string first = await this.RunAndReadUserIdAsync(agent, session); hostedSessionId = session.FoundryHostedAgentSessionId; - string second = await this.RunAndReadUserIdAsync(agent, session, "stable-user-it"); + + var serializedSession = await agent.SerializeSessionAsync(session); + session = Assert.IsType( + await agent.DeserializeSessionAsync(serializedSession)); + + string second = await this.RunAndReadUserIdAsync(agent, session); // Assert Assert.False(string.IsNullOrWhiteSpace(hostedSessionId)); Assert.Equal(hostedSessionId, session.FoundryHostedAgentSessionId); + Assert.Equal("stable-user-it", session.FoundryHostedAgentUserIdentity); Assert.NotEqual("missing", first); - Assert.Equal(first, second); + Assert.True( + string.Equals(first, second, StringComparison.Ordinal), + "Expected the restored session to keep the same platform user key."); } finally { @@ -189,19 +202,15 @@ public async Task SameSession_SameUserIdentity_YieldsStablePlatformUserIdAsync() } } - private async Task RunAndReadUserIdAsync(FoundryAgent agent, AgentSession session, string userIdentity) + private async Task RunAndReadUserIdAsync(FoundryAgent agent, AgentSession session) { - var options = new ChatClientAgentRunOptions( - new ChatOptions().WithFoundryHostedAgentUserIdentity(userIdentity)); - var response = await agent.RunAsync( "Acknowledge the request briefly.", - session, - options); + session); 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}"); + Assert.True(match.Success, "Expected a USER-ID: token in the response text."); return match.Groups[1].Value; } diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md index b29c8f85c46..1b5bedf84b2 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md @@ -43,8 +43,8 @@ supported hosting paths. - `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 +- session-sticky delegated identity (`x-ms-user-identity`) producing distinct platform user keys + inside the container, including after session serialization and restoration The container scenario injects `USER-ID:` via `EchoPlatformUserIdContextProvider`, reading `HostedSessionContext.UserId` (from diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs index 3dc2e738f43..aee7a5000be 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs @@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests; /// -/// Tests for hosted-agent session sticky behavior and per-call user identity. +/// Tests for hosted-agent session and delegated user identity sticky behavior. /// public sealed class FoundryHostedRequestTests { @@ -32,35 +32,55 @@ public void WithFoundryHostedAgentSessionId_WritesOptionsCarrier() } [Fact] - public void WithFoundryHostedAgentUserIdentity_WritesOptionsCarrier() - { - var options = new ChatOptions(); - options.WithFoundryHostedAgentUserIdentity("alice"); - Assert.Equal("alice", options.GetFoundryHostedAgentUserIdentity()); - } - - [Fact] - public async Task CreateFoundryHostedAgentSessionAsync_PinsHostedAndConversationIdsAsync() + public async Task CreateFoundryHostedAgentSessionAsync_PinsHostedConversationAndUserIdentityAsync() { FoundryAgent agent = CreateFoundryAgent(); ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync( hostedSessionId: "sess-1", - conversationId: "conv-1"); + conversationId: "conv-1", + userIdentity: "alice"); Assert.Equal("sess-1", session.FoundryHostedAgentSessionId); Assert.Equal("conv-1", session.ConversationId); + Assert.Equal("alice", session.FoundryHostedAgentUserIdentity); Assert.True(session.StateBag.TryGetValue(FoundryAgentSessionExtensions.FoundryHostedAgentSessionIdKey, out var raw)); Assert.Equal("sess-1", raw); } [Fact] - public async Task CreateFoundryHostedAgentSessionAsync_WithoutIds_LeavesBothEmptyAsync() + public async Task CreateFoundryHostedAgentSessionAsync_WithoutBindings_LeavesAllEmptyAsync() { FoundryAgent agent = CreateFoundryAgent(); ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync(); Assert.Null(session.FoundryHostedAgentSessionId); Assert.Null(session.ConversationId); + Assert.Null(session.FoundryHostedAgentUserIdentity); + } + + [Fact] + public async Task UserIdentity_RoundTripsWithSessionSerializationAsync() + { + FoundryAgent agent = CreateFoundryAgent(); + ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync( + hostedSessionId: "sess-1", + conversationId: "conv-1", + userIdentity: "alice"); + + JsonElement serialized = await agent.SerializeSessionAsync(session); + ChatClientAgentSession restored = Assert.IsType( + await agent.DeserializeSessionAsync(serialized)); + + Assert.Equal("sess-1", restored.FoundryHostedAgentSessionId); + Assert.Equal("conv-1", restored.ConversationId); + Assert.Equal("alice", restored.FoundryHostedAgentUserIdentity); + + string? seenIdentity = null; + var requestAgent = new FoundryHostedRequestAgent( + new ProbeAgent(onRun: _ => seenIdentity = UserIdentityScope.Current)); + await requestAgent.RunAsync("hi", restored); + + Assert.Equal("alice", seenIdentity); } [Fact] @@ -72,6 +92,15 @@ await Assert.ThrowsAsync( () => agent.CreateFoundryHostedAgentSessionAsync(hostedSessionId: " ")); } + [Fact] + public async Task CreateFoundryHostedAgentSessionAsync_WhitespaceUserIdentity_ThrowsAsync() + { + FoundryAgent agent = CreateFoundryAgent(); + + await Assert.ThrowsAsync( + () => agent.CreateFoundryHostedAgentSessionAsync(userIdentity: " ")); + } + [Fact] public async Task Conflict_SessionAndOptionsHostedIdsDiffer_ThrowsAsync() { @@ -136,27 +165,19 @@ public async Task OptionsHostedId_WhenSessionEmpty_IsInjectedAndStickyAfterRunAs } [Fact] - public async Task UserIdentity_DifferentPerCall_OnSameSession_IsAllowedAsync() + public async Task UserIdentity_SessionValueIsUsedAcrossRunsAsync() { - // 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"; + session.FoundryHostedAgentUserIdentity = "alice"; - await agent.RunAsync( - "hi", - session, - new ChatClientAgentRunOptions(new ChatOptions().WithFoundryHostedAgentUserIdentity("alice"))); - await agent.RunAsync( - "hi", - session, - new ChatClientAgentRunOptions(new ChatOptions().WithFoundryHostedAgentUserIdentity("bob"))); + await agent.RunAsync("hi", session); + await agent.RunAsync("hi", session); - Assert.Equal(["alice", "bob"], seen); + Assert.Equal(["alice", "alice"], seen); Assert.Equal("sess-shared", session.FoundryHostedAgentSessionId); } @@ -166,13 +187,12 @@ 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(); + var sessionWithIdentity = new TestSession(); + sessionWithIdentity.FoundryHostedAgentUserIdentity = "alice"; + var sessionWithoutIdentity = new TestSession(); - await agent.RunAsync( - "hi", - session, - new ChatClientAgentRunOptions(new ChatOptions().WithFoundryHostedAgentUserIdentity("alice"))); - await agent.RunAsync("hi", session, new ChatClientAgentRunOptions(new ChatOptions())); + await agent.RunAsync("hi", sessionWithIdentity); + await agent.RunAsync("hi", sessionWithoutIdentity); Assert.Equal(["alice", null], seen); } @@ -263,11 +283,10 @@ public async Task EndToEnd_UserIdentity_AndHostedSessionId_ReachWireAsync() AIAgent agent = new FoundryHostedRequestAgent(new ClientHeadersAgent(chatAgent)); AgentSession session = await chatAgent.CreateSessionAsync(); session.FoundryHostedAgentSessionId = "sess-pinned"; + session.FoundryHostedAgentUserIdentity = "alice"; var runOptions = new ChatClientAgentRunOptions( - new ChatOptions() - .WithFoundryHostedAgentUserIdentity("alice") - .WithClientHeader("x-client-end-user-id", "alice-app")); + new ChatOptions().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(