// 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 + ServedModelPolicy registration + dedup
[Fact]
public void Register_AgentFrameworkUserAgentPolicy_OnUnderlyingOpenAIRequestPolicies()
{
// Arrange + Act: constructing a FoundryChatClient should register the
// AgentFrameworkUserAgentPolicy and ServedModelPolicy 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 both policies are present in its entries.
var policies = chatClient.GetService();
Assert.NotNull(policies);
Assert.Equal(2, 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 more than 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(...), each policy is registered exactly once on the inner pipeline.
var policies = chatClient.GetService();
Assert.NotNull(policies);
Assert.Equal(2, 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