diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index a07bde035b..1a4c5635a5 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -25,7 +25,7 @@
-
+
diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj
index 0db6ba9fe6..a2787b4130 100644
--- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj
+++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj
@@ -1,4 +1,4 @@
-
+
Exe
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj
index 8871ea5242..366894856c 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
index 359e8a35bb..270a3f7391 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj
index 31dafe2280..0029f66e39 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj
index ed24c32eea..a0138c710a 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
index 458518c67b..e54d2f1b37 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj
index 75d012413d..57c5852455 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -11,7 +11,7 @@
-
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj
index 209edd9a82..71d9af8f71 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj
@@ -1,4 +1,4 @@
-
+
$(TargetFrameworksCore)
@@ -31,7 +31,7 @@
-
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
index db66b30a88..6174e1b149 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
@@ -2,6 +2,7 @@
using System;
using System.ClientModel;
+using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
@@ -38,7 +39,28 @@ namespace Microsoft.Agents.AI.Foundry;
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryAgent : DelegatingAIAgent
{
- private readonly AIProjectClient _aiProjectClient;
+ ///
+ /// 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";
+
+ ///
+ /// The cached when one was supplied or constructed by the active
+ /// constructor. Null when the agent was constructed via the agent-endpoint constructor, which
+ /// does not build a full .
+ ///
+ private readonly AIProjectClient? _aiProjectClient;
+
+ ///
+ /// Project-scoped . Always non-null. Used for project-level
+ /// operations such as .
+ /// In agent-endpoint mode this is built directly from the project root derived from the
+ /// supplied agent endpoint; in project-endpoint mode it is the cached client returned by
+ /// .
+ ///
+ private readonly ProjectOpenAIClient _projectOpenAIClient;
///
/// Initializes a new instance of the class using the direct Responses API path.
@@ -72,30 +94,49 @@ public FoundryAgent(
out var aiProjectClient))
{
this._aiProjectClient = aiProjectClient;
+ this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
///
/// Initializes a new instance of the class from an agent-specific endpoint.
///
- /// The agent-specific endpoint URI (must contain the agent name in the path).
+ ///
+ /// The agent-specific endpoint URI. Must be of the shape
+ /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai.
+ ///
/// The authentication credential.
- /// Optional configuration options for the .
+ ///
+ /// 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.
/// 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.
+ ///
+ /// This is the lightweight constructor for invoking an existing Foundry hosted agent when the
+ /// caller already has the per-agent endpoint URL. It populates
+ /// and from the agent name parsed out of the endpoint
+ /// path; Description, Instructions, Temperature, and TopP are not
+ /// populated. Callers that need those fields hydrated from server-side state should use
+ /// AIProjectClient.AsAIAgent(ProjectsAgentVersion) or
+ /// AIProjectClient.AsAIAgent(ProjectsAgentRecord) instead.
+ ///
public FoundryAgent(
Uri agentEndpoint,
AuthenticationTokenProvider credential,
- AIProjectClientOptions? clientOptions = null,
+ ProjectOpenAIClientOptions? clientOptions = null,
IList? tools = null,
Func? clientFactory = null,
IServiceProvider? services = null)
- : base(CreateInnerAgentFromEndpoint(
- CreateProjectClient(agentEndpoint, credential, clientOptions),
- agentEndpoint, tools, clientFactory, services,
- out var aiProjectClient))
+ : base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
{
- this._aiProjectClient = aiProjectClient;
+ this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions);
}
///
@@ -105,6 +146,7 @@ internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgen
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
+ this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
#region Convenience methods
@@ -137,9 +179,7 @@ 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
- .GetProjectOpenAIClient()
- .GetProjectConversationsClient();
+ var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient();
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
@@ -161,6 +201,11 @@ private ChatClientAgent GetInnerChatClientAgent() =>
return this._aiProjectClient;
}
+ if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient))
+ {
+ return this._projectOpenAIClient;
+ }
+
return base.GetService(serviceType, serviceKey);
}
@@ -238,38 +283,172 @@ private static AIAgent WireClientHeaders(ChatClientAgent innerAgent)
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
- System.ClientModel.Primitives.PipelinePosition.PerCall);
+ PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
}
- private static AIAgent CreateInnerAgentFromEndpoint(
- AIProjectClient aiProjectClient,
+ ///
+ /// Builds the inner for the agent-endpoint constructor by
+ /// constructing a per-agent via the
+ /// ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions)
+ /// constructor with set. 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.
+ ///
+ private static AIAgent CreateInnerAgentFromAgentEndpoint(
Uri agentEndpoint,
+ AuthenticationTokenProvider credential,
+ ProjectOpenAIClientOptions? clientOptions,
IList? tools,
Func? clientFactory,
- IServiceProvider? services,
- out AIProjectClient outClient)
+ IServiceProvider? services)
{
- outClient = aiProjectClient;
+ Throw.IfNull(agentEndpoint);
+ Throw.IfNull(credential);
+
+ var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
+
+ var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
+ perAgentOptions.Endpoint = agentEndpoint;
+ perAgentOptions.AgentName = agentName;
+ perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
- AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/');
+ var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
+ var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
+
+ IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
+ if (clientFactory is not null)
+ {
+ chatClient = clientFactory(chatClient);
+ }
ChatClientAgentOptions agentOptions = new()
{
- Name = agentReference.Name,
+ Id = agentName,
+ Name = agentName,
ChatOptions = new() { Tools = tools },
};
- IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
+ return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
+ }
- if (clientFactory is not null)
+ ///
+ /// Builds the project-scoped for the agent-endpoint
+ /// constructor by deriving the project root from the supplied agent endpoint and constructing
+ /// a fresh client without so the SDK
+ /// appends the standard /openai/v1 suffix expected for project-level surfaces such as
+ /// conversations.
+ ///
+ ///
+ /// Only the four observable primitive properties (,
+ /// , ,
+ /// and UserAgentApplicationId) are copied from the caller's options bag. Pipeline
+ /// policies added via AddPolicy on the caller bag do not propagate because
+ /// does not publicly enumerate its policies. The MEAI
+ /// user-agent policy is appended last.
+ ///
+ private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint(
+ Uri agentEndpoint,
+ AuthenticationTokenProvider credential,
+ ProjectOpenAIClientOptions? clientOptions)
+ {
+ var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
+
+ var projectOptions = new ProjectOpenAIClientOptions();
+ if (clientOptions is not null)
{
- chatClient = clientFactory(chatClient);
+ if (clientOptions.RetryPolicy is not null)
+ {
+ projectOptions.RetryPolicy = clientOptions.RetryPolicy;
+ }
+
+ if (clientOptions.NetworkTimeout is not null)
+ {
+ projectOptions.NetworkTimeout = clientOptions.NetworkTimeout;
+ }
+
+ if (clientOptions.Transport is not null)
+ {
+ projectOptions.Transport = clientOptions.Transport;
+ }
+
+ if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
+ {
+ projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
+ }
}
- return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
+ projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
+
+ return new ProjectOpenAIClient(projectRoot, credential, projectOptions);
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Single source of truth for both agent-name extraction and project-root derivation.
+ /// 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 static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
@@ -278,7 +457,7 @@ private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationT
Throw.IfNull(credential);
clientOptions ??= new AIProjectClientOptions();
- clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, System.ClientModel.Primitives.PipelinePosition.PerCall);
+ clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
return new AIProjectClient(endpoint, credential, clientOptions);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj
index 71e7df373f..06e22b8c18 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj
@@ -1,7 +1,10 @@
- true
+
true
$(NoWarn);OPENAI001
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj
index fa28a32494..4a139df1b6 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
index 45d09689ff..01184031e8 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
@@ -7,6 +7,7 @@
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
@@ -184,7 +185,7 @@ public void Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent()
// Act: this AsAIAgent path constructs FoundryAgent via its internal
// (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
- var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name"));
+ var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
// Assert
Assert.NotNull(agent.GetService());
@@ -398,4 +399,379 @@ public async Task Constructor_UserAgentHeaderAddedToRequestsAsync()
}
#endregion
+
+ #region Agent-endpoint constructor tests
+
+ private const string TestAgentEndpoint = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai";
+ private static readonly Uri s_testAgentEndpoint = new(TestAgentEndpoint);
+
+ [Fact]
+ public void AgentEndpointConstructor_NullEndpoint_ThrowsArgumentNullException()
+ {
+ ArgumentNullException ex = Assert.Throws(() =>
+ new FoundryAgent(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider()));
+ Assert.Equal("agentEndpoint", ex.ParamName);
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_NullCredential_ThrowsArgumentNullException()
+ {
+ ArgumentNullException ex = Assert.Throws(() =>
+ new FoundryAgent(agentEndpoint: s_testAgentEndpoint, credential: null!));
+ Assert.Equal("credential", ex.ParamName);
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_PopulatesNameAndIdFromEndpointSlug()
+ {
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
+
+ Assert.Equal("it-happy-path", agent.Name);
+ Assert.Equal("it-happy-path", agent.Id);
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
+ {
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
+
+ Assert.NotNull(agent.GetService());
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull()
+ {
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
+
+ Assert.Null(agent.GetService());
+ }
+
+ [Fact]
+ public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
+ {
+ FoundryAgent agent = new(
+ s_testEndpoint,
+ new FakeAuthenticationTokenProvider(),
+ model: "gpt-4o-mini",
+ instructions: "Test");
+
+ Assert.NotNull(agent.GetService());
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_AppliesClientFactoryOnce()
+ {
+ int count = 0;
+ FoundryAgent agent = new(
+ s_testAgentEndpoint,
+ new FakeAuthenticationTokenProvider(),
+ clientFactory: c => { count++; return c; });
+
+ Assert.Equal(1, count);
+ Assert.NotNull(agent);
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_RunAsync_RoutesThroughPerAgentResponsesUrlAsync()
+ {
+ Uri? capturedUri = null;
+ using HttpHandlerAssert handler = new(req =>
+ {
+ capturedUri = req.RequestUri;
+ 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.NotNull(capturedUri);
+ string path = capturedUri!.AbsolutePath;
+ Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", path, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("/openai/v1/responses", path, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_RunStreamingAsync_RoutesThroughPerAgentResponsesUrlAsync()
+ {
+ Uri? capturedUri = null;
+ bool sawStreamTrue = false;
+ using HttpHandlerAssert handler = new(async req =>
+ {
+ capturedUri = req.RequestUri;
+ if (req.Content is not null)
+ {
+ string body = await req.Content.ReadAsStringAsync().ConfigureAwait(false);
+ if (body.Contains("\"stream\":true", StringComparison.Ordinal))
+ {
+ sawStreamTrue = true;
+ }
+ }
+
+ // Minimal SSE response; xUnit assertion only cares about the URL/body shape.
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("data: [DONE]\n\n", Encoding.UTF8, "text/event-stream"),
+ };
+ });
+#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);
+ try
+ {
+ await foreach (var _ in agent.RunStreamingAsync("Hello"))
+ {
+ // drain
+ }
+ }
+ catch
+ {
+ // SSE parse errors are acceptable; we only assert the request shape.
+ }
+
+ Assert.NotNull(capturedUri);
+ Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", capturedUri!.AbsolutePath, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
+ Assert.True(sawStreamTrue, "Expected request body to include \"stream\":true.");
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_CreateConversationSessionAsync_RoutesThroughProjectLevelUrlAsync()
+ {
+ Uri? capturedUri = null;
+ using HttpHandlerAssert handler = new(req =>
+ {
+ capturedUri = req.RequestUri;
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"id\":\"conv_123\"}", 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);
+ try
+ {
+ _ = await agent.CreateConversationSessionAsync();
+ }
+ catch
+ {
+ // Underlying SDK may attempt extra parsing on the minimal response. We only assert URL routing.
+ }
+
+ Assert.NotNull(capturedUri);
+ string path = capturedUri!.AbsolutePath;
+ Assert.Contains("/api/projects/test-project/openai/v1/conversations", path, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("/agents/", path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_StampsMeaiUserAgentHeaderAsync()
+ {
+ bool meaiSeen = false;
+ using HttpHandlerAssert handler = new(req =>
+ {
+ if (req.Headers.TryGetValues("User-Agent", out var values))
+ {
+ foreach (string v in values)
+ {
+ if (v.IndexOf("MEAI/", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ meaiSeen = 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(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline.");
+ }
+
+ [Fact]
+ public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync()
+ {
+ // Direct switch to ProjectOpenAIClientOptions means caller-supplied pipeline policies
+ // (added via AddPolicy) actually flow through to the per-agent traffic. Assert that a
+ // tag-stamping policy executes on each outbound per-agent request.
+ bool tagSeen = false;
+ using HttpHandlerAssert handler = new(req =>
+ {
+ if (req.Headers.TryGetValues("X-Test-Tag", out var values))
+ {
+ foreach (string v in values)
+ {
+ if (v == "tag-1")
+ {
+ tagSeen = 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) };
+ opts.AddPolicy(new HeaderStampPolicy("X-Test-Tag", "tag-1"), PipelinePosition.PerCall);
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+ await agent.RunAsync("Hello");
+
+ Assert.True(tagSeen, "Expected caller-supplied per-call policy to execute on the per-agent pipeline.");
+ }
+
+ [Fact]
+ public void AgentEndpointConstructor_OverridesCallerEndpointAndAgentName()
+ {
+ // The caller may set Endpoint/AgentName on the options bag; we must override both with
+ // values derived from agentEndpoint so the URL routing is correct regardless.
+ ProjectOpenAIClientOptions opts = new()
+ {
+ Endpoint = new Uri("https://wrong.example.com/openai/v1"),
+ AgentName = "wrong-agent",
+ };
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+
+ Assert.Equal("it-happy-path", agent.Name);
+ Assert.Equal(s_testAgentEndpoint, opts.Endpoint);
+ Assert.Equal("it-happy-path", opts.AgentName);
+ }
+
+ [Fact]
+ 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
+ // project-level client's options via the public ProjectOpenAIClient surface.
+ ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
+
+ FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
+
+ ProjectOpenAIClient? projectClient = agent.GetService();
+ Assert.NotNull(projectClient);
+ // Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
+ Assert.Equal("my-app-id", opts.UserAgentApplicationId);
+ }
+
+ #endregion
+
+ #region ParseAgentEndpoint tests
+
+ [Fact]
+ public void ParseAgentEndpoint_StandardShape_Parses()
+ {
+ var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai"));
+ Assert.Equal("a1", name);
+ Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_TrailingSlash_Parses()
+ {
+ var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai/"));
+ Assert.Equal("a1", name);
+ Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_UppercaseAgentsSegment_Parses()
+ {
+ var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/Agents/a1/endpoint/protocols/openai"));
+ Assert.Equal("a1", name);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_SpecialCharsInName_Parses()
+ {
+ var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/it-happy_path-1/endpoint/protocols/openai"));
+ Assert.Equal("it-happy_path-1", name);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_QueryAndFragmentStripped()
+ {
+ var (_, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a/endpoint/protocols/openai?x=1#frag"));
+ Assert.Equal(string.Empty, root.Query);
+ Assert.Equal(string.Empty, root.Fragment);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_SovereignCloudHostNoApiPrefix_Parses()
+ {
+ var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.cognitive.microsoft.us/projects/p/agents/a1/endpoint/protocols/openai"));
+ Assert.Equal("a1", name);
+ Assert.Equal("https://h.cognitive.microsoft.us/projects/p", root.AbsoluteUri.TrimEnd('/'));
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_MissingAgentsSegment_Throws()
+ {
+ ArgumentException ex = Assert.Throws(() =>
+ FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/openai/v1")));
+ Assert.Equal("agentEndpoint", ex.ParamName);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_WrongSuffix_Throws()
+ {
+ ArgumentException ex = Assert.Throws(() =>
+ FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a1/openai/v1")));
+ Assert.Equal("agentEndpoint", ex.ParamName);
+ }
+
+ [Fact]
+ public void ParseAgentEndpoint_EmptyAgentName_Throws()
+ {
+ ArgumentException ex = Assert.Throws(() =>
+ FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents//endpoint/protocols/openai")));
+ Assert.Equal("agentEndpoint", ex.ParamName);
+ }
+
+ #endregion
+
+ private sealed class HeaderStampPolicy : PipelinePolicy
+ {
+ private readonly string _name;
+ private readonly string _value;
+ public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
+
+ 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, 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/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
index 3b7176711a..b17efa64f9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
@@ -1,4 +1,4 @@
-
+
false
@@ -7,7 +7,7 @@
-
+