diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs index e5e773db87..37f5970d4b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Reflection; @@ -9,8 +10,11 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// -/// Pipeline policy that appends the hosted-agent User-Agent segment -/// (e.g. "foundry-hosting/agent-framework-dotnet/{version}") to outgoing requests. +/// Pipeline policy that emits the hosted-agent User-Agent segment +/// ("foundry-hosting/agent-framework-dotnet/{version}"), matching Python's hosted +/// contract (foundry-hosting/agent-framework-python/{version}, see +/// python/packages/core/agent_framework/_telemetry.py: the hosted prefix is joined +/// with the base agent-framework segment into a single combined User-Agent value). /// /// /// @@ -19,6 +23,12 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// is already present in the User-Agent header, the policy does not append it again. /// /// +/// When a bare agent-framework-dotnet/{version} segment is already present (stamped by +/// the framework-wide AgentFrameworkUserAgentPolicy registered by +/// FoundryChatClient), this policy replaces that segment with the combined +/// hosted form so the wire never carries both forms simultaneously, preserving Python parity. +/// +/// /// This policy is added at hosted-agent resolution time via the MEAI 10.5.1 /// hook on the agent's underlying chat client. It is only /// registered when an agent is resolved by the Foundry hosting layer. @@ -30,6 +40,12 @@ internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy private static readonly string s_supplementValue = CreateSupplementValue(); + /// Bare segment stamped by AgentFrameworkUserAgentPolicy in the non-hosted scenario; this policy upgrades it in-place when both run. + private const string BareAgentFrameworkPrefix = "agent-framework-dotnet/"; + + /// Combined hosted segment that this policy emits. Recognized in-place so callers whose pipelines already carry a (possibly different-version) combined segment get it replaced rather than double-prefixed (Q-D fix). + private const string CombinedHostedPrefix = "foundry-hosting/agent-framework-dotnet/"; + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) { AppendHeader(message); @@ -46,10 +62,49 @@ private static void AppendHeader(PipelineMessage message) { if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing)) { - // Guard against double-append on retries or when the policy - // is registered on multiple pipeline positions. - if (existing.Contains(s_supplementValue)) + // Guard against double-append on retries or when the policy is registered on + // multiple pipeline positions. + if (existing!.Contains(s_supplementValue)) + { + return; + } + + // Combined-form check first: if the caller's pipeline already has + // `foundry-hosting/agent-framework-dotnet/{version}` (with a version that differs + // from ours — otherwise the .Contains above would have returned early), replace the + // entire combined span in place. Without this, the bare-prefix search below would + // match `agent-framework-dotnet/` *inside* the combined segment and produce a + // malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` value. + var combinedIdx = existing.IndexOf(CombinedHostedPrefix, StringComparison.Ordinal); + if (combinedIdx >= 0) { + var combinedEnd = existing.IndexOf(' ', combinedIdx); + if (combinedEnd < 0) + { + combinedEnd = existing.Length; + } + + var replacedCombined = string.Concat(existing.AsSpan(0, combinedIdx), s_supplementValue.AsSpan(), existing.AsSpan(combinedEnd)); + message.Request.Headers.Set("User-Agent", replacedCombined); + return; + } + + // If the bare agent-framework segment is present (stamped by + // AgentFrameworkUserAgentPolicy when not hosted), upgrade it in place to the + // combined hosted form so the wire never carries both segments simultaneously. + // Mirrors Python where get_user_agent() returns a single combined string when the + // hosted prefix is registered. + var idx = existing.IndexOf(BareAgentFrameworkPrefix, StringComparison.Ordinal); + if (idx >= 0) + { + var end = existing.IndexOf(' ', idx); + if (end < 0) + { + end = existing.Length; + } + + var replaced = string.Concat(existing.AsSpan(0, idx), s_supplementValue.AsSpan(), existing.AsSpan(end)); + message.Request.Headers.Set("User-Agent", replaced); return; } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AIProjectClientExtensions.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Foundry/AIProjectClientExtensions.cs index 8feb3bd465..d4b94a0f79 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AIProjectClientExtensions.cs @@ -23,7 +23,7 @@ namespace Azure.AI.Projects; /// Provides extension methods for . /// [Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] -public static partial class AzureAIProjectChatClientExtensions +public static partial class AIProjectClientExtensions { /// /// Uses an existing server side agent, wrapped as a using the provided and . @@ -63,7 +63,7 @@ public static FoundryAgent AsAIAgent( clientFactory, services); - return new FoundryAgent(aiProjectClient, innerAgent); + return new FoundryAgent(innerAgent); } /// @@ -132,7 +132,7 @@ public static FoundryAgent AsAIAgent( !allowDeclarativeMode, services); - return new FoundryAgent(aiProjectClient, innerAgent); + return new FoundryAgent(innerAgent); } /// @@ -165,7 +165,7 @@ public static FoundryAgent AsAIAgent( !allowDeclarativeMode, services); - return new FoundryAgent(aiProjectClient, innerAgent); + return new FoundryAgent(innerAgent); } /// @@ -246,7 +246,7 @@ private static ChatClientAgent CreateChatClientAgent( Func? clientFactory, IServiceProvider? services) { - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions); + IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions); if (clientFactory is not null) { @@ -268,10 +268,7 @@ private static ChatClientAgent CreateResponsesChatClientAgent( Throw.IfNull(agentOptions.ChatOptions); Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); - IChatClient chatClient = aiProjectClient - .GetProjectOpenAIClient() - .GetResponsesClient() - .AsIChatClient(agentOptions.ChatOptions.ModelId); + IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId); if (clientFactory is not null) { @@ -298,7 +295,7 @@ private static ChatClientAgent AsChatClientAgent( Func? clientFactory, IServiceProvider? services) { - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions); + IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions); if (clientFactory is not null) { @@ -316,7 +313,7 @@ private static ChatClientAgent AsChatClientAgent( Func? clientFactory, IServiceProvider? services) { - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); + IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); if (clientFactory is not null) { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs new file mode 100644 index 0000000000..5072e44c9c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry; + +/// +/// Framework-wide pipeline policy that appends the agent-framework-dotnet/{version} +/// segment to outgoing User-Agent headers, mirroring the +/// agent-framework-python/{version} contract used by every Python provider package. +/// +/// +/// +/// The segment value is computed once from the Microsoft.Agents.AI.Foundry assembly's +/// . The policy is idempotent on retries: if +/// the segment is already present in the User-Agent header, the policy does not append +/// it again. +/// +/// +/// The policy is registered by FoundryChatClient on the underlying chat client's +/// OpenAIRequestPolicies hook so every outbound Foundry call carries the segment. The +/// policy is currently colocated with the Foundry package; it is expected to migrate to a +/// framework-wide location (such as Microsoft.Agents.AI) once another provider package +/// adopts the same User-Agent contract. +/// +/// +internal sealed class AgentFrameworkUserAgentPolicy : PipelinePolicy +{ + /// Gets the singleton policy instance. + public static AgentFrameworkUserAgentPolicy Instance { get; } = new AgentFrameworkUserAgentPolicy(); + + private static readonly string s_segmentValue = CreateSegmentValue(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AppendHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AppendHeader(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void AppendHeader(PipelineMessage message) + { + if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing)) + { + // Guard against double-append on retries or when the policy + // is registered on multiple pipeline positions. + if (existing!.Contains(s_segmentValue)) + { + return; + } + + message.Request.Headers.Set("User-Agent", $"{existing} {s_segmentValue}"); + } + else + { + message.Request.Headers.Set("User-Agent", s_segmentValue); + } + } + + private static string CreateSegmentValue() + { + const string Name = "agent-framework-dotnet"; + + if (typeof(AgentFrameworkUserAgentPolicy).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+'); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClient.cs deleted file mode 100644 index c9a121bfc4..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClient.cs +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; -using Azure.AI.Projects.Agents; -using Microsoft.Extensions.AI; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; -using OpenAI.Responses; - -namespace Microsoft.Agents.AI.Foundry; - -/// -/// Provides a chat client implementation that integrates with Azure AI Agents, enabling chat interactions using -/// Azure-specific agent capabilities. -/// -[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] -internal sealed class AzureAIProjectChatClient : DelegatingChatClient -{ - private readonly ChatClientMetadata? _metadata; - private readonly AIProjectClient _agentClient; - private readonly ProjectsAgentVersion? _agentVersion; - private readonly ProjectsAgentRecord? _agentRecord; - private readonly ChatOptions? _chatOptions; - private readonly AgentReference _agentReference; - - /// - /// Initializes a new instance of the class. - /// - /// An instance of to interact with Azure AI Agents services. - /// An instance of representing the specific agent to use. - /// The default model to use for the agent, if applicable. - /// An instance of representing the options on how the agent was predefined. - /// - /// The provided should be decorated with a for proper functionality. - /// - internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? chatOptions) - : base(Throw.IfNull(aiProjectClient) - .GetProjectOpenAIClient() - .GetProjectResponsesClientForAgent(agentReference) - .AsIChatClient()) - { - this._agentClient = aiProjectClient; - this._agentReference = Throw.IfNull(agentReference); - this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId); - this._chatOptions = chatOptions; - } - - /// - /// Initializes a new instance of the class. - /// - /// An instance of to interact with Azure AI Agents services. - /// An instance of representing the specific agent to use. - /// An instance of representing the options on how the agent was predefined. - /// - /// The provided should be decorated with a for proper functionality. - /// - internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? chatOptions) - : this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), chatOptions) - { - this._agentRecord = agentRecord; - } - - internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? chatOptions) - : this( - aiProjectClient, - CreateAgentReference(Throw.IfNull(agentVersion)), - (agentVersion.Definition as DeclarativeAgentDefinition)?.Model, - chatOptions) - { - this._agentVersion = agentVersion; - } - - /// - /// Creates an from an . - /// Uses the agent version's version if available, otherwise defaults to "latest". - /// - /// The agent version to create a reference from. - /// An for the specified agent version. - private static AgentReference CreateAgentReference(ProjectsAgentVersion agentVersion) - { - // If the version is null, empty, or whitespace, use "latest" as the default. - // This handles cases where hosted agents (like MCP agents) may not have a version assigned. - var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version; - return new AgentReference(agentVersion.Name, version); - } - - /// - public override object? GetService(Type serviceType, object? serviceKey = null) - { - return (serviceKey is null && serviceType == typeof(ChatClientMetadata)) - ? this._metadata - : (serviceKey is null && serviceType == typeof(AIProjectClient)) - ? this._agentClient - : (serviceKey is null && serviceType == typeof(ProjectsAgentVersion)) - ? this._agentVersion - : (serviceKey is null && serviceType == typeof(ProjectsAgentRecord)) - ? this._agentRecord - : (serviceKey is null && serviceType == typeof(AgentReference)) - ? this._agentReference - : base.GetService(serviceType, serviceKey); - } - - /// - public override async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - var agentOptions = this.GetAgentEnabledChatOptions(options); - - return await base.GetResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false); - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var agentOptions = this.GetAgentEnabledChatOptions(options); - - await foreach (var chunk in base.GetStreamingResponseAsync(messages, agentOptions, cancellationToken).ConfigureAwait(false)) - { - yield return chunk; - } - } - - private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options) - { - // Start with a clone of the base chat options defined for the agent, if any. - ChatOptions agentEnabledChatOptions = this._chatOptions?.Clone() ?? new(); - - // Ignore per-request all options that can't be overridden. - agentEnabledChatOptions.Instructions = null; - agentEnabledChatOptions.Tools = null; - agentEnabledChatOptions.Temperature = null; - agentEnabledChatOptions.TopP = null; - agentEnabledChatOptions.PresencePenalty = null; - agentEnabledChatOptions.ResponseFormat = null; - - // Use the conversation from the request, or the one defined at the client level. - agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._chatOptions?.ConversationId; - - // Preserve the original RawRepresentationFactory - var originalFactory = options?.RawRepresentationFactory; - - agentEnabledChatOptions.RawRepresentationFactory = (client) => - { - if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions) - { - responseCreationOptions = new CreateResponseOptions(); - } - - responseCreationOptions.Agent = this._agentReference; -#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - responseCreationOptions.Patch.Remove("$.model"u8); -#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - return responseCreationOptions; - }; - - return agentEnabledChatOptions; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectResponsesChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectResponsesChatClient.cs deleted file mode 100644 index a768d102f8..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectResponsesChatClient.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Azure.AI.Projects; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Foundry; - -#pragma warning disable OPENAI001 -internal sealed class AzureAIProjectResponsesChatClient : DelegatingChatClient -{ - private readonly ChatClientMetadata _metadata; - private readonly AIProjectClient _aiProjectClient; - - internal AzureAIProjectResponsesChatClient(AIProjectClient aiProjectClient, string defaultModelId) - : base(Throw.IfNull(aiProjectClient) - .GetProjectOpenAIClient() - .GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(defaultModelId)) - .AsIChatClient()) - { - this._aiProjectClient = aiProjectClient; - this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId); - } - - public override object? GetService(Type serviceType, object? serviceKey = null) - { - return (serviceKey is null && serviceType == typeof(ChatClientMetadata)) - ? this._metadata - : (serviceKey is null && serviceType == typeof(AIProjectClient)) - ? this._aiProjectClient - : base.GetService(serviceType, serviceKey); - } -} -#pragma warning restore OPENAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ChatClientAgentFoundryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ChatClientAgentFoundryExtensions.cs new file mode 100644 index 0000000000..772675108b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ChatClientAgentFoundryExtensions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Foundry; + +/// +/// Foundry-specific extensions on . Mirrors Python's free +/// to_prompt_agent(agent) function for agents whose underlying chat client is a +/// . +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class ChatClientAgentFoundryExtensions +{ + /// + /// Converts the supplied agent into a ready to publish + /// via AgentAdministrationClient.CreateAgentVersionAsync. + /// + /// + /// Only works on agents whose chat client is a and whose + /// construction mode is convertible. The Agent Endpoint construction mode (Mode 3) is not + /// convertible because no local definition exists; conversion in that case throws. + /// + /// The chat client agent to convert. + /// A token that can cancel an internal server-side fetch when the agent was constructed from a bare . + /// A suitable for publishing. + /// is . + /// The agent's chat client is not a ; the agent was constructed via the Agent Endpoint mode (Mode 3); no model id is set on the agent's for the Responses Agent mode (Mode 1); or the agent contains an that cannot be converted to a ResponseTool. + public static Task ToPromptAgentAsync(this ChatClientAgent agent, CancellationToken cancellationToken = default) + { + Throw.IfNull(agent); + return FoundryPromptAgentConverter.ConvertAsync(agent.ChatClient, agent.GetService(), cancellationToken); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index 30bfc84d9a..e412bb35b9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -39,11 +39,6 @@ namespace Microsoft.Agents.AI.Foundry; [Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] public sealed class FoundryAgent : DelegatingAIAgent { - /// - /// The cached supplied to or constructed by the active constructor. - /// - private readonly AIProjectClient _aiProjectClient; - /// /// Initializes a new instance of the class using the direct Responses API path. /// @@ -73,9 +68,8 @@ public FoundryAgent( : base(CreateInnerAgent( CreateProjectClient(projectEndpoint, credential, clientOptions), model, instructions, name, description, tools, clientFactory, loggerFactory, services, - out var aiProjectClient)) + out _)) { - this._aiProjectClient = aiProjectClient; } /// @@ -87,9 +81,11 @@ public FoundryAgent( /// /// The authentication credential. /// - /// Optional configuration for the underlying . When supplied: + /// Optional configuration for the underlying . When supplied: /// /// The instance is passed through to the per-agent client; pipeline policies added via AddPolicy(...) on it execute on the per-agent traffic. + /// Endpoint and are owned by this constructor and are overwritten with values derived from ; any caller value is replaced. + /// For the project-level conversations client a separate fresh options bag is built that copies only , , , and UserAgentApplicationId; pipeline policies added via AddPolicy(...) do not propagate to the conversations pipeline. /// /// /// Optional tools to use when interacting with the agent. @@ -113,43 +109,37 @@ public FoundryAgent( IList? tools = null, Func? clientFactory = null, IServiceProvider? services = null) - : base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services, out var aiProjectClient)) + : base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services)) { - this._aiProjectClient = aiProjectClient; } /// - /// Initializes a new instance of the class from an agent-specific - /// endpoint while reusing an existing . + /// Internal constructor used by the AsAIAgent(this AIProjectClient, Uri, ...) + /// extension where the caller already has an and the agent + /// endpoint URI. Reuses the supplied client's pipeline (no new credential or transport is + /// stamped) and surfaces the agent through a just like the + /// public agent-endpoint ctor. /// - /// An existing rooted at the same project as . - /// - /// The agent-specific endpoint URI. Must be of the shape - /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai. - /// - /// Optional tools to use when interacting with the agent. - /// Provides a way to customize the creation of the underlying . - /// Optional service provider for resolving dependencies required by AI functions. - /// or is null. - /// does not match the expected agent-endpoint shape. internal FoundryAgent( AIProjectClient aiProjectClient, Uri agentEndpoint, IList? tools = null, Func? clientFactory = null, IServiceProvider? services = null) - : base(BuildAgentEndpointInnerAgent(aiProjectClient, agentEndpoint, clientOptions: null, tools, clientFactory, services)) + : base(CreateInnerAgentFromAgentEndpointReusingProjectClient(aiProjectClient, agentEndpoint, tools, clientFactory, services)) { - this._aiProjectClient = Throw.IfNull(aiProjectClient); } /// - /// Internal constructor used by AsAIAgent extension methods that already have an and a configured . + /// Internal constructor used by AsAIAgent extension methods that already have a + /// configured . The inner agent already routes through a + /// whose GetService<AIProjectClient>() surfaces + /// the project client to downstream callers, so the agent does not also need a private + /// reference here. /// - internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent) + internal FoundryAgent(ChatClientAgent innerAgent) : base(WireClientHeaders(Throw.IfNull(innerAgent))) { - this._aiProjectClient = Throw.IfNull(aiProjectClient); } #region Convenience methods @@ -182,7 +172,13 @@ public ValueTask CreateSessionAsync(string conversationId, Cancell /// A linked to the newly created server-side conversation. public async Task CreateConversationSessionAsync(CancellationToken cancellationToken = default) { - var conversationsClient = this._aiProjectClient.ProjectOpenAIClient.GetProjectConversationsClient(); + // The inner FoundryChatClient surfaces an AIProjectClient via GetService for all + // three construction modes (Plan #2 Agent Endpoint mode materialization). Resolve it through the + // delegating chain at call time instead of caching a private reference on this agent. + var aiProjectClient = this.GetService() + ?? throw new InvalidOperationException( + "FoundryAgent inner chain does not expose an AIProjectClient; cannot create a project-level conversation session."); + var conversationsClient = aiProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient(); var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value; @@ -196,17 +192,6 @@ private ChatClientAgent GetInnerChatClientAgent() => #endregion - /// - public override object? GetService(Type serviceType, object? serviceKey = null) - { - if (serviceKey is null && serviceType == typeof(AIProjectClient)) - { - return this._aiProjectClient; - } - - return base.GetService(serviceType, serviceKey); - } - #region Private helpers private static AIAgent CreateInnerAgent( @@ -251,7 +236,7 @@ private static AIAgent CreateResponsesChatClientAgent( Throw.IfNull(agentOptions.ChatOptions); Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); - IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId); + IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId); if (clientFactory is not null) { @@ -288,16 +273,10 @@ private static AIAgent WireClientHeaders(ChatClientAgent innerAgent) } /// - /// Builds the inner for the agent-endpoint constructor by - /// constructing a project-scoped and using - /// . - /// This routes the outbound URL through the per-agent endpoint shape that the Foundry service - /// expects for hosted agents and lets the SDK auto-append the api-version query string. - /// Caller-supplied are passed through to the per-agent - /// client with Endpoint and - /// overridden by values derived from - /// ; any policies the caller added via AddPolicy - /// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last. + /// Builds the inner for the agent-endpoint constructor. The + /// per-agent shape and URL parsing are owned by + /// ; we just construct it in the Agent Endpoint mode (Mode 3) + /// and pass the inner chat client through any caller-provided . /// private static AIAgent CreateInnerAgentFromAgentEndpoint( Uri agentEndpoint, @@ -305,29 +284,37 @@ private static AIAgent CreateInnerAgentFromAgentEndpoint( ProjectOpenAIClientOptions? clientOptions, IList? tools, Func? clientFactory, - IServiceProvider? services, - out AIProjectClient outClient) + IServiceProvider? services) { Throw.IfNull(agentEndpoint); Throw.IfNull(credential); - var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint); - outClient = CreateProjectClient(projectRoot, credential, CreateProjectClientOptions(clientOptions)); + IChatClient chatClient = new FoundryChatClient(agentEndpoint, credential, clientOptions); + var agentName = ((FoundryChatClient)chatClient).AgentName!; - return BuildAgentEndpointInnerAgent(outClient, agentEndpoint, clientOptions, tools, clientFactory, services); + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + ChatClientAgentOptions agentOptions = new() + { + Id = agentName, + Name = agentName, + ChatOptions = new() { Tools = tools }, + }; + + return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services)); } /// - /// Builds the inner for an agent endpoint against a pre-built - /// . The caller is responsible for ensuring the supplied client - /// is rooted at the same project as ; the agent name is - /// parsed from the endpoint URI and passed to - /// . + /// Variant of that reuses an existing + /// 's pipeline instead of stamping a fresh credential. Used by + /// the AsAIAgent(AIProjectClient, Uri agentEndpoint, ...) extension overload. /// - private static AIAgent BuildAgentEndpointInnerAgent( + private static AIAgent CreateInnerAgentFromAgentEndpointReusingProjectClient( AIProjectClient aiProjectClient, Uri agentEndpoint, - ProjectOpenAIClientOptions? clientOptions, IList? tools, Func? clientFactory, IServiceProvider? services) @@ -335,14 +322,9 @@ private static AIAgent BuildAgentEndpointInnerAgent( Throw.IfNull(aiProjectClient); Throw.IfNull(agentEndpoint); - var (agentName, _) = ParseAgentEndpoint(agentEndpoint); - - var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions(); - perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); + IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentEndpoint, clientOptions: null); + var agentName = ((FoundryChatClient)chatClient).AgentName!; - IChatClient chatClient = aiProjectClient.ProjectOpenAIClient - .GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions) - .AsIChatClient(); if (clientFactory is not null) { chatClient = clientFactory(chatClient); @@ -358,6 +340,13 @@ private static AIAgent BuildAgentEndpointInnerAgent( return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services)); } + /// + /// Parses an agent endpoint URI. Delegates to + /// so the chat client and the agent share a single source of truth for the URL shape. + /// + internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint) + => FoundryChatClient.ParseAgentEndpoint(agentEndpoint); + /// /// Parses an agent endpoint URI of shape /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai @@ -369,90 +358,12 @@ private static AIAgent BuildAgentEndpointInnerAgent( /// strips query string and fragment. Throws for inputs that /// do not match the expected shape. /// - /// - /// The endpoint is missing the /agents/ segment, has an empty agent name, or has a - /// suffix other than /endpoint/protocols/openai. - /// - internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint) - { - Throw.IfNull(agentEndpoint); - - const string AgentsSegment = "/agents/"; - const string ExpectedSuffix = "/endpoint/protocols/openai"; - - var path = agentEndpoint.AbsolutePath.TrimEnd('/'); - var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase); - if (idx < 0) - { - throw new ArgumentException( - $"Expected an agent endpoint of shape 'https:///.../projects//agents//endpoint/protocols/openai' but got '{agentEndpoint}'.", - nameof(agentEndpoint)); - } - - var afterAgents = path.Substring(idx + AgentsSegment.Length); - var nextSlash = afterAgents.IndexOf('/'); - if (nextSlash <= 0) - { - throw new ArgumentException( - $"Agent endpoint '{agentEndpoint}' is missing the '{ExpectedSuffix}' suffix.", - nameof(agentEndpoint)); - } - - var agentName = afterAgents.Substring(0, nextSlash); - var suffix = afterAgents.Substring(nextSlash); - if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException( - $"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.", - nameof(agentEndpoint)); - } - - var rootPath = path.Substring(0, idx); - var projectRoot = new UriBuilder(agentEndpoint) - { - Path = rootPath, - Query = string.Empty, - Fragment = string.Empty, - }.Uri; - - return (agentName, projectRoot); - } - private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null) { Throw.IfNull(endpoint); Throw.IfNull(credential); - clientOptions ??= new AIProjectClientOptions(); - clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); - return new AIProjectClient(endpoint, credential, clientOptions); - } - - internal static AIProjectClientOptions? CreateProjectClientOptions(ProjectOpenAIClientOptions? clientOptions) - { - if (clientOptions is null) - { - return null; - } - - // Copy pipeline behavior the caller configured on the per-agent options bag onto the - // project-level options bag so the agent endpoint client honors it. UserAgentApplicationId - // is project-level (not derived from the agent endpoint), so it must be carried through too. - var projectOptions = new AIProjectClientOptions - { - Transport = clientOptions.Transport, - RetryPolicy = clientOptions.RetryPolicy, - NetworkTimeout = clientOptions.NetworkTimeout, - MessageLoggingPolicy = clientOptions.MessageLoggingPolicy, - UserAgentApplicationId = clientOptions.UserAgentApplicationId, - }; - - if (clientOptions.ClientLoggingOptions is not null) - { - projectOptions.ClientLoggingOptions = clientOptions.ClientLoggingOptions; - } - - return projectOptions; + return new AIProjectClient(endpoint, credential, clientOptions ?? new AIProjectClientOptions()); } #endregion diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentExtensions.cs new file mode 100644 index 0000000000..db079dc9ff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentExtensions.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Files; +using OpenAI.VectorStores; + +#pragma warning disable OPENAI001 + +namespace Microsoft.Agents.AI.Foundry; + +/// +/// Foundry-specific extensions on . Hosts the prompt-agent converter +/// plus thin forwarders that surface the file and vector-store helpers from the inner +/// at the agent level so callers do not need to drop down to +/// agent.GetService<FoundryChatClient>().X() for common workflows. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class FoundryAgentExtensions +{ + /// + /// Converts the supplied into a + /// ready to publish via AgentAdministrationClient.CreateAgentVersionAsync. + /// + /// + /// The Agent Endpoint construction mode (Mode 3) is not convertible because no local + /// definition exists; conversion in that case throws . + /// + /// The Foundry agent to convert. + /// A token that can cancel an internal server-side fetch when the agent was constructed from a bare . + /// A suitable for publishing. + /// is . + /// The agent's chat client is not a ; the agent was constructed via the Agent Endpoint mode (Mode 3); no model id is set on the agent's for the Responses Agent mode (Mode 1); or the agent contains an that cannot be converted to a ResponseTool. + public static Task ToPromptAgentAsync(this FoundryAgent agent, CancellationToken cancellationToken = default) + { + Throw.IfNull(agent); + + var innerChatClient = agent.GetService() + ?? throw new InvalidOperationException( + "ToPromptAgentAsync could not resolve the inner IChatClient on the FoundryAgent."); + var chatOptions = agent.GetService(); + return FoundryPromptAgentConverter.ConvertAsync(innerChatClient, chatOptions, cancellationToken); + } + + /// + /// Uploads a file to the project. Thin forwarder to + /// + /// on the agent's inner . + /// + /// The Foundry agent whose inner chat client owns the upload pipeline. + /// Path to the file to upload. + /// The upload purpose (e.g. ). + /// A token that can cancel the upload. + /// is . + /// The agent does not expose a via . + public static Task UploadFileAsync(this FoundryAgent agent, string filePath, FileUploadPurpose purpose, CancellationToken cancellationToken = default) + => RequireFoundryChatClient(agent).UploadFileAsync(filePath, purpose, cancellationToken); + + /// + /// Deletes a previously uploaded file. Thin forwarder to + /// . + /// + /// The Foundry agent whose inner chat client owns the file pipeline. + /// The file id returned by . + /// A token that can cancel the delete. + /// is . + /// The agent does not expose a . + public static Task DeleteFileAsync(this FoundryAgent agent, string fileId, CancellationToken cancellationToken = default) + => RequireFoundryChatClient(agent).DeleteFileAsync(fileId, cancellationToken); + + /// + /// Uploads the supplied files, creates a vector store containing them, and waits until the + /// store leaves the in-progress state. Thin forwarder to + /// . + /// + /// The Foundry agent whose inner chat client owns the file and vector-store pipeline. + /// The vector store name. + /// Paths to files to upload and attach to the store. + /// Optional last-active-at expiration window. + /// Optional upper bound on the wait for the vector store to leave the in-progress state. Defaults to 5 minutes; pass to disable. + /// A token that can cancel the orchestration. + /// is . + /// The agent does not expose a . + /// The vector store did not leave the in-progress state within . + public static Task CreateVectorStoreAsync(this FoundryAgent agent, string name, IEnumerable filePaths, TimeSpan? expiresAfter = null, TimeSpan? pollingTimeout = null, CancellationToken cancellationToken = default) + => RequireFoundryChatClient(agent).CreateVectorStoreAsync(name, filePaths, expiresAfter, pollingTimeout, cancellationToken); + + /// + /// Deletes a vector store. Thin forwarder to + /// . + /// + /// The Foundry agent whose inner chat client owns the vector-store pipeline. + /// The vector store id. + /// A token that can cancel the delete. + /// is . + /// The agent does not expose a . + public static Task DeleteVectorStoreAsync(this FoundryAgent agent, string vectorStoreId, CancellationToken cancellationToken = default) + => RequireFoundryChatClient(agent).DeleteVectorStoreAsync(vectorStoreId, cancellationToken); + + private static FoundryChatClient RequireFoundryChatClient(FoundryAgent agent) + { + Throw.IfNull(agent); + return agent.GetService() + ?? throw new InvalidOperationException( + "FoundryAgent does not expose a FoundryChatClient via GetService(). " + + "File and vector-store helpers require the agent's inner chat client to be a FoundryChatClient."); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs new file mode 100644 index 0000000000..6d7144af18 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs @@ -0,0 +1,647 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Files; +using OpenAI.Responses; +using OpenAI.VectorStores; + +#pragma warning disable OPENAI001 + +namespace Microsoft.Agents.AI.Foundry; + +/// +/// Foundry chat-client decorator that unifies the three Foundry chat-client construction +/// modes (Responses Agent, Prompt Agent, Agent Endpoint) behind a single type and centralizes +/// Foundry-specific concerns: microsoft.foundry telemetry tagging, +/// agent-framework-dotnet/{version} User-Agent stamping, and (for Prompt Agents) +/// per-request payload mutation that injects the agent reference and strips per-request +/// overrides that the server owns. +/// +/// +/// +/// Replaces the previous AzureAIProjectChatClient and AzureAIProjectResponsesChatClient +/// decorators. All Foundry entry points (the public FoundryAgent constructors and the +/// AIProjectClientExtensions.AsAIAgent overloads) now construct a +/// internally, so telemetry and the agent-framework User-Agent +/// segment are uniform across paths. +/// +/// +/// The three construction modes are: +/// +/// +/// Responses Agent (Mode 1): direct Responses API call against a project-level model id; no server-side agent definition exists. Constructed from (AIProjectClient, modelId). +/// Prompt Agent (Mode 2): server-side agent definition (a , typically a ) invoked by against the project Responses URL. Constructed from , , or . +/// Agent Endpoint (Mode 3): invocation via the per-agent endpoint URL …/projects/{p}/agents/{name}/endpoint/protocols/openai. The agent behind the endpoint can be either a hosted (container-backed) agent or a Prompt Agent. Constructed from (Uri agentEndpoint, credential). +/// +/// +/// Note: "Hosted Agent" refers to a container-based runtime agent (see +/// Microsoft.Agents.AI.Foundry.Hosting) and is the kind of agent that may sit +/// behind an Agent Endpoint. It is not synonymous with the Agent Endpoint mode itself. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public sealed class FoundryChatClient : DelegatingChatClient +{ + private readonly ChatClientMetadata _metadata; + private readonly AIProjectClient? _aiProjectClient; + private readonly AgentReference? _agentReference; + private readonly ProjectsAgentVersion? _agentVersion; + private readonly ProjectsAgentRecord? _agentRecord; + private readonly ChatOptions? _baseChatOptions; + + /// + /// Initializes a new instance for the Responses Agent mode (Mode 1): direct Responses API + /// call against a project-level model id; no server-side agent definition exists. + /// + /// The project client. + /// The model deployment id. + internal FoundryChatClient(AIProjectClient aiProjectClient, string modelId) + : base(Throw.IfNull(aiProjectClient) + .GetProjectOpenAIClient() + .GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(modelId)) + .AsIChatClient()) + { + this._aiProjectClient = aiProjectClient; + this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId); + TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); + } + + /// + /// Initializes a new instance for the Prompt Agent mode (Mode 2): server-side agent + /// definition invoked by . + /// + internal FoundryChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? baseChatOptions) + : base(Throw.IfNull(aiProjectClient) + .GetProjectOpenAIClient() + .GetProjectResponsesClientForAgent(Throw.IfNull(agentReference)) + .AsIChatClient()) + { + this._aiProjectClient = aiProjectClient; + this._agentReference = agentReference; + this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId); + this._baseChatOptions = baseChatOptions; + this.AgentName = agentReference.Name; + TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); + } + + /// + /// Initializes a new instance for the Prompt Agent mode (Mode 2, record variant): + /// server-side agent definition invoked by record, resolving to the latest version. + /// + internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? baseChatOptions) + : this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), baseChatOptions) + { + this._agentRecord = agentRecord; + } + + /// + /// Initializes a new instance for the Prompt Agent mode (Mode 2, version variant): + /// server-side agent definition invoked by a specific version. + /// + internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? baseChatOptions) + : this( + aiProjectClient, + CreateAgentReference(Throw.IfNull(agentVersion)), + (agentVersion.Definition as DeclarativeAgentDefinition)?.Model, + baseChatOptions) + { + this._agentVersion = agentVersion; + } + + /// + /// Initializes a new instance for the Agent Endpoint mode (Mode 3): invocation via the + /// per-agent endpoint URL. Parses the URL into its per-agent + /// shape internally and forwards through the resulting + /// responses client. + /// + /// + /// The agent-specific endpoint URI. Must be of the shape + /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai. + /// + /// The authentication credential. + /// Optional per-agent client options. Endpoint and AgentName are owned by this ctor and overridden with values derived from . + internal FoundryChatClient(Uri agentEndpoint, AuthenticationTokenProvider credential, ProjectOpenAIClientOptions? clientOptions) + : this(BuildAgentEndpointInner(agentEndpoint, credential, clientOptions)) + { + } + + /// + /// Initializes a new instance for the Agent Endpoint mode (Mode 3) by reusing an existing + /// 's pipeline. Equivalent to the + /// + /// constructor but skips building a fresh per-agent pipeline: the project-level + /// on is used directly. + /// + /// The project client already configured at the project root containing . + /// The per-agent endpoint URI. Same shape constraints as the other agent-endpoint ctor. + /// Optional per-agent client options applied to the per-agent GetProjectResponsesClientForAgentEndpoint call. + internal FoundryChatClient(AIProjectClient aiProjectClient, Uri agentEndpoint, ProjectOpenAIClientOptions? clientOptions) + : this(BuildAgentEndpointInnerFromProjectClient(aiProjectClient, agentEndpoint, clientOptions)) + { + } + + private FoundryChatClient(AgentEndpointInner inner) + : base(inner.ChatClient) + { + this._aiProjectClient = inner.AIProjectClient; + this.AgentName = inner.AgentName; + this._metadata = new ChatClientMetadata("microsoft.foundry"); + TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); + } + + /// + /// Gets the agent name associated with this chat client. + /// + /// + /// Set in two cases: + /// + /// + /// + /// Prompt Agent mode (Mode 2): the value of supplied at + /// construction. + /// + /// + /// + /// + /// Agent Endpoint mode (Mode 3): the agent name segment parsed from the supplied agent + /// endpoint URI. + /// + /// + /// + /// + /// Returns for the Responses Agent mode (Mode 1) where no agent name + /// exists. + /// + /// + internal string? AgentName { get; } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + return (serviceKey is null && serviceType == typeof(ChatClientMetadata)) + ? this._metadata + : (serviceKey is null && serviceType == typeof(AIProjectClient)) + ? this._aiProjectClient + : (serviceKey is null && serviceType == typeof(AgentReference)) + ? this._agentReference + : (serviceKey is null && serviceType == typeof(ProjectsAgentVersion)) + ? this._agentVersion + : (serviceKey is null && serviceType == typeof(ProjectsAgentRecord)) + ? this._agentRecord + : base.GetService(serviceType, serviceKey); + } + + /// + public override async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + var effectiveOptions = this._agentReference is not null + ? this.GetAgentEnabledChatOptions(options) + : options; + + return await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var effectiveOptions = this._agentReference is not null + ? this.GetAgentEnabledChatOptions(options) + : options; + + await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false)) + { + yield return chunk; + } + } + + #region File and vector-store helpers (mirrors Python's foundry_chat_client surface) + + /// + /// Uploads a single file to the project for the supplied purpose. The upload is performed + /// against the project-level reachable via + /// , so this method works uniformly across all three + /// FoundryChatClient construction modes. + /// + /// Absolute or relative path to the file to upload. The file must exist. + /// The file upload purpose (e.g. ). + /// A token that can cancel the upload. + /// The created as returned by the service. + /// is . + /// The file at does not exist. + public async Task UploadFileAsync(string filePath, FileUploadPurpose purpose, CancellationToken cancellationToken = default) + { + Throw.IfNull(filePath); + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"File not found: '{filePath}'.", filePath); + } + + var fileClient = this.GetOpenAIFileClient(); + // Use the Stream overload to honor cancellation; the (string, purpose) overload has no + // CancellationToken parameter in the OpenAI SDK. + using var stream = File.OpenRead(filePath); + var result = await fileClient.UploadFileAsync(stream, Path.GetFileName(filePath), purpose, cancellationToken).ConfigureAwait(false); + return result.Value; + } + + /// Deletes a file previously uploaded to the project. + /// The file id returned by . + /// A token that can cancel the delete. + /// The deletion result. + /// is or whitespace. + public async Task DeleteFileAsync(string fileId, CancellationToken cancellationToken = default) + { + Throw.IfNullOrWhitespace(fileId); + var fileClient = this.GetOpenAIFileClient(); + var result = await fileClient.DeleteFileAsync(fileId, cancellationToken).ConfigureAwait(false); + return result.Value; + } + + /// + /// Uploads the supplied files, creates a vector store containing them, waits until the + /// store finishes ingesting its files (status leaves ), + /// and returns the . Mirrors Python's + /// foundry_chat_client.create_vector_store(name, files, expires_after_days). + /// + /// The vector store name. + /// Paths to files to upload and attach to the store. + /// Optional last-active-at expiration window. When supplied, the vector store expires this many days after its last use. + /// Optional upper bound on the wait for the vector store to leave . Defaults to 5 minutes when not supplied; pass to disable. Independent of : cancellation always wins. + /// A token that can cancel the orchestration. + /// The created and fully-ready . The returned instance reflects the state observed after polling completes; it may be in (typical), , or any other terminal status returned by the service. Only is polled. + /// + /// + /// File-upload semantics are best-effort: when one of the per-file uploads throws, this method + /// makes a best-effort attempt to delete the files it has already uploaded so they do not + /// accumulate as orphaned resources on the project, then rethrows the original exception. The + /// cleanup itself does not throw — its failures are silently ignored because the caller is + /// already receiving a more meaningful exception from the original upload failure. + /// + /// + /// Cancellation aborts the polling loop with an ; any + /// already-uploaded files and the partially-created vector store remain on the project and are + /// the caller's responsibility to clean up. The same applies when the polling timeout elapses + /// (a is thrown instead). + /// + /// + /// is or whitespace, or is . + /// The vector store did not leave within . + public async Task CreateVectorStoreAsync(string name, IEnumerable filePaths, TimeSpan? expiresAfter = null, TimeSpan? pollingTimeout = null, CancellationToken cancellationToken = default) + { + Throw.IfNullOrWhitespace(name); + Throw.IfNull(filePaths); + + var fileIds = new List(); + try + { + foreach (var path in filePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + var uploaded = await this.UploadFileAsync(path, FileUploadPurpose.Assistants, cancellationToken).ConfigureAwait(false); + fileIds.Add(uploaded.Id); + } + } + catch + { + // Q-B: best-effort cleanup of files already uploaded before the mid-loop failure so + // they do not accumulate as orphaned resources on the project. Swallow cleanup + // exceptions — the caller is already going to see the original upload exception, and + // there is nothing useful we can do with a secondary delete failure. + await this.BestEffortDeleteFilesAsync(fileIds).ConfigureAwait(false); + throw; + } + + var options = new VectorStoreCreationOptions + { + Name = name, + }; + foreach (var id in fileIds) + { + options.FileIds.Add(id); + } + if (expiresAfter is { } window) + { + options.ExpirationPolicy = new VectorStoreExpirationPolicy(VectorStoreExpirationAnchor.LastActiveAt, (int)Math.Ceiling(window.TotalDays)); + } + + var vectorStoreClient = this.GetVectorStoreClient(); + var createResult = await vectorStoreClient.CreateVectorStoreAsync(options, cancellationToken).ConfigureAwait(false); + var created = createResult.Value; + + // Q-A: poll until the vector store leaves the in-progress state. Without this the helper + // hands the caller a vector store whose file ingestion may still be running, defeating + // the purpose of the one-call wrapper. + return await WaitForVectorStoreReadyAsync(vectorStoreClient, created, pollingTimeout ?? s_defaultPollingTimeout, cancellationToken).ConfigureAwait(false); + } + + private async Task BestEffortDeleteFilesAsync(IEnumerable fileIds) + { + foreach (var id in fileIds) + { + try + { + // Pass CancellationToken.None: cleanup runs in the catch path; the caller's + // token may already be cancelled and we still want to do our best to free + // orphaned resources before propagating the original exception. + await this.DeleteFileAsync(id, CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Silently ignore cleanup failures; see XML doc on CreateVectorStoreAsync. + } + } + } + + /// Upper bound on when the caller does not supply one. Chosen to comfortably cover normal Foundry vector-store ingestion (seconds to a minute for modest file sets) while still surfacing a clear failure if the server is stuck. + private static readonly TimeSpan s_defaultPollingTimeout = TimeSpan.FromMinutes(5); + + private static async Task WaitForVectorStoreReadyAsync(VectorStoreClient client, VectorStore initial, TimeSpan timeout, CancellationToken cancellationToken) + { + if (initial.Status != VectorStoreStatus.InProgress) + { + return initial; + } + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var delay = TimeSpan.FromMilliseconds(250); + var maxDelay = TimeSpan.FromSeconds(2); + var current = initial; + while (current.Status == VectorStoreStatus.InProgress) + { + if (timeout != Timeout.InfiniteTimeSpan && stopwatch.Elapsed >= timeout) + { + throw new TimeoutException( + $"Vector store '{current.Id}' did not leave the in-progress state within {timeout.TotalSeconds:0.##} seconds."); + } + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + var refreshed = await client.GetVectorStoreAsync(current.Id, cancellationToken).ConfigureAwait(false); + current = refreshed.Value; + + if (delay < maxDelay) + { + var next = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); + delay = next < maxDelay ? next : maxDelay; + } + } + + return current; + } + + /// Deletes a vector store. The associated files (if any) are not deleted by this method; call separately to clean them up. + /// The vector store id. + /// A token that can cancel the delete. + /// The deletion result. + /// is or whitespace. + public async Task DeleteVectorStoreAsync(string vectorStoreId, CancellationToken cancellationToken = default) + { + Throw.IfNullOrWhitespace(vectorStoreId); + var vectorStoreClient = this.GetVectorStoreClient(); + var result = await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId, cancellationToken).ConfigureAwait(false); + return result.Value; + } + + private OpenAIFileClient GetOpenAIFileClient() + { + var projectClient = this._aiProjectClient + ?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient."); + return projectClient.GetProjectOpenAIClient().GetOpenAIFileClient(); + } + + private VectorStoreClient GetVectorStoreClient() + { + var projectClient = this._aiProjectClient + ?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient."); + return projectClient.GetProjectOpenAIClient().GetVectorStoreClient(); + } + + #endregion + + /// + /// Parses an agent endpoint URI of shape + /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai + /// and returns the agent name and the derived project-root URI. + /// + /// + /// Tolerates trailing slash, casing variants on /agents/ and the suffix segment, and + /// strips query string and fragment. Throws for inputs that + /// do not match the expected shape. + /// + /// + /// The endpoint is missing the /agents/ segment, has an empty agent name, or has a + /// suffix other than /endpoint/protocols/openai. + /// + internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint) + { + Throw.IfNull(agentEndpoint); + + const string AgentsSegment = "/agents/"; + const string ExpectedSuffix = "/endpoint/protocols/openai"; + + var path = agentEndpoint.AbsolutePath.TrimEnd('/'); + var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + throw new ArgumentException( + $"Expected an agent endpoint of shape 'https:///.../projects//agents//endpoint/protocols/openai' but got '{agentEndpoint}'. " + + "If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.", + nameof(agentEndpoint)); + } + + var afterAgents = path.Substring(idx + AgentsSegment.Length); + var nextSlash = afterAgents.IndexOf('/'); + if (nextSlash <= 0) + { + throw new ArgumentException( + $"Agent endpoint '{agentEndpoint}' is missing the '{ExpectedSuffix}' suffix.", + nameof(agentEndpoint)); + } + + var agentName = afterAgents.Substring(0, nextSlash); + var suffix = afterAgents.Substring(nextSlash); + if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.", + nameof(agentEndpoint)); + } + + var rootPath = path.Substring(0, idx); + var projectRoot = new UriBuilder(agentEndpoint) + { + Path = rootPath, + Query = string.Empty, + Fragment = string.Empty, + }.Uri; + + return (agentName, projectRoot); + } + + private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options) + { + // Start with a clone of the base chat options defined for the agent, if any. + ChatOptions agentEnabledChatOptions = this._baseChatOptions?.Clone() ?? new(); + + // Ignore per-request all options that can't be overridden. + agentEnabledChatOptions.Instructions = null; + agentEnabledChatOptions.Tools = null; + agentEnabledChatOptions.Temperature = null; + agentEnabledChatOptions.TopP = null; + agentEnabledChatOptions.PresencePenalty = null; + agentEnabledChatOptions.ResponseFormat = null; + + // Use the conversation from the request, or the one defined at the client level. + agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._baseChatOptions?.ConversationId; + + // Preserve the original RawRepresentationFactory. + var originalFactory = options?.RawRepresentationFactory; + + agentEnabledChatOptions.RawRepresentationFactory = (client) => + { + if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions) + { + responseCreationOptions = new CreateResponseOptions(); + } + + responseCreationOptions.Agent = this._agentReference; +#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. + responseCreationOptions.Patch.Remove("$.model"u8); +#pragma warning restore SCME0001 + + return responseCreationOptions; + }; + + return agentEnabledChatOptions; + } + + private static AgentReference CreateAgentReference(ProjectsAgentVersion agentVersion) + { + // If the version is null, empty, or whitespace, use "latest" as the default. This handles + // cases where hosted agents (like MCP agents) may not have a version assigned. + var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version; + return new AgentReference(agentVersion.Name, version); + } + + private static AgentEndpointInner BuildAgentEndpointInner( + Uri agentEndpoint, + AuthenticationTokenProvider credential, + ProjectOpenAIClientOptions? clientOptions) + { + Throw.IfNull(agentEndpoint); + Throw.IfNull(credential); + + var (agentName, projectRoot) = ParseAgentEndpoint(agentEndpoint); + + var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions(); + perAgentOptions.Endpoint = agentEndpoint; + perAgentOptions.AgentName = agentName; + + var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope); + var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions); + + var chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient(); + + // Materialize a project-level AIProjectClient from the parsed project root so + // GetService() returns non-null for all FoundryChatClient + // construction modes. Project-level helpers (file upload, vector store create/delete) + // depend on this. RBAC for those calls is at the project level; if the supplied + // credential lacks project-scope permissions, the SDK surfaces a clean 401/403 at + // call time. The four observable primitive ClientPipelineOptions properties are + // propagated from the caller's per-agent options bag so test-injected transports and + // explicit RetryPolicy / NetworkTimeout / UserAgentApplicationId reach the + // project-level pipeline. Pipeline policies added via AddPolicy on the caller bag are + // NOT propagated because ClientPipelineOptions does not publicly enumerate policies. + var aiProjectClientOptions = new AIProjectClientOptions(); + if (clientOptions is not null) + { + if (clientOptions.RetryPolicy is not null) + { + aiProjectClientOptions.RetryPolicy = clientOptions.RetryPolicy; + } + if (clientOptions.NetworkTimeout is not null) + { + aiProjectClientOptions.NetworkTimeout = clientOptions.NetworkTimeout; + } + if (clientOptions.Transport is not null) + { + aiProjectClientOptions.Transport = clientOptions.Transport; + } + if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId)) + { + aiProjectClientOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId; + } + } + var aiProjectClient = new AIProjectClient(projectRoot, credential, aiProjectClientOptions); + + return new AgentEndpointInner(chatClient, aiProjectClient, agentName); + } + + private static AgentEndpointInner BuildAgentEndpointInnerFromProjectClient( + AIProjectClient aiProjectClient, + Uri agentEndpoint, + ProjectOpenAIClientOptions? clientOptions) + { + Throw.IfNull(aiProjectClient); + Throw.IfNull(agentEndpoint); + + var (agentName, _) = ParseAgentEndpoint(agentEndpoint); + + var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions(); + perAgentOptions.Endpoint = agentEndpoint; + perAgentOptions.AgentName = agentName; + + var chatClient = aiProjectClient.GetProjectOpenAIClient() + .GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions) + .AsIChatClient(); + + // Reuse the caller's AIProjectClient verbatim — no new pipeline is materialized. + return new AgentEndpointInner(chatClient, aiProjectClient, agentName); + } + + /// Best-effort registration of via the MEAI hook with at-most-once dedup per pipeline. + private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerClient) + { + if (innerClient?.GetService() is { } policies) + { + // OpenAIRequestPoliciesReflection.AddPolicyIfMissing performs a check-then-add against + // the private _entries collection on the OpenAIRequestPolicies instance, so the + // policy is registered at most once even when many FoundryChatClient instances share + // the same underlying chat client. + OpenAIRequestPoliciesReflection.AddPolicyIfMissing( + policies, + AgentFrameworkUserAgentPolicy.Instance, + PipelinePosition.PerCall); + } + } + + /// Default OAuth scope for the Azure AI resource. Matches the scope used by Azure.AI.Extensions.OpenAI's internal authentication helper so the bearer token is accepted by the Foundry control plane. + private const string AzureAiResourceScope = "https://ai.azure.com/.default"; + + private readonly struct AgentEndpointInner + { + public AgentEndpointInner(IChatClient chatClient, AIProjectClient aiProjectClient, string agentName) + { + this.ChatClient = chatClient; + this.AIProjectClient = aiProjectClient; + this.AgentName = agentName; + } + + public IChatClient ChatClient { get; } + public AIProjectClient AIProjectClient { get; } + public string AgentName { get; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryPromptAgentConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryPromptAgentConverter.cs new file mode 100644 index 0000000000..5cf7f71580 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryPromptAgentConverter.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 + +namespace Microsoft.Agents.AI.Foundry; + +/// +/// Shared internal implementation behind the public ToPromptAgentAsync extension methods +/// on and . Converts a Foundry-backed +/// agent into a ready to publish via +/// . +/// +/// +/// +/// Dispatch by construction mode (reachable via +/// ): +/// +/// +/// Responses Agent (Mode 1): synthesize a from the agent's . +/// Prompt Agent (Mode 2, cached version): return the cached . +/// Prompt Agent (Mode 2, AgentReference-only): fetch the latest version from the service and return its definition. +/// Agent Endpoint (Mode 3): throw — no local definition exists to convert. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +internal static class FoundryPromptAgentConverter +{ + /// Performs the conversion for an agent whose chat client and chat options are supplied. + /// The chat client extracted from the calling agent (must surface a via ). + /// The agent's chat options (model id, instructions, temperature, top-p, tools). Required for the Responses Agent mode; ignored for the Prompt Agent mode. + /// A token that can cancel a server-side fetch (Prompt Agent AgentReference path). + /// A suitable for AgentAdministrationClient.CreateAgentVersionAsync. + /// Thrown when the chat client is not Foundry-backed, the agent was constructed via the Agent Endpoint mode, no model id is set for the Responses Agent mode, or an unsupported is encountered. + public static async Task ConvertAsync(IChatClient chatClient, ChatOptions? chatOptions, CancellationToken cancellationToken) + { + Throw.IfNull(chatClient); + + var foundryChatClient = chatClient.GetService() + ?? throw new InvalidOperationException( + "ToPromptAgentAsync requires a FoundryChatClient-backed agent. " + + "The supplied agent's chat client does not expose a FoundryChatClient via GetService()."); + + // Prompt Agent (Mode 2) with a cached server-side version (constructed via ProjectsAgentVersion or ProjectsAgentRecord). + if (foundryChatClient.GetService() is { } cachedVersion) + { + return cachedVersion.Definition; + } + + // Prompt Agent (Mode 2) AgentReference-only: fetch the agent definition from the service. + // Honor a pinned AgentReference.Version when present (Q-C fix); fall back to the latest + // version only when the reference is unpinned ("", null, or "latest"). + if (foundryChatClient.GetService() is { } agentReference) + { + var aiProjectClient = foundryChatClient.GetService() + ?? throw new InvalidOperationException( + "Cannot fetch the agent version because the FoundryChatClient does not expose an AIProjectClient."); + + if (!string.IsNullOrWhiteSpace(agentReference.Version) + && !string.Equals(agentReference.Version, "latest", StringComparison.OrdinalIgnoreCase)) + { + var pinnedVersion = await aiProjectClient.AgentAdministrationClient + .GetAgentVersionAsync(agentReference.Name, agentReference.Version, cancellationToken) + .ConfigureAwait(false); + return pinnedVersion.Value.Definition; + } + + var record = await aiProjectClient.AgentAdministrationClient + .GetAgentAsync(agentReference.Name, cancellationToken) + .ConfigureAwait(false); + return record.Value.GetLatestVersion().Definition; + } + + // Agent Endpoint (Mode 3): AgentName is set (parsed from URL) but no AgentReference exists + // locally. The agent definition lives only on the server and is not retrievable through this + // chat client, so conversion is not supported here. + if (foundryChatClient.AgentName is not null) + { + throw new InvalidOperationException( + "ToPromptAgentAsync is not supported for agents constructed via the Agent Endpoint mode (Mode 3); " + + "no local definition exists to convert."); + } + + // Responses Agent (Mode 1): synthesize from ChatOptions. + return SynthesizeFromChatOptions(chatOptions); + } + + private static DeclarativeAgentDefinition SynthesizeFromChatOptions(ChatOptions? chatOptions) + { + if (chatOptions is null || string.IsNullOrWhiteSpace(chatOptions.ModelId)) + { + throw new InvalidOperationException( + "ToPromptAgentAsync requires a model id on the agent's ChatOptions to synthesize a prompt agent definition."); + } + + var definition = new DeclarativeAgentDefinition(chatOptions.ModelId!) + { + Instructions = chatOptions.Instructions, + Temperature = chatOptions.Temperature, + TopP = chatOptions.TopP, + }; + + if (chatOptions.Tools is { Count: > 0 } tools) + { + foreach (var tool in tools) + { + definition.Tools.Add(ConvertTool(tool)); + } + } + + return definition; + } + + private static ResponseTool ConvertTool(AITool tool) + { + Throw.IfNull(tool); + + if (tool is AIFunction function) + { + // strictModeEnabled is intentionally true to match the Python spec's + // default behavior. JsonSchema on AIFunction is a JsonElement; serialize via its + // string form so the payload matches what callers pass elsewhere in this codebase. + return ResponseTool.CreateFunctionTool( + function.Name, + BinaryData.FromString(function.JsonSchema.ToString() ?? "{}"), + strictModeEnabled: true, + function.Description); + } + + if (tool.GetService(typeof(ResponseTool)) is ResponseTool responseTool) + { + return responseTool; + } + + throw new InvalidOperationException( + $"Cannot convert AITool of type '{tool.GetType().Name}' to a ResponseTool. " + + "Only AIFunction and AITool instances that wrap a ResponseTool (such as those produced by FoundryAITool factories) are supported."); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs deleted file mode 100644 index e00025b7ee..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Reflection; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI; - -internal static class RequestOptionsExtensions -{ - /// Gets the singleton that adds a MEAI user-agent header. - internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance; - - /// Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header. - private sealed class MeaiUserAgentPolicy : PipelinePolicy - { - public static MeaiUserAgentPolicy Instance { get; } = new MeaiUserAgentPolicy(); - - private static readonly string s_userAgentValue = CreateUserAgentValue(); - - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - AddUserAgentHeader(message); - ProcessNext(message, pipeline, currentIndex); - } - - public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - AddUserAgentHeader(message); - return ProcessNextAsync(message, pipeline, currentIndex); - } - - private static void AddUserAgentHeader(PipelineMessage message) => - message.Request.Headers.Add("User-Agent", s_userAgentValue); - - private static string CreateUserAgentValue() - { - const string Name = "MEAI"; - - if (typeof(MeaiUserAgentPolicy).Assembly.GetCustomAttribute()?.InformationalVersion is string version) - { - int pos = version.IndexOf('+'); - if (pos >= 0) - { - version = version.Substring(0, pos); - } - - if (version.Length > 0) - { - return $"{Name}/{version}"; - } - } - - return Name; - } - } -} diff --git a/dotnet/tests/Foundry.IntegrationTests/FoundryAgentExtensionsTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryAgentExtensionsTests.cs new file mode 100644 index 0000000000..c5f5f3b9c5 --- /dev/null +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryAgentExtensionsTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using OpenAI.Files; +using OpenAI.Responses; +using OpenAI.VectorStores; +using Shared.IntegrationTests; + +namespace Foundry.IntegrationTests; + +/// +/// Integration tests for the file and vector-store forwarder extensions on +/// declared in . End-to-end +/// counterparts of the unit tests in +/// FoundryAgentExtensionsTests that exercise the live Foundry project pipeline. +/// +/// +/// Mirrors +/// in shape (file upload → vector store creation → FileSearchTool answer → cleanup), but routes +/// every helper call through the new extensions instead of the raw +/// projectOpenAIClient.GetProjectFilesClient() / GetProjectVectorStoresClient() +/// path. Skipped by default for the same reasons as the existing vector-store IT (cost and +/// runtime); flip Skip to run manually after seeding the right Foundry project. +/// +public class FoundryAgentExtensionsTests +{ + private readonly AIProjectClient _client = new( + new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), + TestAzureCliCredentials.CreateAzureCliCredential()); + + [Fact(Skip = "For manual testing only")] + public async Task UploadFileAsync_ViaAgentExtension_UploadsToProjectAsync() + { + // Arrange — non-versioned Responses Agent (Mode 1) so we do not have to provision a server-side agent. + var agent = this._client.AsAIAgent( + model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + instructions: "Be helpful."); + var foundryAgent = this.WrapAsFoundryAgent(agent); + + var filePath = Path.GetTempFileName() + ".txt"; + File.WriteAllText(filePath, "agent-extensions integration test payload"); + + OpenAIFile? uploaded = null; + try + { + // Act. + uploaded = await foundryAgent.UploadFileAsync(filePath, FileUploadPurpose.Assistants); + + // Assert. + Assert.NotNull(uploaded); + Assert.False(string.IsNullOrEmpty(uploaded.Id)); + Assert.Equal(Path.GetFileName(filePath), uploaded.Filename); + } + finally + { + if (uploaded is not null) + { + await foundryAgent.DeleteFileAsync(uploaded.Id); + } + + File.Delete(filePath); + } + } + + [Fact(Skip = "For manual testing only")] + public async Task DeleteFileAsync_ViaAgentExtension_RemovesUploadedFileAsync() + { + var agent = this._client.AsAIAgent( + model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + instructions: "Be helpful."); + var foundryAgent = this.WrapAsFoundryAgent(agent); + + var filePath = Path.GetTempFileName() + ".txt"; + File.WriteAllText(filePath, "delete-me payload"); + + try + { + var uploaded = await foundryAgent.UploadFileAsync(filePath, FileUploadPurpose.Assistants); + + // Act. + var result = await foundryAgent.DeleteFileAsync(uploaded.Id); + + // Assert. + Assert.NotNull(result); + Assert.Equal(uploaded.Id, result.FileId); + Assert.True(result.Deleted); + } + finally + { + File.Delete(filePath); + } + } + + [Fact(Skip = "For manual testing only")] + public async Task CreateVectorStoreAsync_ViaAgentExtension_BuildsStoreAndAnswersFileSearchQuestionAsync() + { + // Mirrors CreateAgent_CreatesAgentWithVectorStoresAsync but the upload-then-create-store + // sequence routes through the FoundryAgent.CreateVectorStoreAsync extension (single call + // that uploads, creates the store, and polls until ready). The resulting vector store id + // is then wired to a versioned agent's FileSearch tool and queried for a known value. + string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("VectorStoreExtAgent"); + const string AgentInstructions = """ + You are a helpful agent that can help fetch data from files you know about. + Use the File Search Tool to look up codes for words. + Do not answer a question unless you can find the answer using the File Search Tool. + """; + + // Non-versioned helper agent that owns the upload pipeline. + var helperAgent = this._client.AsAIAgent( + model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + instructions: "Be helpful."); + var helperFoundryAgent = this.WrapAsFoundryAgent(helperAgent); + + var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt"; + File.WriteAllText(searchFilePath, "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457."); + + VectorStore? vectorStore = null; + FoundryAgent? versionedAgent = null; + try + { + // Act — single agent-level helper call uploads, creates, and waits until ready. + vectorStore = await helperFoundryAgent.CreateVectorStoreAsync( + "WordCodeLookup_ExtensionVectorStore", + new[] { searchFilePath }); + + Assert.NotNull(vectorStore); + Assert.False(string.IsNullOrEmpty(vectorStore.Id)); + Assert.NotEqual(VectorStoreStatus.InProgress, vectorStore.Status); + + // Wire the store id into a versioned agent's FileSearch tool to prove it is actually usable. + var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = AgentInstructions, + Tools = { ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStore.Id]) }, + }; + + var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync( + AgentName, + new ProjectsAgentVersionCreationOptions(definition)); + + versionedAgent = this._client.AsAIAgent(agentVersion); + + // Assert. + var result = await versionedAgent.RunAsync("Can you give me the documented code for 'banana'?"); + Assert.Contains("673457", result.ToString()); + } + finally + { + if (versionedAgent is not null) + { + await this._client.AgentAdministrationClient.DeleteAgentAsync(versionedAgent.Name); + } + + // Cleanup the vector store via the new extension too. + if (vectorStore is not null) + { + await helperFoundryAgent.DeleteVectorStoreAsync(vectorStore.Id); + } + + File.Delete(searchFilePath); + } + } + + [Fact(Skip = "For manual testing only")] + public async Task DeleteVectorStoreAsync_ViaAgentExtension_RemovesStoreAsync() + { + var agent = this._client.AsAIAgent( + model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + instructions: "Be helpful."); + var foundryAgent = this.WrapAsFoundryAgent(agent); + + var filePath = Path.GetTempFileName() + ".txt"; + File.WriteAllText(filePath, "delete-store payload"); + + VectorStore? vectorStore = null; + try + { + vectorStore = await foundryAgent.CreateVectorStoreAsync( + "DeleteVectorStore_ExtensionTest", + new[] { filePath }); + + // Act. + var result = await foundryAgent.DeleteVectorStoreAsync(vectorStore.Id); + + // Assert. + Assert.NotNull(result); + Assert.Equal(vectorStore.Id, result.VectorStoreId); + Assert.True(result.Deleted); + vectorStore = null; + } + finally + { + if (vectorStore is not null) + { + await foundryAgent.DeleteVectorStoreAsync(vectorStore.Id); + } + + File.Delete(filePath); + } + } + + /// + /// Resolves the underlying from an handle + /// returned by AIProjectClient.AsAIAgent(model, instructions). The Mode 1 overload + /// returns a ; the extension forwarders we test live on + /// , so callers wanting them through this entry point need to + /// reach for the FoundryAgent constructor instead. This helper makes the test setup + /// consistent across the four IT scenarios. + /// + private FoundryAgent WrapAsFoundryAgent(AIAgent agent) + { + // The Mode 1 AsAIAgent overload returns ChatClientAgent rather than FoundryAgent; use + // the FoundryAgent projectEndpoint+model+instructions ctor to get the same underlying + // FoundryChatClient surfaced through a FoundryAgent typed handle. + _ = agent; + return new FoundryAgent( + projectEndpoint: new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), + credential: TestAzureCliCredentials.CreateAzureCliCredential(), + model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), + instructions: "Be helpful."); + } +} diff --git a/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs index 015877df05..200df19b16 100644 --- a/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/FoundryVersionedAgentStructuredOutputRunTests.cs @@ -11,7 +11,7 @@ namespace Foundry.IntegrationTests; public class FoundryVersionedAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new FoundryVersionedAgentStructuredOutputFixture()) { private const string NotSupported = "Versioned Foundry agents do not support specifying structured output type at invocation time."; - private const string ResponseFormatNotSupported = "AzureAIProjectChatClient clears ResponseFormat for versioned agents; structured output must be defined in the server-side agent definition."; + private const string ResponseFormatNotSupported = "FoundryChatClient clears ResponseFormat for versioned agents; structured output must be defined in the server-side agent definition."; /// /// Verifies that response format provided at agent initialization is used when invoking RunAsync. @@ -41,7 +41,7 @@ public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResul /// /// /// Versioned Foundry agents do not support specifying the structured output type at invocation time yet. - /// The type T provided to RunAsync<T> is ignored by AzureAIProjectChatClient and is only used + /// The type T provided to RunAsync<T> is ignored by FoundryChatClient and is only used /// for deserializing the agent response by AgentResponse<T>.Result. /// [RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)] diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedOutboundUserAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedOutboundUserAgentTests.cs index 844911055f..090633f7d5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedOutboundUserAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedOutboundUserAgentTests.cs @@ -64,15 +64,29 @@ public async Task Hosted_InboundResponsesRequest_TriggersOutboundCall_WithFoundr var inboundBody = await inboundResponse.Content.ReadAsStringAsync(); // Assert: at least one OUTBOUND request reached the fake transport, AND it carries the - // foundry-hosting/agent-framework-dotnet/{version} supplement on its User-Agent. - // (We don't care about the inbound response shape — only that the agent's call to MEAI - // triggered an outbound request whose UA reaches the sandbox boundary correctly.) + // combined hosted segment foundry-hosting/agent-framework-dotnet/{version} on its + // User-Agent. This matches Python's contract + // (foundry-hosting/agent-framework-python/{version}, see + // python/packages/core/agent_framework/_telemetry.py): a single combined segment when + // hosted, never two separate ones. The bare agent-framework-dotnet/{version} segment + // (from AgentFrameworkUserAgentPolicy in FoundryChatClient) must be upgraded in place + // by HostedAgentUserAgentPolicy — never appear duplicated. Assert.True(this._outboundHandler!.Requests.Count > 0, $"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}"); var outbound = this._outboundHandler.Requests[0]; Assert.StartsWith(TestEndpoint, outbound.Uri); Assert.Contains("MEAI/", outbound.UserAgent); - Assert.Contains("foundry-hosting/agent-framework-dotnet", outbound.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet/", outbound.UserAgent); + + // The bare agent-framework-dotnet/{v} segment must NOT appear separately when the + // combined form is present — Python emits a single combined value when the hosted + // prefix is registered, and .NET preserves that contract via the in-place upgrade in + // HostedAgentUserAgentPolicy. + var combinedIdx = outbound.UserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal); + var beforeCombined = outbound.UserAgent.Substring(0, combinedIdx); + var afterCombined = outbound.UserAgent.Substring(combinedIdx + "foundry-hosting/agent-framework-dotnet/".Length); + Assert.DoesNotContain("agent-framework-dotnet/", beforeCombined); + Assert.DoesNotContain("agent-framework-dotnet/", afterCombined); } private async Task StartHostedServerAsync() @@ -197,6 +211,179 @@ private static int EntriesCount(OpenAIRequestPolicies policies) return array?.Length ?? -1; } + // ----------------------------------------------------------------------- + // Direct unit tests for HostedAgentUserAgentPolicy's in-place upgrade behavior. + // These run the policy on a synthetic ClientPipeline (no hosting infrastructure) + // so the upgrade logic itself can be asserted in isolation. + // ----------------------------------------------------------------------- + + [Fact] + public async Task HostedAgentUserAgentPolicy_UpgradesBareAgentFrameworkSegment_InPlaceAsync() + { + // Arrange: an upstream per-call policy stamps the bare agent-framework-dotnet/{version} + // segment (matching what AgentFrameworkUserAgentPolicy would write in non-hosted code). + // Then HostedAgentUserAgentPolicy runs and must REPLACE that segment with the combined + // foundry-hosting/agent-framework-dotnet/{version} form, not append a duplicate. + using var handler = new InspectingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [new SetUserAgentPolicy("agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.Instance], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert: combined form is present; bare form is gone (no duplicate agent-framework segment). + Assert.NotNull(handler.LastUserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent); + var ua = handler.LastUserAgent!; + var firstAgentFramework = ua.IndexOf("agent-framework-dotnet/", StringComparison.Ordinal); + Assert.True(firstAgentFramework >= 0, "Expected agent-framework-dotnet segment."); + var secondAgentFramework = ua.IndexOf("agent-framework-dotnet/", firstAgentFramework + 1, StringComparison.Ordinal); + Assert.Equal(-1, secondAgentFramework); + } + + [Fact] + public async Task HostedAgentUserAgentPolicy_AppendsCombined_WhenNoBareSegmentPresentAsync() + { + // Arrange: nothing upstream stamps the bare segment. Hosted policy should append the + // full combined segment to whatever User-Agent is on the wire. + using var handler = new InspectingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [HostedAgentUserAgentPolicy.Instance], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert + Assert.NotNull(handler.LastUserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet/", handler.LastUserAgent); + } + + [Fact] + public async Task HostedAgentUserAgentPolicy_IsIdempotent_WhenCombinedSegmentAlreadyPresentAsync() + { + // Arrange: upstream pre-populates the combined segment (simulating a retry or duplicate + // registration). Hosted policy must not re-append. + using var handler = new InspectingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [new SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/9.9.9"), HostedAgentUserAgentPolicy.Instance], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert: exactly one occurrence of "foundry-hosting/agent-framework-dotnet/" segment. + Assert.NotNull(handler.LastUserAgent); + var first = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal); + Assert.True(first >= 0); + var second = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", first + 1, StringComparison.Ordinal); + Assert.Equal(-1, second); + } + + [Fact] + public async Task HostedAgentUserAgentPolicy_ReplacesDifferentVersionCombinedSegment_InPlaceAsync() + { + // Q-D regression: when the User-Agent already carries the COMBINED hosted form with a + // different version (e.g. an older registration or caller-supplied baseline), the policy + // must replace the entire combined span — not just the bare suffix — so we never emit + // the malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` shape. + using var handler = new InspectingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [new SetUserAgentPolicy("foundry-hosting/agent-framework-dotnet/0.0.1 MEAI/10.5.1"), HostedAgentUserAgentPolicy.Instance], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert: no doubled foundry-hosting/ prefix. + Assert.NotNull(handler.LastUserAgent); + Assert.DoesNotContain("foundry-hosting/foundry-hosting/", handler.LastUserAgent, StringComparison.Ordinal); + + // The combined segment must appear exactly once, and the trailing MEAI segment must be + // preserved in place (i.e. the policy only rewrote the combined span, not anything after it). + var firstCombined = handler.LastUserAgent!.IndexOf("foundry-hosting/agent-framework-dotnet/", StringComparison.Ordinal); + Assert.True(firstCombined >= 0); + var secondCombined = handler.LastUserAgent.IndexOf("foundry-hosting/agent-framework-dotnet/", firstCombined + 1, StringComparison.Ordinal); + Assert.Equal(-1, secondCombined); + Assert.Contains(" MEAI/10.5.1", handler.LastUserAgent, StringComparison.Ordinal); + + // And the version that survives must be the runtime supplement value's version, not 0.0.1. + Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet/0.0.1", handler.LastUserAgent, StringComparison.Ordinal); + } + + private sealed class InspectingHandler : HttpClientHandler + { + public string? LastUserAgent { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values) + ? string.Join(",", values) + : null; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + RequestMessage = request, + }); + } + } + + private sealed class SetUserAgentPolicy : PipelinePolicy + { + private readonly string _value; + public SetUserAgentPolicy(string value) => this._value = value; + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set("User-Agent", this._value); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set("User-Agent", this._value); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } + private sealed class NoopHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AIProjectClientExtensionsTests.cs similarity index 93% rename from dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AIProjectClientExtensionsTests.cs index 2996be725d..f4da9e6b77 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AIProjectClientExtensionsTests.cs @@ -23,9 +23,9 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests; #pragma warning disable CS0618 /// -/// Unit tests for the class. +/// Unit tests for the class. /// -public sealed class AzureAIProjectChatClientExtensionsTests +public sealed class AIProjectClientExtensionsTests { #region AsAIAgent(AIProjectClient, model, instructions) Tests @@ -71,7 +71,11 @@ public void AsAIAgent_Rapi_WithModelAndInstructions_CreatesChatClientAgent() Assert.Equal("test-agent", agent.Name); Assert.Equal("A test agent", agent.Description); Assert.NotNull(agent.GetService()); - Assert.Null(agent.GetService()); + // After the FoundryChatClient consolidation the inner chat-client now exposes the + // AIProjectClient via GetService — Foundry callers can walk to the project client from + // the agent without holding their own reference. (Previously this path returned null + // because AsAIAgent(model, instructions) skipped the decorator entirely.) + Assert.NotNull(agent.GetService()); } /// @@ -123,7 +127,10 @@ public void AsAIAgent_Rapi_WithOptions_CreatesChatClientAgent() Assert.NotNull(agent); Assert.Equal("options-agent", agent.Name); Assert.Equal("Agent from options", agent.Description); - Assert.Null(agent.GetService()); + // After the FoundryChatClient consolidation the inner chat-client now exposes the + // AIProjectClient via GetService — see twin assertion in + // AsAIAgent_Rapi_WithModelAndInstructions_CreatesChatClientAgent for the rationale. + Assert.NotNull(agent.GetService()); } /// @@ -185,6 +192,106 @@ public async Task AsAIAgent_Rapi_WithModelAndInstructions_UserAgentHeaderAddedTo Assert.True(userAgentFound, "MEAI user-agent header was not found in any request"); } + /// + /// Verify that the non-versioned AsAIAgent overload now wraps with FoundryChatClient + /// (regression-prevention for the previously-untagged extension path). + /// + [Fact] + public void AsAIAgent_Rapi_WithModelAndInstructions_ExposesFoundryChatClientAndProviderName() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + + // Act + ChatClientAgent agent = client.AsAIAgent("gpt-4o-mini", "You are helpful."); + + // Assert: FoundryChatClient is internal-sealed and reachable via GetService(). + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + + // Provider tag is "microsoft.foundry" (previously this path had no Foundry tag at all). + var metadata = chatClient!.GetService(); + Assert.NotNull(metadata); + Assert.Equal("microsoft.foundry", metadata!.ProviderName); + Assert.Equal("gpt-4o-mini", metadata.DefaultModelId); + + // Reaching the FoundryChatClient by type (via InternalsVisibleTo). + Assert.NotNull(agent.GetService()); + } + + /// + /// Verify that the options-based non-versioned AsAIAgent overload now wraps with FoundryChatClient. + /// + [Fact] + public void AsAIAgent_Rapi_WithOptions_ExposesFoundryChatClientAndProviderName() + { + // Arrange + AIProjectClient client = this.CreateTestAgentClient(); + ChatClientAgentOptions options = new() + { + Name = "options-agent", + ChatOptions = new ChatOptions { ModelId = "gpt-4o-mini", Instructions = "x" }, + }; + + // Act + ChatClientAgent agent = client.AsAIAgent(options); + + // Assert + var chatClient = agent.GetService(); + Assert.NotNull(chatClient); + var metadata = chatClient!.GetService(); + Assert.NotNull(metadata); + Assert.Equal("microsoft.foundry", metadata!.ProviderName); + Assert.NotNull(agent.GetService()); + } + + /// + /// Verify that the non-versioned AsAIAgent overload stamps the + /// agent-framework-dotnet/{version} segment on outbound requests via the new + /// AgentFrameworkUserAgentPolicy registered by FoundryChatClient. + /// + [Fact] + public async Task AsAIAgent_Rapi_WithModelAndInstructions_StampsAgentFrameworkUserAgentSegmentAsync() + { + bool afSeen = false; + using HttpHandlerAssert httpHandler = new(request => + { + if (request.Headers.TryGetValues("User-Agent", out IEnumerable? values)) + { + foreach (string value in values) + { + if (value.Contains("agent-framework-dotnet/")) + { + afSeen = true; + } + } + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") + }; + }); + +#pragma warning disable CA5399 + using HttpClient httpClient = new(httpHandler); +#pragma warning restore CA5399 + + AIProjectClient aiProjectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + ChatClientAgent agent = aiProjectClient.AsAIAgent("gpt-4o-mini", "You are helpful."); + + // Act + AgentSession session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + + // Assert + Assert.True(afSeen, "Expected agent-framework-dotnet/{version} segment on outbound requests from AsAIAgent(model, instructions)."); + } + #endregion #region AsAIAgent(AIProjectClient, ProjectsAgentRecord) Tests diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AgentFrameworkUserAgentPolicyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AgentFrameworkUserAgentPolicyTests.cs new file mode 100644 index 0000000000..94999cfb64 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AgentFrameworkUserAgentPolicyTests.cs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Verifies the framework-wide . The policy stamps +/// agent-framework-dotnet/{version} onto the outgoing User-Agent header of every +/// request made through a Foundry chat client and is registered automatically by +/// FoundryChatClient via the MEAI OpenAIRequestPolicies hook. +/// +public sealed class AgentFrameworkUserAgentPolicyTests +{ + [Fact] + public async Task AgentFrameworkUserAgentPolicy_AddsAgentFrameworkSegment_ToOutgoingRequestAsync() + { + // Arrange + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert + Assert.Equal(1, handler.Count); + Assert.NotNull(handler.LastUserAgent); + Assert.Contains("agent-framework-dotnet/", handler.LastUserAgent); + } + + [Fact] + public async Task AgentFrameworkUserAgentPolicy_DoesNotStampMeaiSegmentAsync() + { + // Arrange: the AF policy must only contribute the agent-framework-dotnet segment. + // The MEAI/{version} segment is contributed by the MEAI-shipped policy at a different + // layer; this policy must not duplicate or replace it. + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert + Assert.NotNull(handler.LastUserAgent); + Assert.DoesNotContain("MEAI/", handler.LastUserAgent); + Assert.DoesNotContain("foundry-hosting/", handler.LastUserAgent); + } + + [Fact] + public async Task AgentFrameworkUserAgentPolicy_PreservesExistingUserAgent_WhenAppendingAsync() + { + // Arrange: a per-call policy upstream that pre-populates the User-Agent header. The AF + // policy must read the existing value and append (not overwrite) the agent-framework + // segment so both stay reachable on the wire. (The exact separator the HTTP transport + // emits between multi-value User-Agent entries is comma per RFC 7230; this test does + // not assert on the separator character because that is a transport detail.) + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [new SeedUserAgentPolicy("existing-app/1.0"), AgentFrameworkUserAgentPolicy.Instance], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert: both segments survive to the wire. + Assert.NotNull(handler.LastUserAgent); + Assert.Contains("existing-app/1.0", handler.LastUserAgent); + Assert.Contains("agent-framework-dotnet/", handler.LastUserAgent); + } + + [Fact] + public async Task AgentFrameworkUserAgentPolicy_IsIdempotent_DoesNotDoubleStampAsync() + { + // Arrange: register the same policy twice on the same pipeline. The second application + // must detect the segment is already present and not append it again. Guards against + // double-stamping on retries or duplicate registration. + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [AgentFrameworkUserAgentPolicy.Instance, AgentFrameworkUserAgentPolicy.Instance], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert: exactly one occurrence of "agent-framework-dotnet/". + Assert.NotNull(handler.LastUserAgent); + var ua = handler.LastUserAgent!; + var first = ua.IndexOf("agent-framework-dotnet/", StringComparison.Ordinal); + Assert.True(first >= 0, "Expected at least one agent-framework-dotnet segment."); + var second = ua.IndexOf("agent-framework-dotnet/", first + 1, StringComparison.Ordinal); + Assert.Equal(-1, second); + } + + [Fact] + public void AgentFrameworkUserAgentPolicy_ExposesSingletonInstance() + { + // Two reads of the static property must return the same instance. The policy is stateless + // and shared; allocating a fresh instance per registration site would bloat memory and + // defeat the dedup logic in OpenAIRequestPoliciesReflection.AddPolicyIfMissing. + var first = AgentFrameworkUserAgentPolicy.Instance; + var second = AgentFrameworkUserAgentPolicy.Instance; + Assert.Same(first, second); + } + + [Fact] + public void AgentFrameworkUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard() + { + // The policy emits "agent-framework-dotnet/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}". + // If the assembly metadata stops being readable, the policy falls back to "agent-framework-dotnet" + // without a version, which is a measurable telemetry regression. + var attr = typeof(AgentFrameworkUserAgentPolicy).Assembly + .GetCustomAttribute(); + Assert.NotNull(attr); + Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion)); + } + + private sealed class RecordingHandler : HttpClientHandler + { + public int Count { get; private set; } + public string? LastUserAgent { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.Count++; + this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values) + ? string.Join(",", values) + : null; + + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + return Task.FromResult(resp); + } + } + + private sealed class SeedUserAgentPolicy : PipelinePolicy + { + private readonly string _value; + public SeedUserAgentPolicy(string value) => this._value = value; + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set("User-Agent", this._value); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set("User-Agent", this._value); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientTests.cs deleted file mode 100644 index e3461d8191..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/AzureAIProjectChatClientTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.ClientModel.Primitives; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; - -namespace Microsoft.Agents.AI.Foundry.UnitTests; - -#pragma warning disable CS0618 -public class AzureAIProjectChatClientTests -{ - /// - /// Verify that after the first RunAsync, the session's ConversationId is set from the - /// response, and subsequent requests include that conversation ID automatically. - /// - [Fact] - public async Task ChatClient_UsesDefaultConversationIdAsync() - { - // Arrange - var responsesRequestCount = 0; - using var httpHandler = new HttpHandlerAssert(async (request) => - { - if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) - { - responsesRequestCount++; - - // Assert: On the second Responses API call, verify the conversation ID - // from the first response is automatically included in the request body. - if (responsesRequestCount == 2 && request.Content is not null) - { - var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - Assert.Contains("resp_0888a", requestBody); - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient projectClient = new( - new Uri("https://test.openai.azure.com/"), - new FakeAuthenticationTokenProvider(), - new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - - var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); - - // Act - var session = await agent.CreateSessionAsync(); - await agent.RunAsync("Hello", session); - await agent.RunAsync("Follow up", session); - - // Assert - Assert.Equal(2, responsesRequestCount); - var chatClientSession = Assert.IsType(session); - Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId); - } - - /// - /// Verify that when the chat client doesn't have a default "conv_" conversation id, the chat client still uses the conversation ID in HTTP requests. - /// - [Fact] - public async Task ChatClient_UsesPerRequestConversationId_WhenNoDefaultConversationIdIsProvidedAsync() - { - // Arrange - var requestTriggered = false; - using var httpHandler = new HttpHandlerAssert(async (request) => - { - if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) - { - requestTriggered = true; - - // Assert - if (request.Content is not null) - { - var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - Assert.Contains("conv_12345", requestBody); - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient projectClient = new( - new Uri("https://test.openai.azure.com/"), - new FakeAuthenticationTokenProvider(), - new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - - var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); - - // Act - var session = await agent.CreateSessionAsync(); - await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } }); - - Assert.True(requestTriggered); - var chatClientSession = Assert.IsType(session); - Assert.Equal("conv_12345", chatClientSession.ConversationId); - } - - /// - /// Verify that even when the chat client has a default conversation id, the chat client will prioritize the per-request conversation id provided in HTTP requests. - /// - [Fact] - public async Task ChatClient_UsesPerRequestConversationId_EvenWhenDefaultConversationIdIsProvidedAsync() - { - // Arrange - var requestTriggered = false; - using var httpHandler = new HttpHandlerAssert(async (request) => - { - if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) - { - requestTriggered = true; - - // Assert - if (request.Content is not null) - { - var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - Assert.Contains("conv_12345", requestBody); - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient projectClient = new( - new Uri("https://test.openai.azure.com/"), - new FakeAuthenticationTokenProvider(), - new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - - var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); - - // Act - var session = await agent.CreateSessionAsync(); - await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } }); - - Assert.True(requestTriggered); - var chatClientSession = Assert.IsType(session); - Assert.Equal("conv_12345", chatClientSession.ConversationId); - } - - /// - /// Verify that when the chat client is provided without a "conv_" prefixed conversation ID, the chat client uses the previous conversation ID in HTTP requests. - /// - [Fact] - public async Task ChatClient_UsesPreviousResponseId_WhenConversationIsNotPrefixedAsConvAsync() - { - // Arrange - var requestTriggered = false; - using var httpHandler = new HttpHandlerAssert(async (request) => - { - if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) - { - requestTriggered = true; - - // Assert - if (request.Content is not null) - { - var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - Assert.Contains("resp_0888a", requestBody); - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; - }); - -#pragma warning disable CA5399 - using var httpClient = new HttpClient(httpHandler); -#pragma warning restore CA5399 - - AIProjectClient projectClient = new( - new Uri("https://test.openai.azure.com/"), - new FakeAuthenticationTokenProvider(), - new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); - - var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); - - // Act - var session = await agent.CreateSessionAsync(); - await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "resp_0888a" } }); - - Assert.True(requestTriggered); - var chatClientSession = Assert.IsType(session); - Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId); - } -} -#pragma warning restore CS0618 diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentExtensionsTests.cs new file mode 100644 index 0000000000..27979c1c6d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentExtensionsTests.cs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Azure.AI.Projects; +using OpenAI.Files; + +#pragma warning disable OPENAI001, CS0618 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Unit tests for the file and vector-store forwarder extensions on +/// declared in . The forwarders are thin shims over the +/// inner , so coverage focuses on (a) request shape (the agent +/// path reaches the same wire as a direct chat-client call), (b) null/missing-FoundryChatClient +/// handling, and (c) returns the same payload the chat client would. +/// +public sealed class FoundryAgentExtensionsTests +{ + private static readonly Uri s_testProjectEndpoint = new("https://test.openai.azure.com/"); + + [Fact] + public async Task UploadFileAsync_Forwards_ToInnerFoundryChatClient_Async() + { + // Arrange — agent built via the Responses Agent (Mode 1) projectEndpoint+model+instructions + // ctor wires a FoundryChatClient inside that the extension can resolve via GetService. + var sawPostToFiles = false; + using var handler = new HttpHandlerAssert(req => + { + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal)) + { + sawPostToFiles = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(FakeFileJson("file_via_agent"), Encoding.UTF8, "application/json"), + }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + + var agent = new FoundryAgent( + projectEndpoint: s_testProjectEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Be helpful.", + clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + + var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"fae-{Guid.NewGuid():N}.txt"); + System.IO.File.WriteAllText(path, "hello"); + + try + { + // Act — call the forwarder on the agent. + var result = await agent.UploadFileAsync(path, FileUploadPurpose.Assistants); + + // Assert + Assert.True(sawPostToFiles, "POST to /files must reach the wire through the agent forwarder."); + Assert.Equal("file_via_agent", result.Id); + } + finally + { + System.IO.File.Delete(path); + } + } + + [Fact] + public async Task DeleteFileAsync_Forwards_ToInnerFoundryChatClient_Async() + { + var sawDelete = false; + using var handler = new HttpHandlerAssert(req => + { + if (req.Method == HttpMethod.Delete && req.RequestUri!.AbsolutePath.Contains("/files/", StringComparison.Ordinal)) + { + sawDelete = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":\"file_abc\",\"object\":\"file\",\"deleted\":true}", Encoding.UTF8, "application/json"), + }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + + var agent = new FoundryAgent( + projectEndpoint: s_testProjectEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Be helpful.", + clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + + var result = await agent.DeleteFileAsync("file_abc"); + + Assert.True(sawDelete); + Assert.NotNull(result); + } + + [Fact] + public async Task CreateVectorStoreAsync_Forwards_ToInnerFoundryChatClient_Async() + { + var sawVectorStorePost = false; + using var handler = new HttpHandlerAssert(req => + { + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/vector_stores", StringComparison.Ordinal)) + { + sawVectorStorePost = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(FakeVectorStoreJson("vs_via_agent", "kb"), Encoding.UTF8, "application/json"), + }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + + var agent = new FoundryAgent( + projectEndpoint: s_testProjectEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Be helpful.", + clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + + var store = await agent.CreateVectorStoreAsync("kb", Array.Empty()); + + Assert.True(sawVectorStorePost); + Assert.Equal("vs_via_agent", store.Id); + } + + [Fact] + public async Task DeleteVectorStoreAsync_Forwards_ToInnerFoundryChatClient_Async() + { + var sawDelete = false; + using var handler = new HttpHandlerAssert(req => + { + if (req.Method == HttpMethod.Delete && req.RequestUri!.AbsolutePath.Contains("/vector_stores/", StringComparison.Ordinal)) + { + sawDelete = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":\"vs_abc\",\"object\":\"vector_store.deleted\",\"deleted\":true}", Encoding.UTF8, "application/json"), + }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + + var agent = new FoundryAgent( + projectEndpoint: s_testProjectEndpoint, + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Be helpful.", + clientOptions: new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + + await agent.DeleteVectorStoreAsync("vs_abc"); + + Assert.True(sawDelete); + } + + [Fact] + public async Task UploadFileAsync_NullAgent_ThrowsArgumentNullExceptionAsync() + => await Assert.ThrowsAsync(() => + FoundryAgentExtensions.UploadFileAsync(null!, "x", FileUploadPurpose.Assistants)); + + [Fact] + public async Task DeleteFileAsync_NullAgent_ThrowsArgumentNullExceptionAsync() + => await Assert.ThrowsAsync(() => + FoundryAgentExtensions.DeleteFileAsync(null!, "file_abc")); + + [Fact] + public async Task CreateVectorStoreAsync_NullAgent_ThrowsArgumentNullExceptionAsync() + => await Assert.ThrowsAsync(() => + FoundryAgentExtensions.CreateVectorStoreAsync(null!, "kb", Array.Empty())); + + [Fact] + public async Task DeleteVectorStoreAsync_NullAgent_ThrowsArgumentNullExceptionAsync() + => await Assert.ThrowsAsync(() => + FoundryAgentExtensions.DeleteVectorStoreAsync(null!, "vs_abc")); + + // ----- Helpers ----- + + private static string FakeFileJson(string id) + => $"{{\"id\":\"{id}\",\"object\":\"file\",\"bytes\":11,\"created_at\":1700000000,\"filename\":\"x.txt\",\"purpose\":\"assistants\",\"status\":\"processed\"}}"; + + private static string FakeVectorStoreJson(string id, string name) + => $"{{\"id\":\"{id}\",\"object\":\"vector_store\",\"created_at\":1700000000,\"name\":\"{name}\",\"usage_bytes\":0,\"file_counts\":{{\"in_progress\":0,\"completed\":0,\"failed\":0,\"cancelled\":0,\"total\":0}},\"status\":\"completed\",\"last_active_at\":1700000000}}"; +} +#pragma warning restore CS0618 diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs index 11f36c2797..1d88809e9f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs @@ -2,7 +2,6 @@ using System; using System.ClientModel.Primitives; -using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text; @@ -352,18 +351,24 @@ public void Constructor_WithChatClientFactory_AppliesFactory() } [Fact] - public async Task Constructor_UserAgentHeaderAddedToRequestsAsync() + public async Task Constructor_AgentFrameworkUserAgentHeaderAddedToRequestsAsync() { - bool userAgentFound = false; + // After the FoundryChatClient consolidation, every outbound request from a + // FoundryAgent-built chat client carries the new agent-framework-dotnet/{version} + // segment (stamped by AgentFrameworkUserAgentPolicy registered via the MEAI + // OpenAIRequestPolicies hook). The local MEAI/{version} stamp was removed because + // MEAI 10.5.1 stamps that itself; this test only verifies the framework-wide segment + // that the Foundry package now guarantees. + bool agentFrameworkUserAgentFound = false; using HttpHandlerAssert httpHandler = new(request => { - if (request.Headers.TryGetValues("User-Agent", out IEnumerable? values)) + if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable? values)) { foreach (string value in values) { - if (value.StartsWith("MEAI/", StringComparison.OrdinalIgnoreCase)) + if (value.Contains("agent-framework-dotnet/")) { - userAgentFound = true; + agentFrameworkUserAgentFound = true; } } } @@ -396,7 +401,7 @@ public async Task Constructor_UserAgentHeaderAddedToRequestsAsync() AgentSession session = await agent.CreateSessionAsync(); await agent.RunAsync("Hello", session); - Assert.True(userAgentFound, "Expected MEAI user-agent header to be present in requests."); + Assert.True(agentFrameworkUserAgentFound, "Expected agent-framework-dotnet user-agent segment to be present on outbound requests."); } #endregion @@ -434,6 +439,9 @@ public void AgentEndpointConstructor_PopulatesNameAndIdFromEndpointSlug() [Fact] public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull() { + // Behavior change: FoundryAgent no longer caches a ProjectOpenAIClient. Callers + // retrieve it from the AIProjectClient themselves + // (agent.GetService()!.GetProjectOpenAIClient()). FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); Assert.Null(agent.GetService()); @@ -442,6 +450,10 @@ public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull() [Fact] public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull() { + // Behavior change: after Plan #2's Agent Endpoint mode (Mode 3) AIProjectClient materialization, the + // agent-endpoint constructor now derives a project-level AIProjectClient from the + // parsed project root URL and surfaces it via GetService. Previously this returned + // null because no AIProjectClient was constructed for hosted-agent-endpoint agents. FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); Assert.NotNull(agent.GetService()); @@ -450,6 +462,7 @@ public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull() [Fact] public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull() { + // See AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull for rationale. FoundryAgent agent = new( s_testEndpoint, new FakeAuthenticationTokenProvider(), @@ -611,6 +624,57 @@ public async Task AgentEndpointConstructor_StampsMeaiUserAgentHeaderAsync() Assert.True(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline."); } + [Fact] + public void AgentEndpointConstructor_ExposesFoundryProviderName_OnChatClientMetadata() + { + // Behavior change: after the FoundryChatClient consolidation, the agent-endpoint path + // now wraps with FoundryChatClient in the Agent Endpoint mode (Mode 3) and stamps the microsoft.foundry provider + // name. Previously this path used a bare AsIChatClient() with no Foundry-specific + // decorator, so the provider name defaulted to whatever MEAI surfaces. This guards the + // new behavior. + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); + + var metadata = agent.GetService(); + Assert.NotNull(metadata); + Assert.Equal("microsoft.foundry", metadata!.ProviderName); + } + + [Fact] + public async Task AgentEndpointConstructor_StampsAgentFrameworkUserAgentSegmentAsync() + { + // Behavior change: after the FoundryChatClient consolidation, every outbound request + // from the agent-endpoint constructor carries the agent-framework-dotnet/{version} + // segment via AgentFrameworkUserAgentPolicy. Previously this path had no + // agent-framework branding at all. + bool afSeen = false; + using HttpHandlerAssert handler = new(req => + { + if (req.Headers.TryGetValues("User-Agent", out var values)) + { + foreach (string v in values) + { + if (v.Contains("agent-framework-dotnet/")) + { + afSeen = true; + } + } + } + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using HttpClient http = new(handler); +#pragma warning restore CA5399 + ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) }; + + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + await agent.RunAsync("Hello"); + + Assert.True(afSeen, "Expected agent-framework-dotnet/{version} segment on the agent-endpoint outbound User-Agent."); + } + [Fact] public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync() { @@ -666,82 +730,22 @@ public void AgentEndpointConstructor_OverridesCallerEndpointAndAgentName() } [Fact] - public void AgentEndpointConstructor_PreservesUserAgentApplicationId() + public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient() { + // The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's + // application-id stamp in the outbound request. Verify the value is propagated onto the + // caller's options bag and that the materialized AIProjectClient is reachable so + // downstream conversation/file/vector-store operations can pick the application id up. ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" }; FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts); + AIProjectClient? aiProjectClient = agent.GetService(); + Assert.NotNull(aiProjectClient); // Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim. - Assert.NotNull(agent); Assert.Equal("my-app-id", opts.UserAgentApplicationId); } - [Fact] - public void CreateProjectClientOptions_NullCallerOptions_ReturnsNull() - { - Assert.Null(FoundryAgent.CreateProjectClientOptions(null)); - } - - [Fact] - public void CreateProjectClientOptions_CarriesPipelineSettingsAndUserAgent() - { - // Arrange - var transport = new FakePipelineTransport(); - var retryPolicy = new FakeRetryPolicy(); - var messageLoggingPolicy = new FakeMessageLoggingPolicy(); - var clientLoggingOptions = new ClientLoggingOptions { EnableLogging = false }; - var networkTimeout = TimeSpan.FromSeconds(42); - - ProjectOpenAIClientOptions callerOptions = new() - { - UserAgentApplicationId = "my-app-id", - Transport = transport, - RetryPolicy = retryPolicy, - MessageLoggingPolicy = messageLoggingPolicy, - ClientLoggingOptions = clientLoggingOptions, - NetworkTimeout = networkTimeout, - }; - - // Act - AIProjectClientOptions? projectOptions = FoundryAgent.CreateProjectClientOptions(callerOptions); - - // Assert: every settable pipeline behavior the caller configured is forwarded - // onto the project-level options bag, not silently dropped. - Assert.NotNull(projectOptions); - Assert.Equal("my-app-id", projectOptions!.UserAgentApplicationId); - Assert.Same(transport, projectOptions.Transport); - Assert.Same(retryPolicy, projectOptions.RetryPolicy); - Assert.Same(messageLoggingPolicy, projectOptions.MessageLoggingPolicy); - Assert.Same(clientLoggingOptions, projectOptions.ClientLoggingOptions); - Assert.Equal(networkTimeout, projectOptions.NetworkTimeout); - } - - private sealed class FakeRetryPolicy : PipelinePolicy - { - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - => ProcessNext(message, pipeline, currentIndex); - - public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - => ProcessNextAsync(message, pipeline, currentIndex); - } - - private sealed class FakeMessageLoggingPolicy : PipelinePolicy - { - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - => ProcessNext(message, pipeline, currentIndex); - - public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - => ProcessNextAsync(message, pipeline, currentIndex); - } - - private sealed class FakePipelineTransport : PipelineTransport - { - protected override PipelineMessage CreateMessageCore() => throw new NotSupportedException(); - protected override void ProcessCore(PipelineMessage message) => throw new NotSupportedException(); - protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new NotSupportedException(); - } - #endregion #region ParseAgentEndpoint tests @@ -824,13 +828,13 @@ private sealed class HeaderStampPolicy : PipelinePolicy private readonly string _value; public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; } - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList pipeline, int currentIndex) { message.Request.Headers.Set(this._name, this._value); ProcessNext(message, pipeline, currentIndex); } - public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList pipeline, int currentIndex) { message.Request.Headers.Set(this._name, this._value); return ProcessNextAsync(message, pipeline, currentIndex); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs new file mode 100644 index 0000000000..f075d80857 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs @@ -0,0 +1,616 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; + +#pragma warning disable OPENAI001, CS0618 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Unit tests for the internal . Covers the three construction +/// modes (Responses Agent, Prompt Agent, Agent Endpoint), the GetService +/// returns per mode, the metadata-tagging contract, the agent-framework user-agent registration, +/// the Agent Endpoint mode (Mode 3) URL parsing happy and error paths, and end-to-end behavior through the public +/// AsAIAgent(AgentReference) extension that constructs a FoundryChatClient internally. +/// +public sealed class FoundryChatClientTests +{ + #region the Responses Agent mode (Mode 1): Responses Agent (AIProjectClient + modelId) + + [Fact] + public void Mode1_ResponsesAgent_StampsFoundryProviderName() + { + // Arrange + var projectClient = CreateProjectClient(); + + // Act + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + // Assert + var metadata = chatClient.GetService(); + Assert.NotNull(metadata); + Assert.Equal("microsoft.foundry", metadata!.ProviderName); + Assert.Equal("gpt-4o-mini", metadata.DefaultModelId); + } + + [Fact] + public void Mode1_ResponsesAgent_ExposesAIProjectClient_ViaGetService() + { + // Arrange + var projectClient = CreateProjectClient(); + + // Act + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + // Assert + Assert.Same(projectClient, chatClient.GetService()); + // ProjectOpenAIClient is intentionally NOT exposed via GetService — callers retrieve + // it from the AIProjectClient themselves (aiProjectClient.GetProjectOpenAIClient()). + Assert.Null(chatClient.GetService()); + } + + [Fact] + public void Mode1_ResponsesAgent_ReturnsNullForAgentSpecificServices() + { + // Arrange + var projectClient = CreateProjectClient(); + + // Act + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + // Assert + Assert.Null(chatClient.GetService()); + Assert.Null(chatClient.GetService()); + Assert.Null(chatClient.GetService()); + // No agent name exists in the Responses Agent mode (Mode 1) — only the Prompt Agent mode (Mode 2) (from AgentReference.Name) and the Agent Endpoint mode (Mode 3) + // (parsed from URL) populate FoundryChatClient.AgentName. + Assert.Null(chatClient.AgentName); + } + + [Fact] + public void Mode1_ResponsesAgent_ThrowsOnNullProjectClient() + => Assert.Throws(() => new FoundryChatClient(aiProjectClient: null!, "gpt-4o-mini")); + + [Fact] + public void Mode1_ResponsesAgent_ThrowsOnEmptyModelId() + => Assert.Throws(() => new FoundryChatClient(CreateProjectClient(), modelId: "")); + + #endregion + + #region the Prompt Agent mode (Mode 2): Prompt Agent (direct unit tests) + + [Fact] + public void Mode2_PromptAgent_StampsFoundryProviderNameAndDefaultModelId() + { + // Arrange + var projectClient = CreateProjectClient(); + var agentRef = new AgentReference("agent-name", "1"); + + // Act + var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: "gpt-4o", baseChatOptions: null); + + // Assert + var metadata = chatClient.GetService(); + Assert.NotNull(metadata); + Assert.Equal("microsoft.foundry", metadata!.ProviderName); + Assert.Equal("gpt-4o", metadata.DefaultModelId); + } + + [Fact] + public void Mode2_PromptAgent_ExposesAgentReference_ViaGetService() + { + // Arrange + var projectClient = CreateProjectClient(); + var agentRef = new AgentReference("agent-name", "1"); + + // Act + var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: null, baseChatOptions: null); + + // Assert + Assert.Same(agentRef, chatClient.GetService()); + Assert.Same(projectClient, chatClient.GetService()); + // ProjectOpenAIClient is intentionally NOT exposed via GetService — see comment in + // Mode1_ResponsesAgent_ExposesAIProjectClient_ViaGetService. + Assert.Null(chatClient.GetService()); + // Version/Record were not provided via this ctor. + Assert.Null(chatClient.GetService()); + Assert.Null(chatClient.GetService()); + } + + [Fact] + public void Mode2_PromptAgent_PopulatesAgentNameFromAgentReference() + { + // Arrange + var projectClient = CreateProjectClient(); + var agentRef = new AgentReference("my-server-side-agent", "1"); + + // Act + var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: null, baseChatOptions: null); + + // Assert: AgentName is general-purpose across the Prompt Agent (Mode 2) and Agent Endpoint (Mode 3) modes. In the Prompt Agent mode (Mode 2) it mirrors + // AgentReference.Name so callers have a uniform handle regardless of construction mode. + Assert.Equal("my-server-side-agent", chatClient.AgentName); + } + + [Fact] + public void Mode2_PromptAgent_AllowsNullDefaultModelIdAndBaseChatOptions() + { + // Arrange + var projectClient = CreateProjectClient(); + var agentRef = new AgentReference("agent-name", "1"); + + // Act + Assert: must not throw; defaultModelId and baseChatOptions are optional. + var chatClient = new FoundryChatClient(projectClient, agentRef, defaultModelId: null, baseChatOptions: null); + Assert.NotNull(chatClient); + } + + [Fact] + public void Mode2_PromptAgent_ThrowsOnNullAgentReference() + => Assert.Throws(() => + new FoundryChatClient(CreateProjectClient(), agentReference: null!, defaultModelId: null, baseChatOptions: null)); + + #endregion + + #region the Prompt Agent mode (Mode 2): Prompt Agent end-to-end round-trip via AsAIAgent(AgentReference) extension + + // The end-to-end tests below exercise the same FoundryChatClient mode-2 behaviors above, + // but through the public AsAIAgent(AgentReference) extension that constructs a FoundryChatClient + // internally. They focus on the conversation-id handling that only manifests through the + // ChatClientAgentSession surface, which requires a fully assembled agent rather than a bare + // chat client. + + /// + /// Verify that after the first RunAsync, the session's ConversationId is set from the + /// response, and subsequent requests include that conversation ID automatically. + /// + [Fact] + public async Task EndToEnd_AgentReference_UsesDefaultConversationIdAsync() + { + // Arrange + var responsesRequestCount = 0; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + responsesRequestCount++; + + // Assert: On the second Responses API call, verify the conversation ID + // from the first response is automatically included in the request body. + if (responsesRequestCount == 2 && request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("resp_0888a", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); + + // Act + var session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session); + await agent.RunAsync("Follow up", session); + + // Assert + Assert.Equal(2, responsesRequestCount); + var chatClientSession = Assert.IsType(session); + Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId); + } + + /// + /// Verify that when the chat client doesn't have a default "conv_" conversation id, the chat client still uses the conversation ID in HTTP requests. + /// + [Fact] + public async Task EndToEnd_AgentReference_UsesPerRequestConversationId_WhenNoDefaultConversationIdIsProvidedAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("conv_12345", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); + + // Act + var session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } }); + + Assert.True(requestTriggered); + var chatClientSession = Assert.IsType(session); + Assert.Equal("conv_12345", chatClientSession.ConversationId); + } + + /// + /// Verify that even when the chat client has a default conversation id, the chat client will prioritize the per-request conversation id provided in HTTP requests. + /// + [Fact] + public async Task EndToEnd_AgentReference_UsesPerRequestConversationId_EvenWhenDefaultConversationIdIsProvidedAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("conv_12345", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); + + // Act + var session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "conv_12345" } }); + + Assert.True(requestTriggered); + var chatClientSession = Assert.IsType(session); + Assert.Equal("conv_12345", chatClientSession.ConversationId); + } + + /// + /// Verify that when the chat client is provided without a "conv_" prefixed conversation ID, the chat client uses the previous conversation ID in HTTP requests. + /// + [Fact] + public async Task EndToEnd_AgentReference_UsesPreviousResponseId_WhenConversationIsNotPrefixedAsConvAsync() + { + // Arrange + var requestTriggered = false; + using var httpHandler = new HttpHandlerAssert(async (request) => + { + if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses")) + { + requestTriggered = true; + + // Assert + if (request.Content is not null) + { + var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("resp_0888a", requestBody); + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetAgentResponseJson(), Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + AIProjectClient projectClient = new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); + + // Act + var session = await agent.CreateSessionAsync(); + await agent.RunAsync("Hello", session, options: new ChatClientAgentRunOptions() { ChatOptions = new() { ConversationId = "resp_0888a" } }); + + Assert.True(requestTriggered); + var chatClientSession = Assert.IsType(session); + Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId); + } + + #endregion + + #region the Agent Endpoint mode (Mode 3): Agent Endpoint + + [Fact] + public void Mode3_AgentEndpoint_ParsesAgentNameFromUrl() + { + // Arrange + Act + var chatClient = new FoundryChatClient( + agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"), + credential: new FakeAuthenticationTokenProvider(), + clientOptions: null); + + // Assert + Assert.Equal("myagent", chatClient.AgentName); + } + + [Fact] + public void Mode3_AgentEndpoint_StampsFoundryProviderName() + { + // Act + var chatClient = new FoundryChatClient( + agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"), + credential: new FakeAuthenticationTokenProvider(), + clientOptions: null); + + // Assert + var metadata = chatClient.GetService(); + Assert.NotNull(metadata); + Assert.Equal("microsoft.foundry", metadata!.ProviderName); + // No model id is knowable from the URL alone. + Assert.Null(metadata.DefaultModelId); + } + + [Fact] + public void Mode3_AgentEndpoint_ExposesProjectOpenAIClientAndAIProjectClient() + { + // Act + var chatClient = new FoundryChatClient( + agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"), + credential: new FakeAuthenticationTokenProvider(), + clientOptions: null); + + // Assert + // ProjectOpenAIClient is intentionally NOT exposed via GetService — callers retrieve + // it from the AIProjectClient themselves (aiProjectClient.GetProjectOpenAIClient()). + Assert.Null(chatClient.GetService()); + // After the materialization change, the Agent Endpoint mode (Mode 3) also exposes a working AIProjectClient + // built from the parsed project root. This makes the helper surface symmetric across + // all three construction modes. + Assert.NotNull(chatClient.GetService()); + Assert.Null(chatClient.GetService()); + Assert.Null(chatClient.GetService()); + Assert.Null(chatClient.GetService()); + } + + [Fact] + public void Mode3_AgentEndpoint_MaterializedAIProjectClient_TargetsParsedProjectRoot() + { + // The Agent Endpoint mode (Mode 3) ctor must derive the project root from the agent endpoint URL and + // construct the AIProjectClient against that root, NOT the agent endpoint itself. + var agentEndpoint = new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"); + var chatClient = new FoundryChatClient( + agentEndpoint: agentEndpoint, + credential: new FakeAuthenticationTokenProvider(), + clientOptions: null); + + var aiProjectClient = chatClient.GetService(); + Assert.NotNull(aiProjectClient); + // AIProjectClient does not expose its endpoint publicly, so we rely on reflection on + // the well-known private field. If the SDK field shape changes this guard fails loudly. + var field = typeof(AIProjectClient).GetField("_endpoint", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var actualEndpoint = (Uri)field!.GetValue(aiProjectClient!)!; + Assert.Equal("https://example.com/api/projects/myproj", actualEndpoint.AbsoluteUri.TrimEnd('/')); + } + + [Fact] + public void Mode3_AgentEndpoint_MaterializedAIProjectClient_IsReusedAcrossGetServiceCalls() + { + // Repeated GetService() calls must return the same instance — the + // materialized client is cached in the existing _aiProjectClient field, not built on + // demand each call. + var chatClient = new FoundryChatClient( + agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"), + credential: new FakeAuthenticationTokenProvider(), + clientOptions: null); + + var first = chatClient.GetService(); + var second = chatClient.GetService(); + Assert.NotNull(first); + Assert.Same(first, second); + } + + [Fact] + public void Mode1_ResponsesAgent_AIProjectClient_IsTheSuppliedInstance() + { + // Regression check: the Responses Agent mode (Mode 1) must continue to expose the AIProjectClient the caller + // supplied via the constructor, NOT a freshly-materialized one. + var supplied = CreateProjectClient(); + var chatClient = new FoundryChatClient(supplied, "gpt-4o-mini"); + Assert.Same(supplied, chatClient.GetService()); + } + + [Fact] + public void Mode2_PromptAgent_AIProjectClient_IsTheSuppliedInstance() + { + // Regression check: the Prompt Agent mode (Mode 2) must continue to expose the AIProjectClient the caller + // supplied via the constructor. + var supplied = CreateProjectClient(); + var agentRef = new AgentReference("agent-name", "1"); + var chatClient = new FoundryChatClient(supplied, agentRef, defaultModelId: null, baseChatOptions: null); + Assert.Same(supplied, chatClient.GetService()); + } + + [Fact] + public void Mode3_AgentEndpoint_ThrowsOnNullEndpoint() + => Assert.Throws(() => + new FoundryChatClient(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider(), clientOptions: null)); + + [Fact] + public void Mode3_AgentEndpoint_ThrowsOnNullCredential() + => Assert.Throws(() => + new FoundryChatClient( + agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"), + credential: null!, + clientOptions: null)); + + #endregion + + #region ParseAgentEndpoint URL parsing + + [Fact] + public void ParseAgentEndpoint_HappyPath_ReturnsAgentNameAndProjectRoot() + { + // Act + var (agentName, projectRoot) = FoundryChatClient.ParseAgentEndpoint( + new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai")); + + // Assert + Assert.Equal("myagent", agentName); + Assert.Equal("https://example.com/api/projects/myproj", projectRoot.AbsoluteUri.TrimEnd('/')); + } + + [Fact] + public void ParseAgentEndpoint_TolerantOfTrailingSlash() + { + // Act + var (agentName, _) = FoundryChatClient.ParseAgentEndpoint( + new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai/")); + + // Assert + Assert.Equal("myagent", agentName); + } + + [Fact] + public void ParseAgentEndpoint_TolerantOfCaseDifferencesOnAgentsSegment() + { + // Act + var (agentName, _) = FoundryChatClient.ParseAgentEndpoint( + new Uri("https://example.com/api/projects/myproj/AGENTS/myagent/endpoint/protocols/openai")); + + // Assert + Assert.Equal("myagent", agentName); + } + + [Fact] + public void ParseAgentEndpoint_StripsQueryAndFragment() + { + // Act + var (_, projectRoot) = FoundryChatClient.ParseAgentEndpoint( + new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai?api-version=v1#frag")); + + // Assert + Assert.Equal(string.Empty, projectRoot.Query); + Assert.Equal(string.Empty, projectRoot.Fragment); + } + + [Fact] + public void ParseAgentEndpoint_ThrowsOnMissingAgentsSegment() + => Assert.Throws(() => + FoundryChatClient.ParseAgentEndpoint(new Uri("https://example.com/api/projects/myproj/anyseg/myagent/endpoint/protocols/openai"))); + + [Fact] + public void ParseAgentEndpoint_ThrowsOnWrongSuffix() + => Assert.Throws(() => + FoundryChatClient.ParseAgentEndpoint(new Uri("https://example.com/api/projects/myproj/agents/myagent/wrong/suffix"))); + + [Fact] + public void ParseAgentEndpoint_ThrowsOnNullUri() + => Assert.Throws(() => FoundryChatClient.ParseAgentEndpoint(null!)); + + #endregion + + #region AgentFrameworkUserAgentPolicy registration + dedup + + [Fact] + public void Register_AgentFrameworkUserAgentPolicy_OnUnderlyingOpenAIRequestPolicies() + { + // Arrange + Act: constructing a FoundryChatClient should register the + // AgentFrameworkUserAgentPolicy on the inner chat client's OpenAIRequestPolicies. + var chatClient = new FoundryChatClient(CreateProjectClient(), "gpt-4o-mini"); + + // Assert: the inner chat client (MEAI's OpenAIResponsesChatClient) exposes + // OpenAIRequestPolicies via GetService, and our policy is present in its entries. + var policies = chatClient.GetService(); + Assert.NotNull(policies); + Assert.Equal(1, EntriesCount(policies!)); + } + + [Fact] + public void Register_AgentFrameworkUserAgentPolicy_IsDedupedAcrossMultipleClients_OnSharedInner() + { + // Arrange: construct via the ProjectsAgentVersion mode-2 variant, which chains via + // :this(...) into the AgentReference ctor. If the policy registration code were + // inadvertently called twice along the chain, we would see 2 entries. + var projectClient = CreateProjectClient(); + var agentVersion = ModelReaderWriter.Read( + BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; + + // Act + var chatClient = new FoundryChatClient(projectClient, agentVersion, baseChatOptions: null); + + // Assert: even though the version variant funnels through the AgentReference ctor + // via :this(...), the policy is registered exactly once on the inner pipeline. + var policies = chatClient.GetService(); + Assert.NotNull(policies); + Assert.Equal(1, EntriesCount(policies!)); + Assert.Same(agentVersion, chatClient.GetService()); + Assert.NotNull(chatClient.GetService()); + } + + #endregion + + #region Helpers + + private static AIProjectClient CreateProjectClient() + => new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(new HttpClient()) }); + + private static int EntriesCount(OpenAIRequestPolicies policies) + { + var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + var arr = (Array)field!.GetValue(policies)!; + return arr.Length; + } + + #endregion +} +#pragma warning restore CS0618 diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientVectorStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientVectorStoreTests.cs new file mode 100644 index 0000000000..923d615807 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientVectorStoreTests.cs @@ -0,0 +1,660 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using OpenAI.Files; + +#pragma warning disable OPENAI001, CS0618 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Unit tests for the file and vector-store helper methods on . +/// Covers all four methods across the three FoundryChatClient construction modes plus argument +/// validation, cancellation, and request-body shape on the wire. +/// +public sealed class FoundryChatClientVectorStoreTests +{ + // ----- Construction helpers shared by every test in this file ----- + + private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode1(string modelId = "gpt-4o-mini", string? responseBody = null) + { + var recorder = new RequestRecorder(responseBody); +#pragma warning disable CA5399 + var httpClient = new HttpClient(recorder); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + return (new FoundryChatClient(projectClient, modelId), recorder); + } + + private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode2(string? responseBody = null) + { + var recorder = new RequestRecorder(responseBody); +#pragma warning disable CA5399 + var httpClient = new HttpClient(recorder); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var agentRef = new AgentReference("agent-name", "1"); + return (new FoundryChatClient(projectClient, agentRef, defaultModelId: "gpt-4o", baseChatOptions: null), recorder); + } + + private static string MakeTempFile(string contents = "hello world") + { + var path = Path.Combine(Path.GetTempPath(), $"fcc-test-{Guid.NewGuid():N}.txt"); + File.WriteAllText(path, contents); + return path; + } + + // ----- UploadFileAsync ----- + + [Fact] + public async Task UploadFileAsync_Mode1_UploadsViaProjectOpenAIClientAsync() + { + var (chatClient, recorder) = CreateMode1(responseBody: FakeFileJson("file_abc")); + var path = MakeTempFile(); + try + { + var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants); + + Assert.Equal("file_abc", result.Id); + Assert.NotEmpty(recorder.Requests); + Assert.EndsWith("/files", recorder.Requests[0].PathAndQuery.TrimEnd('/').Split('?')[0]); + } + finally { File.Delete(path); } + } + + [Fact] + public async Task UploadFileAsync_Mode2_UploadsViaProjectOpenAIClientAsync() + { + var (chatClient, recorder) = CreateMode2(responseBody: FakeFileJson("file_xyz")); + var path = MakeTempFile(); + try + { + var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants); + Assert.Equal("file_xyz", result.Id); + Assert.Contains(recorder.Requests, r => r.PathAndQuery.Contains("/files")); + } + finally { File.Delete(path); } + } + + [Fact] + public async Task UploadFileAsync_Mode3_UploadsViaMaterializedProjectClientAsync() + { + // Q-E: Mode 3 (Agent Endpoint) now honors caller-supplied transports via + // ProjectOpenAIClientOptions.Transport, so we can use a fake transport here instead of + // depending on DNS/network availability against example.com. + var sawUpload = false; + using var handler = new HttpHandlerAssert(req => + { + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal)) + { + sawUpload = true; + return MakeJsonResponse(FakeFileJson("file_mode3")); + } + return MakeJsonResponse("{}"); + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var chatClient = new FoundryChatClient( + agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"), + credential: new FakeAuthenticationTokenProvider(), + clientOptions: new ProjectOpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + + var path = MakeTempFile(); + try + { + var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, CancellationToken.None); + Assert.True(sawUpload); + Assert.Equal("file_mode3", result.Id); + } + finally { File.Delete(path); } + } + + [Fact] + public async Task UploadFileAsync_NullFilePath_ThrowsArgumentNullExceptionAsync() + { + var (chatClient, _) = CreateMode1(); + await Assert.ThrowsAsync(() => + chatClient.UploadFileAsync(null!, FileUploadPurpose.Assistants)); + } + + [Fact] + public async Task UploadFileAsync_FileNotFound_ThrowsFileNotFoundExceptionAsync() + { + var (chatClient, _) = CreateMode1(); + var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt"); + await Assert.ThrowsAsync(() => + chatClient.UploadFileAsync(missing, FileUploadPurpose.Assistants)); + } + + [Fact] + public async Task UploadFileAsync_HonorsCancellationAsync() + { + // Cancellation propagation through the OpenAI SDK pipeline surfaces different exception + // types depending on the framework target (OperationCanceledException on net10.0, + // ObjectDisposedException at the transport layer on net472). Asserting on the exact + // exception class is brittle; assert only that the call throws when the token is + // pre-cancelled. + var (chatClient, _) = CreateMode1(responseBody: FakeFileJson("file_abc")); + var path = MakeTempFile(); + try + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => + chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, cts.Token)); + } + finally { File.Delete(path); } + } + + // ----- DeleteFileAsync ----- + + [Fact] + public async Task DeleteFileAsync_Mode1_CallsDeleteOnFileClientAsync() + { + var (chatClient, recorder) = CreateMode1(responseBody: FakeFileDeletedJson("file_abc")); + await chatClient.DeleteFileAsync("file_abc"); + Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_abc")); + } + + [Fact] + public async Task DeleteFileAsync_Mode2_CallsDeleteOnFileClientAsync() + { + var (chatClient, recorder) = CreateMode2(responseBody: FakeFileDeletedJson("file_xyz")); + await chatClient.DeleteFileAsync("file_xyz"); + Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_xyz")); + } + + [Fact] + public async Task DeleteFileAsync_NullId_ThrowsArgumentExceptionAsync() + { + var (chatClient, _) = CreateMode1(); + await Assert.ThrowsAnyAsync(() => chatClient.DeleteFileAsync(null!)); + } + + [Fact] + public async Task DeleteFileAsync_EmptyId_ThrowsArgumentExceptionAsync() + { + var (chatClient, _) = CreateMode1(); + await Assert.ThrowsAnyAsync(() => chatClient.DeleteFileAsync("")); + } + + [Fact] + public async Task DeleteFileAsync_HonorsCancellationAsync() + { + // Verify the cancellation token reaches the HTTP pipeline by having the handler + // throw OperationCanceledException when the token is cancelled before the request. + // This is more robust than asserting on the exact exception the SDK surfaces, which + // depends on internal pipeline plumbing. + var observedToken = CancellationToken.None; + using var handler = new HttpHandlerAssert(async req => + { + // We don't have direct access to the SDK's CancellationToken here; instead, sleep + // briefly to give the caller's pre-cancellation a chance to be picked up by the + // transport. If cancellation reached the pipeline, the await on this handler call + // would surface OperationCanceledException; if not, the response is returned. + await Task.Delay(50).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(FakeFileDeletedJson("file_abc"), Encoding.UTF8, "application/json"), + }; + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + // Any throw is acceptable evidence that cancellation was honored. The SDK's exact + // exception surface for pre-cancelled tokens is an implementation detail of + // System.ClientModel's pipeline and may differ between versions. + await Assert.ThrowsAnyAsync(() => chatClient.DeleteFileAsync("file_abc", cts.Token)); + } + + // ----- CreateVectorStoreAsync ----- + + [Fact] + public async Task CreateVectorStoreAsync_UploadsThenCreates_WithFileIds_ReturnsVectorStoreAsync() + { + // Each file POST returns a distinct file id; the recorder dispatches on URL to differentiate. + var fileCount = 0; + using var handler = new HttpHandlerAssert(async req => + { + var body = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false); + if (req.RequestUri!.AbsolutePath.Contains("/files") && req.Method == HttpMethod.Post) + { + fileCount++; + return MakeJsonResponse(FakeFileJson($"file_{fileCount}")); + } + if (req.RequestUri.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post) + { + Assert.Contains("file_1", body); + Assert.Contains("file_2", body); + Assert.Contains("knowledge-base", body); + return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "knowledge-base")); + } + return MakeJsonResponse("{}"); + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + var pathA = MakeTempFile("alpha"); + var pathB = MakeTempFile("beta"); + try + { + var store = await chatClient.CreateVectorStoreAsync("knowledge-base", new[] { pathA, pathB }); + Assert.Equal("vs_abc", store.Id); + Assert.Equal(2, fileCount); + } + finally { File.Delete(pathA); File.Delete(pathB); } + } + + [Fact] + public async Task CreateVectorStoreAsync_WithExpiresAfter_SerializesLastActiveAtAnchorAsync() + { + string? vectorStoreBody = null; + using var handler = new HttpHandlerAssert(async req => + { + if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post) + { + vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false); + return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x")); + } + return MakeJsonResponse("{}"); + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + await chatClient.CreateVectorStoreAsync("x", Array.Empty(), expiresAfter: TimeSpan.FromDays(7)); + + Assert.NotNull(vectorStoreBody); + Assert.Contains("\"expires_after\"", vectorStoreBody); + Assert.Contains("\"last_active_at\"", vectorStoreBody); + Assert.Contains("\"days\":7", vectorStoreBody); + } + + [Fact] + public async Task CreateVectorStoreAsync_WithNullExpiresAfter_OmitsExpirationPolicyAsync() + { + string? vectorStoreBody = null; + using var handler = new HttpHandlerAssert(async req => + { + if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post) + { + vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false); + return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x")); + } + return MakeJsonResponse("{}"); + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + await chatClient.CreateVectorStoreAsync("x", Array.Empty(), expiresAfter: null); + + Assert.NotNull(vectorStoreBody); + Assert.DoesNotContain("\"expires_after\"", vectorStoreBody); + } + + [Fact] + public async Task CreateVectorStoreAsync_EmptyFilesList_CreatesEmptyStoreAsync() + { + var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_empty", name: "x")); + var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty()); + Assert.Equal("vs_empty", store.Id); + } + + [Fact] + public async Task CreateVectorStoreAsync_NullName_ThrowsArgumentExceptionAsync() + { + var (chatClient, _) = CreateMode1(); + await Assert.ThrowsAnyAsync(() => + chatClient.CreateVectorStoreAsync(null!, Array.Empty())); + } + + [Fact] + public async Task CreateVectorStoreAsync_NullFilePaths_ThrowsArgumentNullExceptionAsync() + { + var (chatClient, _) = CreateMode1(); + await Assert.ThrowsAsync(() => + chatClient.CreateVectorStoreAsync("x", filePaths: null!)); + } + + [Fact] + public async Task CreateVectorStoreAsync_HonorsCancellationAsync() + { + // Same rationale as UploadFileAsync_HonorsCancellationAsync — assert only that any + // exception is thrown on a pre-cancelled token. + var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_x", "x")); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => + chatClient.CreateVectorStoreAsync("x", Array.Empty(), expiresAfter: null, cancellationToken: cts.Token)); + } + + [Fact] + public async Task CreateVectorStoreAsync_PollsUntilStoreLeavesInProgress_Async() + { + // Q-A regression: when the create response returns status=in_progress, the helper must + // poll GET /vector_stores/{id} until status changes before returning. Otherwise the + // caller receives a half-built store. + var pollCount = 0; + using var handler = new HttpHandlerAssert(req => + { + if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post) + { + // First response: status=in_progress. + return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: "in_progress"))); + } + if (req.RequestUri.AbsolutePath.Contains("/vector_stores/vs_abc") && req.Method == HttpMethod.Get) + { + pollCount++; + // Stay in_progress for two polls, then complete on the third. + var status = pollCount < 3 ? "in_progress" : "completed"; + return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: status))); + } + return Task.FromResult(MakeJsonResponse("{}")); + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty()); + + Assert.NotEqual(OpenAI.VectorStores.VectorStoreStatus.InProgress, store.Status); + Assert.True(pollCount >= 3, $"Expected at least 3 GET polls before status leaves in_progress; saw {pollCount}."); + } + + [Fact] + public async Task CreateVectorStoreAsync_PollingTimeout_ThrowsTimeoutExceptionAsync() + { + // Sergey #2: caller-supplied (or default) polling timeout must surface as TimeoutException + // when the vector store never leaves InProgress. Mock keeps the store stuck and we pass + // a tiny timeout; cancellation token stays unused so the only path that ends the loop + // is the timeout check. + using var handler = new HttpHandlerAssert(req => + { + if (req.RequestUri!.AbsolutePath.Contains("/vector_stores", StringComparison.Ordinal)) + { + return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_stuck", name: "x", status: "in_progress"))); + } + return Task.FromResult(MakeJsonResponse("{}")); + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + var ex = await Assert.ThrowsAsync(() => + chatClient.CreateVectorStoreAsync("x", Array.Empty(), expiresAfter: null, pollingTimeout: TimeSpan.FromMilliseconds(500))); + Assert.Contains("vs_stuck", ex.Message, StringComparison.Ordinal); + Assert.Contains("in-progress", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CreateVectorStoreAsync_MidUploadFailure_DeletesAlreadyUploadedFilesAsync() + { + // Q-B regression: when the upload loop throws partway through (e.g. file 3 of 5 is + // missing or the network fails), the helper must DELETE the already-uploaded files so + // they do not accumulate as orphaned resources. The exception must still propagate. + var uploadCount = 0; + var deleted = new List(); + using var handler = new HttpHandlerAssert(req => + { + // DELETE first so we don't match the upload-collection /files path against this. + if (req.Method == HttpMethod.Delete) + { + var segments = req.RequestUri!.AbsolutePath.Split('/'); + var fileId = segments[segments.Length - 1]; + deleted.Add(fileId); + return MakeJsonResponse(FakeFileDeletedJson(fileId)); + } + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal)) + { + uploadCount++; + if (uploadCount == 3) + { + // 400 is non-retriable; the SDK retry policy ignores it. 5xx would trigger + // retries and confuse the assertion on upload count. + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed-on-3\"}}", Encoding.UTF8, "application/json"), + }; + } + return MakeJsonResponse(FakeFileJson($"file_{uploadCount}")); + } + return MakeJsonResponse("{}"); + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + var paths = new[] { MakeTempFile("a"), MakeTempFile("b"), MakeTempFile("c"), MakeTempFile("d"), MakeTempFile("e") }; + try + { + await Assert.ThrowsAnyAsync(() => chatClient.CreateVectorStoreAsync("knowledge-base", paths)); + + // Three upload attempts: two succeeded, the third threw. + Assert.Equal(3, uploadCount); + // The two successful uploads must have been deleted as part of best-effort cleanup. + Assert.Equal(2, deleted.Count); + Assert.Contains("file_1", deleted); + Assert.Contains("file_2", deleted); + } + finally + { + foreach (var p in paths) + { + File.Delete(p); + } + } + } + + [Fact] + public async Task CreateVectorStoreAsync_MidUploadFailure_CleanupSwallowsDeleteErrorsAsync() + { + // Q-B follow-on: if a cleanup DELETE itself fails, the helper must still propagate the + // original upload exception — not the cleanup exception. The caller cares about the + // upload failure; cleanup is best-effort. + var uploadCount = 0; + using var handler = new HttpHandlerAssert(req => + { + if (req.Method == HttpMethod.Delete) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"error\":{\"code\":\"DeleteFailed\",\"message\":\"cleanup-failed\"}}", Encoding.UTF8, "application/json"), + }; + } + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal)) + { + uploadCount++; + if (uploadCount == 2) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed\"}}", Encoding.UTF8, "application/json"), + }; + } + return MakeJsonResponse(FakeFileJson($"file_{uploadCount}")); + } + return MakeJsonResponse("{}"); + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini"); + + var paths = new[] { MakeTempFile("a"), MakeTempFile("b") }; + try + { + var ex = await Assert.ThrowsAnyAsync(() => chatClient.CreateVectorStoreAsync("kb", paths)); + + // The original upload-failure message must surface, not the cleanup-failure message. + Assert.DoesNotContain("cleanup-failed", ex.Message ?? "", StringComparison.Ordinal); + } + finally + { + foreach (var p in paths) + { + File.Delete(p); + } + } + } + + // ----- DeleteVectorStoreAsync ----- + + [Fact] + public async Task DeleteVectorStoreAsync_Mode1_CallsDeleteAsync() + { + var (chatClient, recorder) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc")); + await chatClient.DeleteVectorStoreAsync("vs_abc"); + Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_abc")); + } + + [Fact] + public async Task DeleteVectorStoreAsync_Mode2_CallsDeleteAsync() + { + var (chatClient, recorder) = CreateMode2(responseBody: FakeVectorStoreDeletedJson("vs_xyz")); + await chatClient.DeleteVectorStoreAsync("vs_xyz"); + Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_xyz")); + } + + [Fact] + public async Task DeleteVectorStoreAsync_NullId_ThrowsArgumentExceptionAsync() + { + var (chatClient, _) = CreateMode1(); + await Assert.ThrowsAnyAsync(() => chatClient.DeleteVectorStoreAsync(null!)); + } + + [Fact] + public async Task DeleteVectorStoreAsync_HonorsCancellationAsync() + { + // Same approach as DeleteFileAsync_HonorsCancellationAsync — assert that the call + // throws when the token is pre-cancelled, without asserting on the exact exception + // surfaced by the SDK pipeline. + var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc")); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => chatClient.DeleteVectorStoreAsync("vs_abc", cts.Token)); + } + + // ----- Fixtures and helpers ----- + + private static HttpResponseMessage MakeJsonResponse(string json) + => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private static string FakeFileJson(string id) + => $"{{\"id\":\"{id}\",\"object\":\"file\",\"bytes\":11,\"created_at\":1700000000,\"filename\":\"x.txt\",\"purpose\":\"assistants\",\"status\":\"processed\"}}"; + + private static string FakeFileDeletedJson(string id) + => $"{{\"id\":\"{id}\",\"object\":\"file\",\"deleted\":true}}"; + + private static string FakeVectorStoreJson(string id, string name) + => FakeVectorStoreJsonWithStatus(id, name, status: "completed"); + + private static string FakeVectorStoreJsonWithStatus(string id, string name, string status) + => $"{{\"id\":\"{id}\",\"object\":\"vector_store\",\"created_at\":1700000000,\"name\":\"{name}\",\"usage_bytes\":0,\"file_counts\":{{\"in_progress\":0,\"completed\":0,\"failed\":0,\"cancelled\":0,\"total\":0}},\"status\":\"{status}\",\"last_active_at\":1700000000}}"; + + private static string FakeVectorStoreDeletedJson(string id) + => $"{{\"id\":\"{id}\",\"object\":\"vector_store.deleted\",\"deleted\":true}}"; + + private sealed class RequestRecorder : HttpClientHandler + { + private readonly string _responseBody; + public List Requests { get; } = []; + + public RequestRecorder(string? responseBody) + { + this._responseBody = responseBody ?? "{}"; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.Requests.Add(new RecordedRequest + { + Method = request.Method.Method, + PathAndQuery = request.RequestUri?.PathAndQuery ?? "", +#if NET + Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false), +#else + Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync().ConfigureAwait(false), +#endif + }); + return MakeJsonResponse(this._responseBody); + } + } + + private sealed class RecordedRequest + { + public string Method { get; set; } = ""; + public string PathAndQuery { get; set; } = ""; + public string Body { get; set; } = ""; + } +} +#pragma warning restore CS0618 diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryPromptAgentConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryPromptAgentConverterTests.cs new file mode 100644 index 0000000000..6ba02029b1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryPromptAgentConverterTests.cs @@ -0,0 +1,433 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +#pragma warning disable OPENAI001, CS0618 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Unit tests for the public ToPromptAgentAsync extension methods on +/// and . Both entry points dispatch +/// to the same internal converter, so each behavior is asserted through both surfaces. +/// +public sealed class FoundryPromptAgentConverterTests +{ + // ----- Failure modes (assert through ChatClientAgent and FoundryAgent extensions) ----- + + [Fact] + public async Task ToPromptAgentAsync_ChatClientAgent_NonFoundryChatClient_ThrowsInvalidOperationExceptionAsync() + { + var agent = new ChatClientAgent(new NoOpChatClient()); + var ex = await Assert.ThrowsAsync(() => agent.ToPromptAgentAsync()); + Assert.Contains("FoundryChatClient", ex.Message); + } + + [Fact] + public async Task ToPromptAgentAsync_FoundryAgent_FoundryChatClientInMode3_ThrowsInvalidOperationExceptionAsync() + { + var foundryAgent = new FoundryAgent( + agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"), + credential: new FakeAuthenticationTokenProvider()); + var ex = await Assert.ThrowsAsync(() => foundryAgent.ToPromptAgentAsync()); + Assert.Contains("Agent Endpoint mode (Mode 3)", ex.Message); + } + + [Fact] + public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_MissingModelId_ThrowsInvalidOperationExceptionAsync() + { + var projectClient = CreateProjectClient(); + // Construct a FoundryChatClient via the Responses Agent mode (Mode 1) then wrap in a ChatClientAgent whose + // ChatOptions has no ModelId — synthesis must throw. + var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini"); + var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions { ChatOptions = new ChatOptions() }); + var ex = await Assert.ThrowsAsync(() => agent.ToPromptAgentAsync()); + Assert.Contains("model id", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_UnsupportedAITool_ThrowsInvalidOperationExceptionNamingTypeAsync() + { + var projectClient = CreateProjectClient(); + var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini"); + var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = "gpt-4o-mini", + Tools = new System.Collections.Generic.List { new UnsupportedTool() }, + }, + }); + var ex = await Assert.ThrowsAsync(() => agent.ToPromptAgentAsync()); + Assert.Contains(nameof(UnsupportedTool), ex.Message); + } + + [Fact] + public async Task ToPromptAgentAsync_FoundryAgent_HonorsCancellationAsync() + { + // Cancellation should bubble up from the AgentReference fetch path. Construct a + // FoundryAgent via AsAIAgent(AgentReference) and pass a pre-cancelled token. + var (foundryAgent, _) = CreateMode2_PromptAgentOnly("agent-name"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => foundryAgent.ToPromptAgentAsync(cts.Token)); + } + + // ----- the Responses Agent mode (Mode 1) (RAPI) synthesis paths ----- + + [Fact] + public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_RoundTripsModelInstructionsTemperatureTopPAsync() + { + var projectClient = CreateProjectClient(); + var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini"); + var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = "gpt-4o-mini", + Instructions = "Be helpful.", + Temperature = 0.5f, + TopP = 0.9f, + }, + }); + + var def = await agent.ToPromptAgentAsync(); + var declarative = Assert.IsType(def); + Assert.Equal("gpt-4o-mini", declarative.Model); + Assert.Equal("Be helpful.", declarative.Instructions); + Assert.Equal(0.5f, declarative.Temperature); + Assert.Equal(0.9f, declarative.TopP); + Assert.Empty(declarative.Tools); + } + + [Fact] + public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_NoTools_ReturnsDefinitionWithEmptyToolsAsync() + { + var projectClient = CreateProjectClient(); + var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini"); + var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions { ModelId = "gpt-4o-mini" }, + }); + var def = await agent.ToPromptAgentAsync(); + var declarative = Assert.IsType(def); + Assert.Empty(declarative.Tools); + } + + [Fact] + public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_AIFunctionTool_ConvertsToFunctionToolAsync() + { + var projectClient = CreateProjectClient(); + var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini"); + var function = AIFunctionFactory.Create(() => "ok", "my_function", "A documented function."); + var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = "gpt-4o-mini", + Tools = new System.Collections.Generic.List { function }, + }, + }); + + var def = await agent.ToPromptAgentAsync(); + var declarative = Assert.IsType(def); + var fnTool = Assert.Single(declarative.Tools); + var ft = Assert.IsType(fnTool); + Assert.Equal("my_function", ft.FunctionName); + Assert.Equal("A documented function.", ft.FunctionDescription); + } + + [Fact] + public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_FoundryAITool_UnwrapsUnderlyingResponseToolAsync() + { + var projectClient = CreateProjectClient(); + var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini"); + var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = "gpt-4o-mini", + Tools = new System.Collections.Generic.List { FoundryAITool.CreateWebSearchTool() }, + }, + }); + + var def = await agent.ToPromptAgentAsync(); + var declarative = Assert.IsType(def); + var tool = Assert.Single(declarative.Tools); + // The unwrapped instance must be the concrete WebSearchTool from the OpenAI SDK. + Assert.IsType(tool); + } + + [Fact] + public async Task ToPromptAgentAsync_ChatClientAgent_Mode1_MultipleToolsMixed_ConvertsAllInOrderAsync() + { + var projectClient = CreateProjectClient(); + var fcc = new FoundryChatClient(projectClient, "gpt-4o-mini"); + var function = AIFunctionFactory.Create(() => "ok", "fn", ""); + var agent = new ChatClientAgent(fcc, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions + { + ModelId = "gpt-4o-mini", + Tools = new System.Collections.Generic.List { function, FoundryAITool.CreateWebSearchTool() }, + }, + }); + + var def = await agent.ToPromptAgentAsync(); + var declarative = Assert.IsType(def); + Assert.Equal(2, declarative.Tools.Count); + Assert.IsType(declarative.Tools[0]); + Assert.IsType(declarative.Tools[1]); + } + + [Fact] + public async Task ToPromptAgentAsync_FoundryAgent_Mode1_ResultIsDeclarativeAgentDefinitionAsync() + { + // FoundryAgent constructed via the projectEndpoint+model+instructions ctor (Responses Agent mode, the Responses Agent mode (Mode 1)). + var foundryAgent = new FoundryAgent( + projectEndpoint: new Uri("https://test.openai.azure.com/"), + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "You are helpful."); + + var def = await foundryAgent.ToPromptAgentAsync(); + var declarative = Assert.IsType(def); + Assert.Equal("gpt-4o-mini", declarative.Model); + Assert.Equal("You are helpful.", declarative.Instructions); + } + + // ----- the Prompt Agent mode (Mode 2) paths ----- + + [Fact] + public async Task ToPromptAgentAsync_FoundryAgent_Mode2_AgentVersion_ReturnsCachedDefinitionAsync() + { + // Construct via ProjectsAgentVersion → the Definition reference must come back unchanged. + var version = ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; + var projectClient = CreateProjectClient(); + var foundryAgent = projectClient.AsAIAgent(version); + + var def = await foundryAgent.ToPromptAgentAsync(); + Assert.Same(version.Definition, def); + } + + [Fact] + public async Task ToPromptAgentAsync_FoundryAgent_Mode2_AgentRecord_ReturnsLatestVersionDefinitionAsync() + { + var record = ModelReaderWriter.Read(BinaryData.FromString(TestDataUtil.GetAgentResponseJson()))!; + var projectClient = CreateProjectClient(); + var foundryAgent = projectClient.AsAIAgent(record); + + var def = await foundryAgent.ToPromptAgentAsync(); + Assert.Same(record.GetLatestVersion().Definition, def); + } + + [Fact] + public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_FetchesLatestVersionAsync() + { + // The handler returns a known agent JSON. The converter must hit GET /agents/{name} + // and return that record's latest version definition. + var fetched = false; + using var handler = new HttpHandlerAssert(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.Contains("/agents/agent-name")) + { + fetched = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetAgentResponseJson(agentName: "agent-name"), Encoding.UTF8, "application/json"), + }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var foundryAgent = projectClient.AsAIAgent(new AgentReference("agent-name")); + + var def = await foundryAgent.ToPromptAgentAsync(); + Assert.True(fetched); + Assert.NotNull(def); + } + + [Fact] + public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_PinnedVersion_FetchesPinnedVersionAsync() + { + // Q-C regression: when AgentReference.Version is set, the converter must call + // GET /agents/{name}/versions/{version} and return that pinned version's definition, + // NOT GET /agents/{name} -> GetLatestVersion() which would silently substitute the + // server's latest. We probe both paths from the same handler and assert exactly one was hit. + var fetchedLatest = false; + var fetchedPinned = false; + using var handler = new HttpHandlerAssert(req => + { + // Pinned-version path: …/agents/{name}/versions/{version} + if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.Contains("/agents/agent-name/versions/2", StringComparison.Ordinal)) + { + fetchedPinned = true; + var pinnedDef = new DeclarativeAgentDefinition("gpt-pinned") { Instructions = "Pinned-version instructions." }; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetAgentVersionResponseJson(agentName: "agent-name", agentDefinition: pinnedDef), Encoding.UTF8, "application/json"), + }; + } + // Latest-version path: …/agents/{name} + if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.EndsWith("/agents/agent-name", StringComparison.Ordinal)) + { + fetchedLatest = true; + var latestDef = new DeclarativeAgentDefinition("gpt-latest") { Instructions = "Latest-version instructions." }; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetAgentResponseJson(agentName: "agent-name", agentDefinition: latestDef), Encoding.UTF8, "application/json"), + }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var foundryAgent = projectClient.AsAIAgent(new AgentReference("agent-name", "2")); + + var def = await foundryAgent.ToPromptAgentAsync(); + + Assert.True(fetchedPinned, "Pinned-version endpoint (.../agents/agent-name/versions/2) must be called when AgentReference.Version is set."); + Assert.False(fetchedLatest, "Latest-version endpoint (.../agents/agent-name) must NOT be called when AgentReference.Version is set."); + var declarative = Assert.IsType(def); + Assert.Equal("gpt-pinned", declarative.Model); + Assert.Equal("Pinned-version instructions.", declarative.Instructions); + } + + [Fact] + public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_UnpinnedVersionKeyword_FetchesLatestAsync() + { + // Q-C boundary: AgentReference.Version == "latest" must fall back to the GET /agents/{name} + // path (the latest-version path), NOT GET /agents/{name}/versions/latest. + var fetchedLatest = false; + using var handler = new HttpHandlerAssert(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath.EndsWith("/agents/agent-name", StringComparison.Ordinal)) + { + fetchedLatest = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(TestDataUtil.GetAgentResponseJson(agentName: "agent-name"), Encoding.UTF8, "application/json"), + }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var foundryAgent = projectClient.AsAIAgent(new AgentReference("agent-name", "latest")); + + var def = await foundryAgent.ToPromptAgentAsync(); + + Assert.True(fetchedLatest); + Assert.NotNull(def); + } + + [Fact] + public async Task ToPromptAgentAsync_FoundryAgent_Mode2_PromptAgentOnly_ServerReturnsError_PropagatesExceptionAsync() + { + using var handler = new HttpHandlerAssert(req => + new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("{\"error\":{\"code\":\"NotFound\"}}", Encoding.UTF8, "application/json") }); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }); + var foundryAgent = projectClient.AsAIAgent(new AgentReference("missing-agent")); + + await Assert.ThrowsAnyAsync(() => foundryAgent.ToPromptAgentAsync()); + } + + // ----- Python-parity guard: both extensions produce equivalent definitions ----- + + [Fact] + public async Task BothExtensions_ProduceEquivalentDefinitions_ForEquivalentInputsAsync() + { + // Build two agents that are semantically equivalent: one as a plain ChatClientAgent + // via AsAIAgent(model, instructions), and one as a FoundryAgent via the projectEndpoint + // ctor. Both flow through the same converter; assert key fields match. + var projectClient = CreateProjectClient(); + ChatClientAgent ccaAgent = projectClient.AsAIAgent("gpt-4o-mini", "Be helpful."); + var foundryAgent = new FoundryAgent( + projectEndpoint: new Uri("https://test.openai.azure.com/"), + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Be helpful."); + + var ccaDef = await ccaAgent.ToPromptAgentAsync(); + var faDef = await foundryAgent.ToPromptAgentAsync(); + + var a = Assert.IsType(ccaDef); + var b = Assert.IsType(faDef); + Assert.Equal(a.Model, b.Model); + Assert.Equal(a.Instructions, b.Instructions); + Assert.Equal(a.Tools.Count, b.Tools.Count); + } + + // ----- Helpers ----- + + private static AIProjectClient CreateProjectClient() + => new( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(new HttpClient()) }); + + private static (FoundryAgent FoundryAgent, AIProjectClient ProjectClient) CreateMode2_PromptAgentOnly(string agentName) + { + var projectClient = CreateProjectClient(); + var foundryAgent = projectClient.AsAIAgent(new AgentReference(agentName)); + return (foundryAgent, projectClient); + } + + private sealed class NoOpChatClient : IChatClient + { + public Task GetResponseAsync(System.Collections.Generic.IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ChatResponse()); + + public System.Collections.Generic.IAsyncEnumerable GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => EmptyAsyncEnumerableAsync(); + + private static async System.Collections.Generic.IAsyncEnumerable EmptyAsyncEnumerableAsync() + { + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } + + private sealed class UnsupportedTool : AITool + { + public override string Name => "unsupported"; + } +} +#pragma warning restore CS0618 diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/MeaiAutoUserAgentVerificationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/MeaiAutoUserAgentVerificationTests.cs new file mode 100644 index 0000000000..050dd43a22 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/MeaiAutoUserAgentVerificationTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// One-shot verification (kept in tree to detect regressions) that MEAI 10.5.1 stamps its own +/// MEAI/{version} User-Agent segment automatically when an +/// is wrapped via AsIChatClient(). If this test starts failing, the FoundryChatClient +/// implementation must re-register the MEAI policy explicitly via OpenAIRequestPolicies because +/// the local Foundry copy was deleted under the assumption that MEAI provides it built-in. +/// +public sealed class MeaiAutoUserAgentVerificationTests +{ + [Fact] + public async Task MeaiOpenAIResponsesClient_StampsMeaiSegmentAutomatically_WithoutLocalPolicyAsync() + { + // Arrange: bare OpenAI ResponseClient over a fake HTTP transport, wrapped via MEAI's + // AsIChatClient() with no custom OpenAIRequestPolicies registration. If MEAI auto-stamps + // its own MEAI/{version} segment, it will appear here. + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + + var options = new OpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + Endpoint = new Uri("https://example.test/v1"), + }; + + var responseClient = new ResponsesClient(new ApiKeyCredential("test-key"), options); + var chatClient = responseClient.AsIChatClient("gpt-4o-mini"); + + // Act: send a request through MEAI's chat client. The fake transport will throw on + // response parsing, but we only care about the outbound headers, which are captured + // before the response is parsed. + try + { + await chatClient.GetResponseAsync("hi", cancellationToken: CancellationToken.None); + } + catch + { + // Expected: the fake response body is not parseable as a Responses API payload. + } + + // Assert: at least one outbound request reached the transport, and its User-Agent + // contains either "MEAI/" (auto-stamped by MEAI) or no MEAI segment (verification + // signal — see test summary). + Assert.True(handler.Count > 0, "Expected at least one outbound request from MEAI wrapper."); + Assert.NotNull(handler.LastUserAgent); + // INTENT: assert that MEAI auto-stamps. If the assertion fails, see the FoundryChatClient + // implementation note about needing to register the MEAI policy explicitly. + Assert.Contains("MEAI/", handler.LastUserAgent); + } + + private sealed class RecordingHandler : HttpClientHandler + { + public int Count { get; private set; } + public string? LastUserAgent { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.Count++; + this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values) + ? string.Join(",", values) + : null; + + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + return Task.FromResult(resp); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/RequestOptionsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/RequestOptionsExtensionsTests.cs deleted file mode 100644 index df5dd8ebae..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/RequestOptionsExtensionsTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel.Primitives; -using System.Net; -using System.Net.Http; -using System.Reflection; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Foundry.UnitTests; - -/// -/// Verifies the per-call MeaiUserAgentPolicy exposed via -/// . The policy is reachable through the -/// public constructors (which add it to the internally-built -/// 's pipeline), so its behavior is part of the -/// public API surface. -/// -public sealed class RequestOptionsExtensionsTests -{ - [Fact] - public async Task MeaiUserAgentPolicy_AddsMeaiSegment_ToOutgoingRequestAsync() - { - // Arrange - using var handler = new RecordingHandler(); -#pragma warning disable CA5399 - using var httpClient = new HttpClient(handler); -#pragma warning restore CA5399 - var pipeline = ClientPipeline.Create( - new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, - perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy], - perTryPolicies: default, - beforeTransportPolicies: default); - - // Act - var message = pipeline.CreateMessage(); - message.Request.Method = "POST"; - message.Request.Uri = new System.Uri("https://example.test/anything"); - await pipeline.SendAsync(message); - - // Assert - Assert.Equal(1, handler.Count); - Assert.NotNull(handler.LastUserAgent); - Assert.Contains("MEAI/", handler.LastUserAgent); - } - - [Fact] - public async Task MeaiUserAgentPolicy_DoesNotAddFoundryHostingSegmentAsync() - { - // Arrange - using var handler = new RecordingHandler(); -#pragma warning disable CA5399 - using var httpClient = new HttpClient(handler); -#pragma warning restore CA5399 - var pipeline = ClientPipeline.Create( - new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, - perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy], - perTryPolicies: default, - beforeTransportPolicies: default); - - // Act - var message = pipeline.CreateMessage(); - message.Request.Method = "POST"; - message.Request.Uri = new System.Uri("https://example.test/anything"); - await pipeline.SendAsync(message); - - // Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere - // (by the polyfill UserAgentResponsesClient → HostedAgentUserAgentPolicy). - Assert.NotNull(handler.LastUserAgent); - Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent); - } - - [Fact] - public void UserAgentPolicy_ExposesSingletonInstance() - { - // Two reads of the static property must return the same instance — the policy is stateless and shared. - var first = RequestOptionsExtensions.UserAgentPolicy; - var second = RequestOptionsExtensions.UserAgentPolicy; - Assert.Same(first, second); - } - - [Fact] - public void MeaiUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard() - { - // The policy emits "MEAI/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}". - // If the assembly metadata stops being readable, the policy falls back to "MEAI" without a version, - // which is a measurable telemetry regression. - var attr = typeof(RequestOptionsExtensions).Assembly - .GetCustomAttribute(); - Assert.NotNull(attr); - Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion)); - } - - private sealed class RecordingHandler : HttpClientHandler - { - public int Count { get; private set; } - public string? LastUserAgent { get; private set; } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - this.Count++; - this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values) - ? string.Join(",", values) - : null; - - var resp = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{}", Encoding.UTF8, "application/json"), - RequestMessage = request, - }; - return Task.FromResult(resp); - } - } -}