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);
- }
- }
-}