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 @@ +