chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -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;
/// <summary>
/// Verifies the framework-wide <see cref="AgentFrameworkUserAgentPolicy"/>. The policy stamps
/// <c>agent-framework-dotnet/{version}</c> onto the outgoing <c>User-Agent</c> header of every
/// request made through a Foundry chat client and is registered automatically by
/// <c>FoundryChatClient</c> via the MEAI <c>OpenAIRequestPolicies</c> hook.
/// </summary>
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<AssemblyInformationalVersionAttribute>();
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<HttpResponseMessage> 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<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set("User-Agent", this._value);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set("User-Agent", this._value);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
}
@@ -0,0 +1,819 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenAI;
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Tests for the per-call <c>x-client-*</c> header pipeline:
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/>,
/// <see cref="ClientHeadersExtensions.UseClientHeaders(AIAgentBuilder)"/>,
/// the <c>ClientHeadersAgent</c> decorator, the <c>ClientHeadersScope</c> AsyncLocal,
/// and the <c>ClientHeadersPolicy</c> stamping policy.
/// </summary>
public sealed class ClientHeadersExtensionsTests
{
// -------------------------------------------------------------------------------------------
// 1. WithClientHeader writes namespaced key with valid value
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeader_WritesNamespacedKey_WithValidValue()
{
// Arrange
var options = new ChatOptions();
// Act
options.WithClientHeader("x-client-end-user-id", "alice");
// Assert
Assert.NotNull(options.AdditionalProperties);
var raw = options.AdditionalProperties[ClientHeadersExtensions.ClientHeadersKey];
var dict = Assert.IsType<Dictionary<string, string>>(raw);
Assert.Equal("alice", dict["X-CLIENT-END-USER-ID"]); // OrdinalIgnoreCase
}
// -------------------------------------------------------------------------------------------
// 2. WithClientHeader rejects non-x-client- prefix
// -------------------------------------------------------------------------------------------
[Theory]
[InlineData("Authorization")]
[InlineData("X-Custom-Header")]
[InlineData("client-end-user-id")]
[InlineData("xclient-end-user-id")]
public void WithClientHeader_RejectsInvalidPrefix(string name)
{
// Arrange
var options = new ChatOptions();
// Act / Assert
Assert.Throws<ArgumentException>(() => options.WithClientHeader(name, "value"));
}
// -------------------------------------------------------------------------------------------
// 3. WithClientHeader rejects null/empty name and value
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeader_RejectsNullName()
{
var options = new ChatOptions();
Assert.Throws<ArgumentNullException>(() => options.WithClientHeader(null!, "v"));
}
[Fact]
public void WithClientHeader_RejectsNullValue()
{
var options = new ChatOptions();
Assert.Throws<ArgumentNullException>(() => options.WithClientHeader("x-client-foo", null!));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void WithClientHeader_RejectsEmptyOrWhitespaceName(string name)
{
var options = new ChatOptions();
Assert.Throws<ArgumentException>(() => options.WithClientHeader(name, "v"));
}
[Fact]
public void WithClientHeader_RejectsEmptyValue()
{
var options = new ChatOptions();
Assert.Throws<ArgumentException>(() => options.WithClientHeader("x-client-foo", ""));
}
// -------------------------------------------------------------------------------------------
// 4. WithClientHeaders (bulk) is all-or-nothing on first invalid key
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeaders_AllOrNothing_OnInvalidKey()
{
// Arrange
var options = new ChatOptions();
var headers = new[]
{
new KeyValuePair<string, string>("x-client-end-user-id", "alice"),
new KeyValuePair<string, string>("Authorization", "secret"), // invalid prefix
new KeyValuePair<string, string>("x-client-end-chat-id", "chat-1"),
};
// Act / Assert: throws, and no entries are written.
Assert.Throws<ArgumentException>(() => options.WithClientHeaders(headers));
Assert.Null(options.GetClientHeaders());
}
// -------------------------------------------------------------------------------------------
// 5. Multiple WithClientHeader calls accumulate (additive)
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeader_Accumulates_MultipleCalls()
{
// Arrange
var options = new ChatOptions();
// Act
options.WithClientHeader("x-client-a", "1");
options.WithClientHeader("x-client-b", "2");
options.WithClientHeader("x-client-a", "1-updated"); // upsert
// Assert
var dict = options.GetClientHeaders();
Assert.NotNull(dict);
Assert.Equal(2, dict!.Count);
Assert.Equal("1-updated", dict["x-client-a"]);
Assert.Equal("2", dict["x-client-b"]);
}
// -------------------------------------------------------------------------------------------
// 6. Conflict on slot occupied by foreign type throws InvalidOperationException
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeader_ForeignTypeAtSlot_Throws()
{
// Arrange
var options = new ChatOptions
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
[ClientHeadersExtensions.ClientHeadersKey] = "this is not a dictionary",
},
};
// Act / Assert
Assert.Throws<InvalidOperationException>(() => options.WithClientHeader("x-client-foo", "v"));
}
// -------------------------------------------------------------------------------------------
// 7. UseClientHeaders is idempotent (already-wired returns innerAgent)
// -------------------------------------------------------------------------------------------
[Fact]
public void UseClientHeaders_IsIdempotent()
{
// Arrange
var inner = new FakeAgent();
var first = inner.AsBuilder().UseClientHeaders().Build();
// Act
var second = first.AsBuilder().UseClientHeaders().Build();
// Assert: only one ClientHeadersAgent in the chain.
Assert.NotNull(first.GetService<ClientHeadersAgent>());
Assert.NotNull(second.GetService<ClientHeadersAgent>());
// The second call should return the same agent unchanged because the chain is already wired.
Assert.Same(first, second);
}
// -------------------------------------------------------------------------------------------
// 8. ClientHeadersAgent snapshots dict at push time (mid-run mutation does not leak)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersAgent_SnapshotsAtPush_MidRunMutationDoesNotLeakAsync()
{
// Arrange: a fake inner agent that exposes ClientHeadersScope.Current at the moment of RunAsync.
IReadOnlyDictionary<string, string>? observed = null;
var inner = new ProbeAgent(_ =>
{
observed = ClientHeadersScope.Current;
// Mutate the source dictionary mid-run; snapshot must not see the mutation.
return Task.CompletedTask;
});
var agent = new ClientHeadersAgent(inner);
var chatOptions = new ChatOptions();
chatOptions.WithClientHeader("x-client-end-user-id", "alice");
// Act
var task = agent.RunAsync(messages: [], options: new ChatClientAgentRunOptions(chatOptions));
// Mutate the source after RunAsync starts.
chatOptions.WithClientHeader("x-client-end-user-id", "bob");
await task;
// Assert: probe saw "alice", not "bob".
Assert.NotNull(observed);
Assert.Equal("alice", observed!["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 9. ClientHeadersAgent streaming keeps scope alive across yields
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersAgent_Streaming_HasScopeAtFirstYieldAsync()
{
// Arrange: in production the SCM pipeline policy fires once at the first MoveNextAsync
// (when MEAI's OpenAIResponsesChatClient initiates the HTTP request). We assert that at
// that critical moment the AsyncLocal scope is observable. End-to-end coverage of the wire
// behavior is provided by EndToEnd_UseClientHeaders_Streaming_StampsOnWireAsync.
IReadOnlyDictionary<string, string>? observedAtFirstYield = null;
var inner = new ProbeStreamingAgent(yields: 1, onYield: () => observedAtFirstYield = ClientHeadersScope.Current);
var agent = new ClientHeadersAgent(inner);
var chatOptions = new ChatOptions();
chatOptions.WithClientHeader("x-client-end-user-id", "carol");
// Act
await foreach (var _ in agent.RunStreamingAsync(messages: [], options: new ChatClientAgentRunOptions(chatOptions)))
{
// drain
}
// Assert
Assert.NotNull(observedAtFirstYield);
Assert.Equal("carol", observedAtFirstYield!["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 10. ClientHeadersScope is AsyncLocal-isolated across parallel runs and auto-restores on
// async-method return (no explicit Dispose needed).
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersScope_IsAsyncLocalIsolatedAndAutoRestoresAsync()
{
// Arrange
var dictA = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
var dictB = new Dictionary<string, string> { ["x-client-end-user-id"] = "bob" };
// Act / Assert: parallel async flows do not see each other's mutations.
await Task.WhenAll(
ProbeAsync(dictA, "alice"),
ProbeAsync(dictB, "bob"));
async Task ProbeAsync(Dictionary<string, string> dict, string expected)
{
ClientHeadersScope.Current = dict;
await Task.Yield();
Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
}
// Assert: setting Current inside an awaited async method does not leak back to the caller
// after the method returns. This is the AsyncLocal natural-restoration behavior the
// ClientHeadersAgent relies on.
Assert.Null(ClientHeadersScope.Current);
}
// -------------------------------------------------------------------------------------------
// 11. ClientHeadersPolicy no-ops when scope is null
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersPolicy_NoOps_WhenScopeIsNullAsync()
{
// Arrange
using var handler = new RecordingHandler();
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var pipeline = ClientPipeline.Create(
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) },
perCallPolicies: [ClientHeadersPolicy.Instance],
perTryPolicies: default,
beforeTransportPolicies: default);
// Act: no scope pushed
var msg = pipeline.CreateMessage();
msg.Request.Method = "GET";
msg.Request.Uri = new Uri("https://example.test/");
await pipeline.SendAsync(msg);
// Assert
Assert.DoesNotContain(handler.Headers, kv => kv.Key.StartsWith("x-client-", StringComparison.OrdinalIgnoreCase));
}
// -------------------------------------------------------------------------------------------
// 12. ClientHeadersPolicy stamps with Set (overwrites pre-existing same-name header)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task ClientHeadersPolicy_StampsWithSet_OverwritesPreExistingHeaderAsync()
{
// Arrange
using var handler = new RecordingHandler();
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
// A pre-existing policy that always sets x-client-end-user-id=initial.
var preExisting = new HeaderSetterPolicy("x-client-end-user-id", "initial");
var pipeline = ClientPipeline.Create(
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) },
perCallPolicies: [preExisting, ClientHeadersPolicy.Instance],
perTryPolicies: default,
beforeTransportPolicies: default);
// Act
ClientHeadersScope.Current = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
try
{
var msg = pipeline.CreateMessage();
msg.Request.Method = "GET";
msg.Request.Uri = new Uri("https://example.test/");
await pipeline.SendAsync(msg);
}
finally
{
ClientHeadersScope.Current = null;
}
// Assert: the per-call value won.
Assert.Equal("alice", handler.Headers["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 13. Reflection dedup catches duplicate registration on a single OpenAIRequestPolicies
// -------------------------------------------------------------------------------------------
[Fact]
public void OpenAIRequestPoliciesReflection_DedupsDuplicateRegistration()
{
// Arrange
var policies = new OpenAIRequestPolicies();
// Act
var firstAdded = OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, ClientHeadersPolicy.Instance);
var secondAdded = OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, ClientHeadersPolicy.Instance);
// Assert
Assert.True(firstAdded);
Assert.False(secondAdded);
Assert.Equal(1, EntriesCount(policies));
}
// -------------------------------------------------------------------------------------------
// 14. Reflection dedup gracefully fails when shape is wrong (use a fake type to simulate)
// -------------------------------------------------------------------------------------------
[Fact]
public void OpenAIRequestPoliciesReflection_ContainsPolicy_ReturnsFalse_OnNullEntries()
{
// Arrange: a fresh OpenAIRequestPolicies (Entries field exists, but is empty).
var policies = new OpenAIRequestPolicies();
// Act / Assert
Assert.False(OpenAIRequestPoliciesReflection.ContainsPolicy(policies, ClientHeadersPolicy.Instance));
}
// -------------------------------------------------------------------------------------------
// 15. CI guardrail: assert OpenAIRequestPolicies._entries field shape
// -------------------------------------------------------------------------------------------
[Fact]
public void OpenAIRequestPolicies_EntriesField_ShapeGuardrail()
{
// Arrange / Act
var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
// Assert: this test fails loudly if MEAI renames the field, so we know to update
// OpenAIRequestPoliciesReflection. The Entry array element type is private so we only
// assert that the field is an Array; the ContainsPolicy method itself reflects the Policy
// member dynamically so it survives Entry-shape changes too.
Assert.NotNull(field);
Assert.True(typeof(Array).IsAssignableFrom(field!.FieldType),
$"Expected _entries to be an Array, got {field.FieldType}.");
}
// -------------------------------------------------------------------------------------------
// 16. Foundry hosting end-to-end: per-call x-client-end-user-id reaches the wire
// (Covered by the existing HostedOutboundUserAgentTests pattern; we add a focused unit test
// here that verifies UseClientHeaders + the OpenAIRequestPolicies bridge stamps headers
// on the wire when invoked through a real ChatClientAgent.)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task EndToEnd_UseClientHeaders_StampsOnWireAsync()
{
// Arrange: build a real OpenAI ResponsesClient pointed at a fake handler.
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
var responsesClient = openAIClient.GetResponsesClient();
IChatClient chatClient = responsesClient.AsIChatClient();
AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
// Act
await agent.RunAsync("hi", options: runOptions);
// Assert
Assert.True(handler.Requests.Count > 0);
Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 17. Customer raw end-to-end: covered by #16 (which uses raw new ChatClientAgent + AsBuilder).
// Add a streaming variant here.
// -------------------------------------------------------------------------------------------
[Fact]
public async Task EndToEnd_UseClientHeaders_Streaming_StampsOnWireAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
var responsesClient = openAIClient.GetResponsesClient();
IChatClient chatClient = responsesClient.AsIChatClient();
AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "carol");
// Act
try
{
await foreach (var _ in agent.RunStreamingAsync("hi", options: runOptions))
{
// drain
}
}
catch
{
// The fake handler returns a non-streaming JSON; MEAI may throw mid-stream while parsing.
// The wire request is captured before parsing, so the assertion below still validates the header.
}
// Assert
Assert.True(handler.Requests.Count > 0);
Assert.Equal("carol", handler.Requests[0].Headers["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 18. Headers-set-but-no-bridge: silent no-op confirmed (non-OpenAI mock)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task UseClientHeaders_OnNonOpenAIClient_IsSilentNoOpAsync()
{
// Arrange: a non-OpenAI fake agent that does not expose OpenAIRequestPolicies.
var inner = new FakeAgent();
var agent = inner.AsBuilder().UseClientHeaders().Build();
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
// Act / Assert: no throw. AsyncLocal flows but no policy stamps anything because the
// chat client doesn't have OpenAIRequestPolicies registered.
await agent.RunAsync("hi", options: runOptions);
Assert.True(true);
}
// -------------------------------------------------------------------------------------------
// 19. Shared IChatClient across two agents both calling UseClientHeaders registers
// ClientHeadersPolicy exactly once on the shared OpenAIRequestPolicies.
// -------------------------------------------------------------------------------------------
[Fact]
public async Task SharedChatClient_AcrossTwoAgents_RegistersPolicyOnceAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
var responsesClient = openAIClient.GetResponsesClient();
IChatClient chatClient = responsesClient.AsIChatClient();
// Act: build two agents that share the same chat client. Each calls UseClientHeaders.
AIAgent agent1 = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
AIAgent agent2 = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
// Assert: the shared OpenAIRequestPolicies has exactly one ClientHeadersPolicy registered.
var policies = chatClient.GetService<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
// And on the wire, the per-call header is stamped exactly once (no duplication).
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
try
{
await agent1.RunAsync("hi", options: runOptions);
}
catch
{
// tolerate parser issues; we assert on the wire.
}
Assert.True(handler.Requests.Count > 0);
Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 20. ClientHeadersPolicy registration via UseClientHeaders is deduped across many invocations
// on the same chat client (mirrors the Foundry.Hosting per-request resolution scenario).
// -------------------------------------------------------------------------------------------
[Fact]
public void UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce()
{
// Arrange: a chat client whose OpenAIRequestPolicies service we can inspect.
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"),
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
// Act: simulate N hosted-resolution-style wirings on top of the same shared chat client.
for (int i = 0; i < 25; i++)
{
_ = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
}
// Assert: exactly one ClientHeadersPolicy entry on the shared OpenAIRequestPolicies.
var policies = chatClient.GetService<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
}
// -------------------------------------------------------------------------------------------
// 21. Non-streaming hardening: a non-streaming run must restore the ambient ClientHeadersScope
// on return so a previous run's x-client-* headers do not carry into a later headerless run
// on the same async flow. (Streaming already restores naturally via its async iterator.)
// -------------------------------------------------------------------------------------------
[Fact]
public async Task NonStreaming_DoesNotCarryClientHeadersToSubsequentRunAsync()
{
// Arrange: a probe inner agent records ClientHeadersScope.Current observed at each run.
var observed = new List<IReadOnlyDictionary<string, string>?>();
var inner = new ProbeAgent(_ =>
{
observed.Add(ClientHeadersScope.Current);
return Task.CompletedTask;
});
var agent = new ClientHeadersAgent(inner);
// Act: run 1 supplies a client header; run 2 supplies fresh, empty ChatOptions (no headers).
var run1Options = new ChatOptions();
run1Options.WithClientHeader("x-client-end-user-id", "alice");
await agent.RunAsync(messages: [], options: new ChatClientAgentRunOptions(run1Options));
// The scope must not carry back into the caller's flow after run 1 returns.
Assert.Null(ClientHeadersScope.Current);
var run2Options = new ChatOptions();
await agent.RunAsync(messages: [], options: new ChatClientAgentRunOptions(run2Options));
// Assert: run 1 observed "alice"; run 2 observed no headers (did not inherit run 1's value).
Assert.Equal(2, observed.Count);
Assert.NotNull(observed[0]);
Assert.Equal("alice", observed[0]!["x-client-end-user-id"]);
Assert.Null(observed[1]);
Assert.Null(ClientHeadersScope.Current);
}
// -------------------------------------------------------------------------------------------
// 22. End-to-end non-streaming: a second headerless run on the same async flow must not carry
// the first run's x-client-end-user-id onto the wire.
// -------------------------------------------------------------------------------------------
[Fact]
public async Task EndToEnd_NonStreaming_SecondRunDoesNotInheritHeaderOnWireAsync()
{
// Arrange: a real OpenAI ResponsesClient pointed at a recording handler.
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
// Act: run 1 carries x-client-end-user-id; run 2 supplies fresh options with no client headers.
var run1 = new ChatClientAgentRunOptions(new ChatOptions());
run1.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
await agent.RunAsync("hi", options: run1);
var afterRun1 = handler.Requests.Count;
var run2 = new ChatClientAgentRunOptions(new ChatOptions());
await agent.RunAsync("hi", options: run2);
// Assert: run 1 stamped the header; none of the requests issued by run 2 carry it.
// (Assert per-run rather than on an exact total count, which would be brittle to
// any extra/internal SDK requests.)
Assert.True(afterRun1 > 0);
Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
var run2Requests = handler.Requests.Skip(afterRun1).ToList();
Assert.NotEmpty(run2Requests);
Assert.All(run2Requests, r => Assert.False(r.Headers.ContainsKey("x-client-end-user-id")));
}
// -------------------------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------------------------
private static int EntriesCount(OpenAIRequestPolicies policies)
{
var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
var array = (Array?)field?.GetValue(policies);
return array?.Length ?? -1;
}
private static string MinimalResponseJson() => """
{
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
""";
/// <summary>An <see cref="HttpClientHandler"/> that records request headers and returns a fixed response body.</summary>
private sealed class RecordingHandler : HttpClientHandler
{
private readonly string _body;
public RecordingHandler(string body = """{}""")
{
this._body = body;
}
public List<RecordedRequest> Requests { get; } = [];
public Dictionary<string, string> Headers => this.Requests.Count > 0 ? this.Requests[0].Headers : new Dictionary<string, string>();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var h in request.Headers)
{
headers[h.Key] = string.Join(",", h.Value);
}
this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", headers));
var resp = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
RequestMessage = request,
};
return Task.FromResult(resp);
}
}
private sealed class RecordedRequest
{
public RecordedRequest(string uri, Dictionary<string, string> headers)
{
this.Uri = uri;
this.Headers = headers;
}
public string Uri { get; }
public Dictionary<string, string> Headers { get; }
}
/// <summary>A pipeline policy that always stamps a fixed header value via Headers.Set.</summary>
private sealed class HeaderSetterPolicy : PipelinePolicy
{
private readonly string _name;
private readonly string _value;
public HeaderSetterPolicy(string name, string value)
{
this._name = name;
this._value = value;
}
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
/// <summary>A trivial session used by fake agents in these tests.</summary>
private sealed class TrivialSession : AgentSession { }
/// <summary>A minimal AIAgent that does nothing; used to test decorator wiring.</summary>
private sealed class FakeAgent : AIAgent
{
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentResponse());
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield break;
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TrivialSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new TrivialSession());
}
/// <summary>An AIAgent that invokes a probe action each time RunAsync is called.</summary>
private sealed class ProbeAgent : AIAgent
{
private readonly Func<CancellationToken, Task> _probe;
public ProbeAgent(Func<CancellationToken, Task> probe)
{
this._probe = probe;
}
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
await this._probe(cancellationToken);
return new AgentResponse();
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await this._probe(cancellationToken);
yield break;
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TrivialSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new TrivialSession());
}
/// <summary>An AIAgent whose streaming method invokes <c>onYield</c> at each yield point.</summary>
private sealed class ProbeStreamingAgent : AIAgent
{
private readonly int _yields;
private readonly Action _onYield;
public ProbeStreamingAgent(int yields, Action onYield)
{
this._yields = yields;
this._onYield = onYield;
}
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentResponse());
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
for (int i = 0; i < this._yields; i++)
{
this._onYield();
await Task.Yield();
yield return new AgentResponseUpdate();
}
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TrivialSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new TrivialSession());
}
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider
{
public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary<string, object> properties)
{
return new GetTokenOptions(new Dictionary<string, object>());
}
public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken)
{
return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1));
}
public override ValueTask<AuthenticationToken> GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken)
{
return new ValueTask<AuthenticationToken>(this.GetToken(options, cancellationToken));
}
}
@@ -0,0 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
#pragma warning disable OPENAI001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
public class FoundryAIToolTests
{
[Fact]
public void CreateMcpTool_WithProjectConnectionId_SetsProjectConnectionId()
{
// Arrange
const string ConnectionId = "my-foundry-connection";
// Act
AITool tool = FoundryAITool.CreateMcpTool(
serverLabel: "github",
serverUri: new Uri("https://api.githubcopilot.com/mcp"),
projectConnectionId: ConnectionId,
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval));
// Assert
var mcpTool = Assert.IsType<McpTool>(tool.GetService(typeof(McpTool)));
Assert.Equal(ConnectionId, mcpTool.ProjectConnectionId);
}
[Fact]
public void CreateMcpTool_WithProjectConnectionId_SerializesProjectConnectionId()
{
// Arrange
const string ConnectionId = "my-foundry-connection";
// Act
AITool tool = FoundryAITool.CreateMcpTool(
serverLabel: "github",
serverUri: new Uri("https://api.githubcopilot.com/mcp"),
projectConnectionId: ConnectionId);
// Assert
var mcpTool = Assert.IsType<McpTool>(tool.GetService(typeof(McpTool)));
string json = ModelReaderWriter.Write(mcpTool, ModelReaderWriterOptions.Json).ToString();
Assert.Contains("\"project_connection_id\":\"my-foundry-connection\"", json);
Assert.Contains("\"server_url\":\"https://api.githubcopilot.com/mcp\"", json);
}
[Fact]
public void CreateMcpTool_WithoutProjectConnectionId_DoesNotEmitProjectConnectionId()
{
// Arrange & Act
AITool tool = FoundryAITool.CreateMcpTool(
serverLabel: "github",
serverUri: new Uri("https://api.githubcopilot.com/mcp"));
// Assert
var mcpTool = Assert.IsType<McpTool>(tool.GetService(typeof(McpTool)));
Assert.Null(mcpTool.ProjectConnectionId);
string json = ModelReaderWriter.Write(mcpTool, ModelReaderWriterOptions.Json).ToString();
Assert.DoesNotContain("project_connection_id", json);
}
[Fact]
public void CreateMcpTool_WithProjectConnectionIdAndOtherSettings_PreservesAllSettings()
{
// Arrange
const string ConnectionId = "my-foundry-connection";
const string Token = "my-token";
// Act
AITool tool = FoundryAITool.CreateMcpTool(
serverLabel: "github",
serverUri: new Uri("https://api.githubcopilot.com/mcp"),
authorizationToken: Token,
serverDescription: "GitHub MCP",
headers: new Dictionary<string, string> { ["X-Custom"] = "value" },
allowedTools: new McpToolFilter { ToolNames = { "search_issues" } },
projectConnectionId: ConnectionId);
// Assert
var mcpTool = Assert.IsType<McpTool>(tool.GetService(typeof(McpTool)));
Assert.Equal(ConnectionId, mcpTool.ProjectConnectionId);
Assert.Equal(Token, mcpTool.AuthorizationToken);
Assert.Equal("GitHub MCP", mcpTool.ServerDescription);
Assert.Contains("X-Custom", mcpTool.Headers);
Assert.Contains("search_issues", mcpTool.AllowedTools.ToolNames);
}
}
@@ -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;
/// <summary>
/// Unit tests for the file and vector-store forwarder extensions on <see cref="FoundryAgent"/>
/// declared in <see cref="FoundryAgentExtensions"/>. The forwarders are thin shims over the
/// inner <see cref="FoundryChatClient"/>, 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.
/// </summary>
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<string>());
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<ArgumentNullException>(() =>
FoundryAgentExtensions.UploadFileAsync(null!, "x", FileUploadPurpose.Assistants));
[Fact]
public async Task DeleteFileAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryAgentExtensions.DeleteFileAsync(null!, "file_abc"));
[Fact]
public async Task CreateVectorStoreAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryAgentExtensions.CreateVectorStoreAsync(null!, "kb", Array.Empty<string>()));
[Fact]
public async Task DeleteVectorStoreAsync_NullAgent_ThrowsArgumentNullExceptionAsync()
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
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
@@ -0,0 +1,843 @@
// 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 Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for the <see cref="FoundryAgent"/> class.
/// </summary>
public class FoundryAgentTests
{
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
#region Constructor validation tests
[Fact]
public void Constructor_WithNullEndpoint_ThrowsArgumentNullException()
{
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
new FoundryAgent(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test instructions"));
Assert.Equal("endpoint", exception.ParamName);
}
[Fact]
public void Constructor_WithNullCredential_ThrowsArgumentNullException()
{
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
new FoundryAgent(
projectEndpoint: s_testEndpoint,
credential: null!,
model: "gpt-4o-mini",
instructions: "Test instructions"));
Assert.Equal("credential", exception.ParamName);
}
[Fact]
public void Constructor_WithNullModel_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() =>
new FoundryAgent(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
model: null!,
instructions: "Test instructions"));
}
[Fact]
public void Constructor_WithEmptyModel_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() =>
new FoundryAgent(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
model: string.Empty,
instructions: "Test instructions"));
}
[Fact]
public void Constructor_WithNullInstructions_ThrowsArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() =>
new FoundryAgent(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: null!));
}
[Fact]
public void Constructor_WithValidParams_CreatesAgent()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "You are a helpful assistant.",
name: "test-agent",
description: "A test agent");
Assert.NotNull(agent);
Assert.Equal("test-agent", agent.Name);
Assert.Equal("A test agent", agent.Description);
}
#endregion
#region Property tests
[Fact]
public void Name_ReturnsConfiguredName()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test",
name: "my-agent");
Assert.Equal("my-agent", agent.Name);
}
[Fact]
public void Description_ReturnsConfiguredDescription()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test",
description: "Agent description");
Assert.Equal("Agent description", agent.Description);
}
[Fact]
public void GetService_ReturnsAIProjectClient()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
AIProjectClient? client = agent.GetService<AIProjectClient>();
Assert.NotNull(client);
}
[Fact]
public void GetService_ReturnsChatClientAgent()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
ChatClientAgent? innerAgent = agent.GetService<ChatClientAgent>();
Assert.NotNull(innerAgent);
}
[Fact]
public void Constructor_PreWiresClientHeadersAgent()
{
// Arrange / Act: the public FoundryAgent ctor should pre-wire the client-headers
// pipeline so x-client-* headers stamped on ChatClientAgentRunOptions reach the wire.
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
// Assert: ClientHeadersAgent decorator is present in the delegating chain.
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
}
[Fact]
public void Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent()
{
// Arrange: stand up a real AIProjectClient pointed at a fake transport.
using var handler = new NoopHandler();
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) });
// Act: this AsAIAgent path constructs FoundryAgent via its internal
// (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
// Assert
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
}
private sealed class NoopHandler : HttpClientHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
[Fact]
public void GetService_ReturnsIChatClient()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
IChatClient? chatClient = agent.GetService<IChatClient>();
Assert.NotNull(chatClient);
}
[Fact]
public void GetService_ReturnsChatClientMetadata()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
ChatClientMetadata? metadata = agent.GetService<ChatClientMetadata>();
Assert.NotNull(metadata);
Assert.Equal("microsoft.foundry", metadata.ProviderName);
}
[Fact]
public void GetService_ReturnsNullForUnknownType()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
Assert.Null(agent.GetService<HttpClient>());
}
#endregion
#region CreateSessionAsync tests
[Fact]
public async Task CreateSessionAsync_WithConversationId_ReturnsChatClientAgentSessionAsync()
{
// Arrange
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
const string ConversationId = "test-conversation-id";
// Act
AgentSession session = await agent.CreateSessionAsync(ConversationId);
// Assert
ChatClientAgentSession chatSession = Assert.IsType<ChatClientAgentSession>(session);
Assert.Equal(ConversationId, chatSession.ConversationId);
}
[Fact]
public async Task CreateSessionAsync_WithoutConversationId_ReturnsChatClientAgentSessionWithoutConversationIdAsync()
{
// Arrange
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
// Act
AgentSession session = await agent.CreateSessionAsync();
// Assert
ChatClientAgentSession chatSession = Assert.IsType<ChatClientAgentSession>(session);
Assert.Null(chatSession.ConversationId);
}
#endregion
#region Functional tests
[Fact]
public async Task RunAsync_SendsRequestToResponsesAPIAsync()
{
bool requestTriggered = false;
using HttpHandlerAssert httpHandler = new(request =>
{
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
{
requestTriggered = true;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
TestDataUtil.GetOpenAIDefaultResponseJson(),
Encoding.UTF8,
"application/json")
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using HttpClient httpClient = new(httpHandler);
#pragma warning restore CA5399
AIProjectClientOptions clientOptions = new()
{
Transport = new HttpClientPipelineTransport(httpClient)
};
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "You are a helpful assistant.",
clientOptions: clientOptions);
AgentSession session = await agent.CreateSessionAsync();
await agent.RunAsync("Hello", session);
Assert.True(requestTriggered);
}
[Fact]
public void Constructor_WithChatClientFactory_AppliesFactory()
{
bool factoryCalled = false;
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test",
clientFactory: client =>
{
factoryCalled = true;
return client;
});
Assert.True(factoryCalled);
Assert.NotNull(agent);
}
[Fact]
public async Task Constructor_AgentFrameworkUserAgentHeaderAddedToRequestsAsync()
{
// 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 System.Collections.Generic.IEnumerable<string>? values))
{
foreach (string value in values)
{
if (value.Contains("agent-framework-dotnet/"))
{
agentFrameworkUserAgentFound = 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
AIProjectClientOptions clientOptions = new()
{
Transport = new HttpClientPipelineTransport(httpClient)
};
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test",
clientOptions: clientOptions);
AgentSession session = await agent.CreateSessionAsync();
await agent.RunAsync("Hello", session);
Assert.True(agentFrameworkUserAgentFound, "Expected agent-framework-dotnet user-agent segment to be present on outbound requests.");
}
#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<ArgumentNullException>(() =>
new FoundryAgent(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider()));
Assert.Equal("agentEndpoint", ex.ParamName);
}
[Fact]
public void AgentEndpointConstructor_NullCredential_ThrowsArgumentNullException()
{
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() =>
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_ReturnsNull()
{
// Behavior change: FoundryAgent no longer caches a ProjectOpenAIClient. Callers
// retrieve it from the AIProjectClient themselves
// (agent.GetService<AIProjectClient>()!.GetProjectOpenAIClient()).
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.Null(agent.GetService<ProjectOpenAIClient>());
}
[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<AIProjectClient>());
}
[Fact]
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
{
// See AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull for rationale.
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
Assert.Null(agent.GetService<ProjectOpenAIClient>());
}
[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 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<ChatClientMetadata>();
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()
{
// 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
// 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<AIProjectClient>();
Assert.NotNull(aiProjectClient);
// 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<ArgumentException>(() =>
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<ArgumentException>(() =>
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<ArgumentException>(() =>
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<PipelinePolicy> 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<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
}
@@ -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;
/// <summary>
/// Unit tests for the internal <see cref="FoundryChatClient"/>. 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
/// <c>AsAIAgent(AgentReference)</c> extension that constructs a FoundryChatClient internally.
/// </summary>
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<ChatClientMetadata>();
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<AIProjectClient>());
// ProjectOpenAIClient is intentionally NOT exposed via GetService — callers retrieve
// it from the AIProjectClient themselves (aiProjectClient.GetProjectOpenAIClient()).
Assert.Null(chatClient.GetService<ProjectOpenAIClient>());
}
[Fact]
public void Mode1_ResponsesAgent_ReturnsNullForAgentSpecificServices()
{
// Arrange
var projectClient = CreateProjectClient();
// Act
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
// Assert
Assert.Null(chatClient.GetService<AgentReference>());
Assert.Null(chatClient.GetService<ProjectsAgentVersion>());
Assert.Null(chatClient.GetService<ProjectsAgentRecord>());
// 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<ArgumentNullException>(() => new FoundryChatClient(aiProjectClient: null!, "gpt-4o-mini"));
[Fact]
public void Mode1_ResponsesAgent_ThrowsOnEmptyModelId()
=> Assert.Throws<ArgumentException>(() => 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<ChatClientMetadata>();
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<AgentReference>());
Assert.Same(projectClient, chatClient.GetService<AIProjectClient>());
// ProjectOpenAIClient is intentionally NOT exposed via GetService — see comment in
// Mode1_ResponsesAgent_ExposesAIProjectClient_ViaGetService.
Assert.Null(chatClient.GetService<ProjectOpenAIClient>());
// Version/Record were not provided via this ctor.
Assert.Null(chatClient.GetService<ProjectsAgentVersion>());
Assert.Null(chatClient.GetService<ProjectsAgentRecord>());
}
[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<ArgumentNullException>(() =>
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.
/// <summary>
/// Verify that after the first RunAsync, the session's ConversationId is set from the
/// response, and subsequent requests include that conversation ID automatically.
/// </summary>
[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<ChatClientAgentSession>(session);
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
}
/// <summary>
/// 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.
/// </summary>
[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<ChatClientAgentSession>(session);
Assert.Equal("conv_12345", chatClientSession.ConversationId);
}
/// <summary>
/// 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.
/// </summary>
[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<ChatClientAgentSession>(session);
Assert.Equal("conv_12345", chatClientSession.ConversationId);
}
/// <summary>
/// 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.
/// </summary>
[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<ChatClientAgentSession>(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<ChatClientMetadata>();
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<ProjectOpenAIClient>());
// 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<AIProjectClient>());
Assert.Null(chatClient.GetService<AgentReference>());
Assert.Null(chatClient.GetService<ProjectsAgentVersion>());
Assert.Null(chatClient.GetService<ProjectsAgentRecord>());
}
[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<AIProjectClient>();
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<AIProjectClient>() 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<AIProjectClient>();
var second = chatClient.GetService<AIProjectClient>();
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<AIProjectClient>());
}
[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<AIProjectClient>());
}
[Fact]
public void Mode3_AgentEndpoint_ThrowsOnNullEndpoint()
=> Assert.Throws<ArgumentNullException>(() =>
new FoundryChatClient(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider(), clientOptions: null));
[Fact]
public void Mode3_AgentEndpoint_ThrowsOnNullCredential()
=> Assert.Throws<ArgumentNullException>(() =>
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<ArgumentException>(() =>
FoundryChatClient.ParseAgentEndpoint(new Uri("https://example.com/api/projects/myproj/anyseg/myagent/endpoint/protocols/openai")));
[Fact]
public void ParseAgentEndpoint_ThrowsOnWrongSuffix()
=> Assert.Throws<ArgumentException>(() =>
FoundryChatClient.ParseAgentEndpoint(new Uri("https://example.com/api/projects/myproj/agents/myagent/wrong/suffix")));
[Fact]
public void ParseAgentEndpoint_ThrowsOnNullUri()
=> Assert.Throws<ArgumentNullException>(() => 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<OpenAIRequestPolicies>();
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<ProjectsAgentVersion>(
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<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(2, EntriesCount(policies!));
Assert.Same(agentVersion, chatClient.GetService<ProjectsAgentVersion>());
Assert.NotNull(chatClient.GetService<AgentReference>());
}
#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
@@ -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;
/// <summary>
/// Unit tests for the file and vector-store helper methods on <see cref="FoundryChatClient"/>.
/// Covers all four methods across the three FoundryChatClient construction modes plus argument
/// validation, cancellation, and request-body shape on the wire.
/// </summary>
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<ArgumentNullException>(() =>
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<FileNotFoundException>(() =>
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<Exception>(() =>
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<ArgumentException>(() => chatClient.DeleteFileAsync(null!));
}
[Fact]
public async Task DeleteFileAsync_EmptyId_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync<ArgumentException>(() => 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<Exception>(() => 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<string>(), 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<string>(), 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<string>());
Assert.Equal("vs_empty", store.Id);
}
[Fact]
public async Task CreateVectorStoreAsync_NullName_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
chatClient.CreateVectorStoreAsync(null!, Array.Empty<string>()));
}
[Fact]
public async Task CreateVectorStoreAsync_NullFilePaths_ThrowsArgumentNullExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAsync<ArgumentNullException>(() =>
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<Exception>(() =>
chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), 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<string>());
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<TimeoutException>(() =>
chatClient.CreateVectorStoreAsync("x", Array.Empty<string>(), 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<string>();
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<Exception>(() => 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<Exception>(() => 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<ArgumentException>(() => 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<Exception>(() => 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<RecordedRequest> Requests { get; } = [];
public RequestRecorder(string? responseBody)
{
this._responseBody = responseBody ?? "{}";
}
protected override async Task<HttpResponseMessage> 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
@@ -0,0 +1,494 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Tests for <see cref="FoundryEvalConverter"/>.
/// </summary>
public sealed class FoundryEvalConverterTests
{
// ---------------------------------------------------------------
// ResolveEvaluator tests
// ---------------------------------------------------------------
[Fact]
public void ResolveEvaluator_QualityShortNames_ResolvesToBuiltin()
{
Assert.Equal("builtin.relevance", FoundryEvalConverter.ResolveEvaluator("relevance"));
Assert.Equal("builtin.coherence", FoundryEvalConverter.ResolveEvaluator("coherence"));
}
[Fact]
public void ResolveEvaluator_FullyQualifiedName_ReturnsSame()
{
Assert.Equal("builtin.relevance", FoundryEvalConverter.ResolveEvaluator("builtin.relevance"));
}
[Fact]
public void ResolveEvaluator_UnknownName_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(
() => FoundryEvalConverter.ResolveEvaluator("gobblygook"));
Assert.Contains("gobblygook", ex.Message);
}
[Fact]
public void ResolveEvaluator_AgentEvaluators_ResolveCorrectly()
{
Assert.Equal("builtin.intent_resolution", FoundryEvalConverter.ResolveEvaluator("intent_resolution"));
Assert.Equal("builtin.tool_call_accuracy", FoundryEvalConverter.ResolveEvaluator("tool_call_accuracy"));
}
// ---------------------------------------------------------------
// FoundryEvalConverter.ConvertMessage tests
// ---------------------------------------------------------------
[Fact]
public void ConvertMessage_PlainText_ProducesTextContent()
{
var msg = new ChatMessage(ChatRole.User, "Hello world");
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
Assert.Equal("user", output[0].Role);
var text = Assert.IsType<WireTextContent>(Assert.Single(output[0].Content));
Assert.Equal("Hello world", text.Text);
}
[Fact]
public void ConvertMessage_ImageUri_ProducesInputImage()
{
var msg = new ChatMessage(ChatRole.User,
[
new UriContent(new Uri("https://example.com/img.png"), "image/png"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
Assert.IsType<WireImageContent>(Assert.Single(output[0].Content));
}
[Fact]
public void ConvertMessage_FunctionCall_ProducesToolCallContent()
{
var msg = new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather", new Dictionary<string, object?> { ["city"] = "Seattle" }),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
var toolCall = Assert.IsType<WireToolCallContent>(Assert.Single(output[0].Content));
Assert.Equal("c1", toolCall.ToolCallId);
Assert.Equal("get_weather", toolCall.Name);
}
[Fact]
public void ConvertMessage_FunctionCallWithoutArguments_OmitsArguments()
{
var msg = new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "list_items"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
var toolCall = Assert.IsType<WireToolCallContent>(Assert.Single(output[0].Content));
Assert.Null(toolCall.Arguments);
}
[Fact]
public void ConvertMessage_FunctionResults_FanOutToSeparateMessages()
{
var msg = new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent("c1", "72F sunny"),
new FunctionResultContent("c2", "Paris 68F"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Equal(2, output.Count);
Assert.All(output, m => Assert.Equal("tool", m.Role));
Assert.Equal("c1", output[0].ToolCallId);
Assert.Equal("c2", output[1].ToolCallId);
}
[Fact]
public void ConvertMessage_EmptyContent_ProducesEmptyTextFallback()
{
var msg = new ChatMessage(ChatRole.Assistant, Array.Empty<AIContent>());
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
var text = Assert.IsType<WireTextContent>(Assert.Single(output[0].Content));
Assert.Equal(string.Empty, text.Text);
}
[Fact]
public void ConvertMessage_MixedContent_ProducesAllContentTypes()
{
var msg = new ChatMessage(ChatRole.User,
[
new TextContent("Describe this"),
new UriContent(new Uri("https://example.com/img.png"), "image/png"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
Assert.Equal(2, output[0].Content.Count);
Assert.IsType<WireTextContent>(output[0].Content[0]);
Assert.IsType<WireImageContent>(output[0].Content[1]);
}
// ---------------------------------------------------------------
// FoundryEvalConverter.ConvertEvalItem tests
// ---------------------------------------------------------------
[Fact]
public void ConvertEvalItem_BasicItem_HasQueryAndResponse()
{
var item = new EvalItem(query: "What is AI?", response: "Artificial Intelligence.");
var payload = FoundryEvalConverter.ConvertEvalItem(item);
Assert.Equal("What is AI?", payload.Query);
Assert.Equal("Artificial Intelligence.", payload.Response);
Assert.NotNull(payload.QueryMessages);
Assert.NotNull(payload.ResponseMessages);
}
[Fact]
public void ConvertEvalItem_WithContext_IncludesContextField()
{
var item = new EvalItem(query: "q", response: "r")
{
Context = "Some grounding context",
};
var payload = FoundryEvalConverter.ConvertEvalItem(item);
Assert.Equal("Some grounding context", payload.Context);
}
[Fact]
public void ConvertEvalItem_WithoutContext_OmitsContextField()
{
var item = new EvalItem(query: "q", response: "r");
var payload = FoundryEvalConverter.ConvertEvalItem(item);
Assert.Null(payload.Context);
}
[Fact]
public void ConvertEvalItem_WithExpectedOutput_PopulatesGroundTruth()
{
// Arrange
var item = new EvalItem(query: "q", response: "r")
{
ExpectedOutput = "the golden answer",
};
// Act
var payload = FoundryEvalConverter.ConvertEvalItem(item);
// Assert
Assert.Equal("the golden answer", payload.GroundTruth);
}
[Fact]
public void ConvertEvalItem_WithoutExpectedOutput_OmitsGroundTruth()
{
// Arrange
var item = new EvalItem(query: "q", response: "r");
// Act
var payload = FoundryEvalConverter.ConvertEvalItem(item);
// Assert
Assert.Null(payload.GroundTruth);
}
// ---------------------------------------------------------------
// FoundryEvalConverter.BuildTestingCriteria tests
// ---------------------------------------------------------------
[Fact]
public void BuildTestingCriteria_QualityEvaluator_UsesStringDataMapping()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["relevance"], "gpt-4o-mini", includeDataMapping: true);
Assert.Single(criteria);
var entry = criteria[0];
Assert.Equal("azure_ai_evaluator", entry.Type);
Assert.Equal("builtin.relevance", entry.EvaluatorName);
Assert.NotNull(entry.DataMapping);
var mapping = entry.DataMapping;
Assert.Equal("{{item.query}}", mapping["query"]);
Assert.Equal("{{item.response}}", mapping["response"]);
}
[Fact]
public void BuildTestingCriteria_AgentEvaluator_UsesConversationArrayMapping()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["intent_resolution"], "gpt-4o-mini", includeDataMapping: true);
Assert.Single(criteria);
var mapping = criteria[0].DataMapping;
Assert.NotNull(mapping);
Assert.Equal("{{item.query_messages}}", mapping["query"]);
Assert.Equal("{{item.response_messages}}", mapping["response"]);
}
[Fact]
public void BuildTestingCriteria_ToolEvaluator_IncludesToolDefinitions()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["tool_call_accuracy"], "gpt-4o-mini", includeDataMapping: true);
Assert.Single(criteria);
var mapping = criteria[0].DataMapping;
Assert.NotNull(mapping);
Assert.True(mapping.ContainsKey("tool_definitions"));
Assert.Equal("{{item.tool_definitions}}", mapping["tool_definitions"]);
}
[Fact]
public void BuildTestingCriteria_GroundednessEvaluator_IncludesContext()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["groundedness"], "gpt-4o-mini", includeDataMapping: true);
Assert.Single(criteria);
var mapping = criteria[0].DataMapping;
Assert.NotNull(mapping);
Assert.True(mapping.ContainsKey("context"));
Assert.Equal("{{item.context}}", mapping["context"]);
}
[Fact]
public void BuildTestingCriteria_SimilarityEvaluator_IncludesGroundTruth()
{
// Act
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["similarity"], "gpt-4o-mini", includeDataMapping: true);
// Assert
Assert.Single(criteria);
Assert.Equal("builtin.similarity", criteria[0].EvaluatorName);
var mapping = criteria[0].DataMapping;
Assert.NotNull(mapping);
Assert.True(mapping.ContainsKey("ground_truth"));
Assert.Equal("{{item.ground_truth}}", mapping["ground_truth"]);
}
[Fact]
public void BuildTestingCriteria_NonGroundTruthEvaluator_OmitsGroundTruth()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["relevance"], "gpt-4o-mini", includeDataMapping: true);
var mapping = criteria[0].DataMapping;
Assert.NotNull(mapping);
Assert.False(mapping.ContainsKey("ground_truth"));
}
[Fact]
public void BuildTestingCriteria_WithoutDataMapping_OmitsMappingField()
{
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["relevance"], "gpt-4o-mini", includeDataMapping: false);
Assert.Single(criteria);
Assert.Null(criteria[0].DataMapping);
}
[Fact]
public void BuildTestingCriteria_WithRubricRef_EmitsAzureAiEvaluatorWithVersion()
{
var rubric = new GeneratedEvaluatorRef("policy-rubric", Version: "3", DisplayName: "Policy");
var criteria = FoundryEvalConverter.BuildTestingCriteria(
[rubric], "gpt-4o-mini", includeDataMapping: true);
Assert.Single(criteria);
var entry = criteria[0];
Assert.Equal("azure_ai_evaluator", entry.Type);
Assert.Equal("Policy", entry.Name);
Assert.Equal("policy-rubric", entry.EvaluatorName);
Assert.Equal("3", entry.EvaluatorVersion);
Assert.Equal("gpt-4o-mini", entry.InitializationParameters.DeploymentName);
var mapping = entry.DataMapping;
Assert.NotNull(mapping);
Assert.Equal("{{item.query_messages}}", mapping["query"]);
Assert.Equal("{{item.response_messages}}", mapping["response"]);
Assert.False(mapping.ContainsKey("tool_definitions"));
}
[Fact]
public void BuildTestingCriteria_WithVersionlessRubricRef_OmitsVersionField()
{
var rubric = GeneratedEvaluatorRef.Latest("policy-rubric");
var criteria = FoundryEvalConverter.BuildTestingCriteria(
[rubric], "gpt-4o-mini", includeDataMapping: false);
Assert.Single(criteria);
var entry = criteria[0];
Assert.Equal("policy-rubric", entry.Name); // falls back to Name when DisplayName is null
Assert.Equal("policy-rubric", entry.EvaluatorName);
Assert.Null(entry.EvaluatorVersion);
Assert.Null(entry.DataMapping);
}
[Fact]
public void BuildTestingCriteria_RubricRefWithTools_IncludesToolDefinitions()
{
var rubric = new GeneratedEvaluatorRef("tool-aware-rubric", Version: "1");
var criteria = FoundryEvalConverter.BuildTestingCriteria(
[rubric], "gpt-4o-mini", includeDataMapping: true, includeToolDefinitions: true);
Assert.Single(criteria);
var mapping = criteria[0].DataMapping;
Assert.NotNull(mapping);
Assert.True(mapping.ContainsKey("tool_definitions"));
Assert.Equal("{{item.tool_definitions}}", mapping["tool_definitions"]);
}
[Fact]
public void BuildTestingCriteria_MixedSpecs_PreservesOrder()
{
var rubric = new GeneratedEvaluatorRef("policy-rubric", Version: "2");
var criteria = FoundryEvalConverter.BuildTestingCriteria(
["relevance", rubric, "coherence"], "gpt-4o-mini", includeDataMapping: false);
Assert.Equal(3, criteria.Count);
Assert.Equal("builtin.relevance", criteria[0].EvaluatorName);
Assert.Equal("policy-rubric", criteria[1].EvaluatorName);
Assert.Equal("2", criteria[1].EvaluatorVersion);
Assert.Equal("builtin.coherence", criteria[2].EvaluatorName);
}
// ---------------------------------------------------------------
// FoundryEvalConverter.BuildItemSchema tests
// ---------------------------------------------------------------
[Fact]
public void BuildItemSchema_Default_HasQueryResponseAndConversationFields()
{
var schema = FoundryEvalConverter.BuildItemSchema();
Assert.True(schema.Properties.ContainsKey("query"));
Assert.True(schema.Properties.ContainsKey("response"));
Assert.True(schema.Properties.ContainsKey("query_messages"));
Assert.True(schema.Properties.ContainsKey("response_messages"));
Assert.False(schema.Properties.ContainsKey("context"));
Assert.False(schema.Properties.ContainsKey("tool_definitions"));
}
[Fact]
public void BuildItemSchema_WithContext_IncludesContextProperty()
{
var schema = FoundryEvalConverter.BuildItemSchema(hasContext: true);
Assert.True(schema.Properties.ContainsKey("context"));
}
[Fact]
public void BuildItemSchema_WithTools_IncludesToolDefinitionsProperty()
{
var schema = FoundryEvalConverter.BuildItemSchema(hasTools: true);
Assert.True(schema.Properties.ContainsKey("tool_definitions"));
}
[Fact]
public void BuildItemSchema_WithGroundTruth_IncludesGroundTruthProperty()
{
// Act
var schema = FoundryEvalConverter.BuildItemSchema(hasGroundTruth: true);
// Assert
Assert.True(schema.Properties.ContainsKey("ground_truth"));
Assert.Equal("string", schema.Properties["ground_truth"].Type);
}
[Fact]
public void BuildItemSchema_WithoutGroundTruth_OmitsGroundTruthProperty()
{
var schema = FoundryEvalConverter.BuildItemSchema();
Assert.False(schema.Properties.ContainsKey("ground_truth"));
}
// ---------------------------------------------------------------
// FoundryEvalConverter.FindMissingGroundTruthEvaluators tests
// ---------------------------------------------------------------
[Fact]
public void FindMissingGroundTruthEvaluators_NoGroundTruth_ReturnsSimilarity()
{
// Act
var missing = FoundryEvalConverter.FindMissingGroundTruthEvaluators(
["similarity", "relevance"], hasGroundTruth: false);
// Assert
Assert.Single(missing);
Assert.Equal("similarity", missing[0]);
}
[Fact]
public void FindMissingGroundTruthEvaluators_HasGroundTruth_ReturnsEmpty()
{
var missing = FoundryEvalConverter.FindMissingGroundTruthEvaluators(
["similarity"], hasGroundTruth: true);
Assert.Empty(missing);
}
[Fact]
public void FindMissingGroundTruthEvaluators_NoGroundTruthEvaluators_ReturnsEmpty()
{
var missing = FoundryEvalConverter.FindMissingGroundTruthEvaluators(
["relevance", "coherence"], hasGroundTruth: false);
Assert.Empty(missing);
}
[Fact]
public void FindMissingGroundTruthEvaluators_IgnoresRubricRefs()
{
// Rubric refs are not ground-truthdependent and must be skipped even when
// no items carry ExpectedOutput.
var rubric = new GeneratedEvaluatorRef("policy-rubric", Version: "1");
var missing = FoundryEvalConverter.FindMissingGroundTruthEvaluators(
[rubric, "relevance"], hasGroundTruth: false);
Assert.Empty(missing);
}
// ---------------------------------------------------------------
// FoundryEvalConverter.ConvertMessage DataContent test
// ---------------------------------------------------------------
[Fact]
public void ConvertMessage_DataContent_ProducesInputImage()
{
var imageBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG magic bytes
var msg = new ChatMessage(ChatRole.User,
[
new TextContent("Describe this image"),
new DataContent(imageBytes, "image/png"),
]);
var output = FoundryEvalConverter.ConvertMessage(msg);
Assert.Single(output);
Assert.Equal(2, output[0].Content.Count);
var text = Assert.IsType<WireTextContent>(output[0].Content[0]);
Assert.Equal("Describe this image", text.Text);
var image = Assert.IsType<WireImageContent>(output[0].Content[1]);
Assert.Contains("data:image/png;base64,", image.ImageUrl);
}
}
@@ -0,0 +1,254 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Tests for <see cref="FoundryEvals"/> internal helpers.
/// </summary>
public sealed class FoundryEvalsTests
{
[Fact]
public void FilterToolEvaluators_AllToolEvaluators_NoTools_ThrowsArgumentException()
{
// All configured evaluators are tool-type, but no items have tools.
var evaluators = new FoundryEvaluatorSpec[] { "tool_call_accuracy", "tool_selection" };
var ex = Assert.Throws<ArgumentException>(
() => FoundryEvals.FilterToolEvaluators(evaluators, hasTools: false));
Assert.Contains("tool definitions", ex.Message);
}
[Fact]
public void FilterToolEvaluators_MixedEvaluators_NoTools_FiltersToolOnes()
{
var evaluators = new FoundryEvaluatorSpec[] { "relevance", "tool_call_accuracy", "coherence" };
var result = FoundryEvals.FilterToolEvaluators(evaluators, hasTools: false);
Assert.Equal(2, result.Length);
Assert.Contains((FoundryEvaluatorSpec)"relevance", result);
Assert.Contains((FoundryEvaluatorSpec)"coherence", result);
Assert.DoesNotContain((FoundryEvaluatorSpec)"tool_call_accuracy", result);
}
[Fact]
public void FilterToolEvaluators_HasTools_ReturnsAllEvaluators()
{
var evaluators = new FoundryEvaluatorSpec[] { "relevance", "tool_call_accuracy" };
var result = FoundryEvals.FilterToolEvaluators(evaluators, hasTools: true);
Assert.Equal(evaluators, result);
}
[Fact]
public void FilterToolEvaluators_PreservesRubricRefs_WhenNoTools()
{
// Rubric refs are tool-aware but never tool-required, so they must survive filtering
// when no items carry tool definitions.
var rubric = new GeneratedEvaluatorRef("policy-rubric", Version: "3");
var evaluators = new FoundryEvaluatorSpec[] { "relevance", rubric, "tool_call_accuracy" };
var result = FoundryEvals.FilterToolEvaluators(evaluators, hasTools: false);
Assert.Equal(2, result.Length);
Assert.Contains((FoundryEvaluatorSpec)"relevance", result);
Assert.Contains(result, s => s.IsRubric && s.GeneratedRef!.Name == "policy-rubric");
Assert.DoesNotContain((FoundryEvaluatorSpec)"tool_call_accuracy", result);
}
// ---------------------------------------------------------------
// FoundryEvals.ParseRubricScores tests
// ---------------------------------------------------------------
[Fact]
public void ParseRubricScores_CanonicalDimensionScoresKey_ParsesAllFields()
{
// Per Microsoft Learn docs, runtime output uses properties.dimension_scores.
const string Json = """
{
"properties": {
"dimension_scores": [
{ "id": "intent_recognition", "score": 5, "applicable": true, "weight": 9, "reason": "Identified correctly." },
{ "id": "general_quality", "score": 4, "applicable": true, "weight": 5, "reason": "Strong overall." }
]
}
}
""";
using var doc = JsonDocument.Parse(Json);
var result = FoundryEvals.ParseRubricScores(doc.RootElement);
Assert.NotNull(result);
Assert.Equal(2, result!.Count);
Assert.Equal(["intent_recognition", "general_quality"], result.Select(r => r.Id));
Assert.Equal([5, 4], result.Select(r => r.Score));
Assert.Equal([9, 5], result.Select(r => r.Weight));
Assert.True(result[0].Applicable);
Assert.Equal("Identified correctly.", result[0].Reason);
}
[Fact]
public void ParseRubricScores_LegacyRubricScoresKey_StillSupported()
{
// Preview builds used the rubric_scores key; we still accept it for back-compat.
const string Json = """
{
"properties": {
"rubric_scores": [
{ "id": "a", "score": 3, "applicable": true, "weight": 1, "reason": "r" }
]
}
}
""";
using var doc = JsonDocument.Parse(Json);
var result = FoundryEvals.ParseRubricScores(doc.RootElement);
Assert.NotNull(result);
Assert.Single(result!);
Assert.Equal("a", result[0].Id);
}
[Fact]
public void ParseRubricScores_TopLevelKey_FallsBack()
{
// Defensive fallback when SDK shape omits the 'properties' wrapper.
const string Json = """
{
"dimension_scores": [
{ "id": "x", "score": 2, "applicable": true, "weight": 1, "reason": "" }
]
}
""";
using var doc = JsonDocument.Parse(Json);
var result = FoundryEvals.ParseRubricScores(doc.RootElement);
Assert.NotNull(result);
Assert.Single(result!);
Assert.Equal("x", result[0].Id);
}
[Fact]
public void ParseRubricScores_NoRubricKeys_ReturnsNull()
{
const string Json = """
{ "properties": { "other_field": [] } }
""";
using var doc = JsonDocument.Parse(Json);
var result = FoundryEvals.ParseRubricScores(doc.RootElement);
Assert.Null(result);
}
[Fact]
public void ParseRubricScores_SkipsMalformedEntries()
{
// Entries missing weight or applicable are skipped, but well-formed siblings are kept.
const string Json = """
{
"properties": {
"dimension_scores": [
{ "id": "good", "score": 3, "applicable": true, "weight": 1, "reason": "ok" },
{ "id": "bad-no-weight", "score": 2, "applicable": true, "reason": "x" },
{ "id": "bad-no-applicable", "score": 2, "weight": 1, "reason": "x" }
]
}
}
""";
using var doc = JsonDocument.Parse(Json);
var result = FoundryEvals.ParseRubricScores(doc.RootElement);
Assert.NotNull(result);
Assert.Single(result!);
Assert.Equal("good", result[0].Id);
}
[Fact]
public void ParseRubricScores_NonApplicableDimension_KeepsNullScoreWhenMissing()
{
// Non-applicable dimensions can legitimately omit score (or set it to null).
const string Json = """
{
"properties": {
"dimension_scores": [
{ "id": "skipped", "applicable": false, "weight": 5, "reason": "n/a" }
]
}
}
""";
using var doc = JsonDocument.Parse(Json);
var result = FoundryEvals.ParseRubricScores(doc.RootElement);
Assert.NotNull(result);
Assert.Single(result!);
Assert.Equal("skipped", result[0].Id);
Assert.False(result[0].Applicable);
Assert.Null(result[0].Score);
}
// ---------------------------------------------------------------
// FoundryEvaluatorSpec validation tests
// ---------------------------------------------------------------
[Fact]
public void FoundryEvaluatorSpec_Default_IsNotValid()
{
var spec = default(FoundryEvaluatorSpec);
Assert.False(spec.IsValid);
Assert.Null(spec.BuiltinName);
Assert.Null(spec.GeneratedRef);
}
[Fact]
public void FoundryEvaluatorSpec_EnsureValid_DefaultThrows()
{
var spec = default(FoundryEvaluatorSpec);
var ex = Assert.Throws<ArgumentException>(() => spec.EnsureValid("evaluators"));
Assert.Equal("evaluators", ex.ParamName);
}
[Fact]
public void FoundryEvaluatorSpec_EnsureValid_BuiltinPasses()
{
var spec = (FoundryEvaluatorSpec)"relevance";
spec.EnsureValid(); // does not throw
}
[Fact]
public void FoundryEvaluatorSpec_EnsureValid_RubricPasses()
{
var spec = (FoundryEvaluatorSpec)new GeneratedEvaluatorRef("r", "1");
spec.EnsureValid(); // does not throw
}
[Fact]
public void EnsureAllSpecsValid_DefaultEntry_ThrowsWithParamName()
{
var specs = new FoundryEvaluatorSpec[] { "relevance", default };
var ex = Assert.Throws<ArgumentException>(
() => FoundryEvals.EnsureAllSpecsValid(specs, "evaluators"));
Assert.Equal("evaluators", ex.ParamName);
Assert.Contains("index 1", ex.Message);
}
[Fact]
public void EnsureAllSpecsValid_AllValid_DoesNotThrow()
{
var specs = new FoundryEvaluatorSpec[]
{
"relevance",
new GeneratedEvaluatorRef("policy", "1"),
};
FoundryEvals.EnsureAllSpecsValid(specs, "evaluators");
}
}
@@ -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;
/// <summary>
/// Unit tests for the public <c>ToPromptAgentAsync</c> extension methods on
/// <see cref="ChatClientAgent"/> and <see cref="FoundryAgent"/>. Both entry points dispatch
/// to the same internal converter, so each behavior is asserted through both surfaces.
/// </summary>
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<AITool> { new UnsupportedTool() },
},
});
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => 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<Exception>(() => 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<DeclarativeAgentDefinition>(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<DeclarativeAgentDefinition>(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<AITool> { function },
},
});
var def = await agent.ToPromptAgentAsync();
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
var fnTool = Assert.Single(declarative.Tools);
var ft = Assert.IsType<FunctionTool>(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<AITool> { FoundryAITool.CreateWebSearchTool() },
},
});
var def = await agent.ToPromptAgentAsync();
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
var tool = Assert.Single(declarative.Tools);
// The unwrapped instance must be the concrete WebSearchTool from the OpenAI SDK.
Assert.IsType<WebSearchTool>(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<AITool> { function, FoundryAITool.CreateWebSearchTool() },
},
});
var def = await agent.ToPromptAgentAsync();
var declarative = Assert.IsType<DeclarativeAgentDefinition>(def);
Assert.Equal(2, declarative.Tools.Count);
Assert.IsType<FunctionTool>(declarative.Tools[0]);
Assert.IsType<WebSearchTool>(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<DeclarativeAgentDefinition>(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<ProjectsAgentVersion>(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<ProjectsAgentRecord>(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<DeclarativeAgentDefinition>(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<Exception>(() => 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<DeclarativeAgentDefinition>(ccaDef);
var b = Assert.IsType<DeclarativeAgentDefinition>(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<ChatResponse> GetResponseAsync(System.Collections.Generic.IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new ChatResponse());
public System.Collections.Generic.IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(System.Collections.Generic.IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> EmptyAsyncEnumerableAsync();
private static async System.Collections.Generic.IAsyncEnumerable<ChatResponseUpdate> 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
@@ -0,0 +1,96 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
public class HostedMcpToolboxAIToolTests
{
[Fact]
public void Ctor_NameOnly_BuildsMarkerAddress()
{
var tool = new HostedMcpToolboxAITool("my-toolbox");
Assert.Equal("my-toolbox", tool.ToolboxName);
Assert.Null(tool.Version);
Assert.Equal("my-toolbox", tool.ServerName);
Assert.Equal("foundry-toolbox://my-toolbox", tool.ServerAddress);
Assert.Equal("mcp", tool.Name);
}
[Fact]
public void Ctor_WithVersion_IncludesVersionQuery()
{
var tool = new HostedMcpToolboxAITool("my-toolbox", "v3");
Assert.Equal("v3", tool.Version);
Assert.Equal("foundry-toolbox://my-toolbox?version=v3", tool.ServerAddress);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Ctor_InvalidName_Throws(string? name)
{
Assert.ThrowsAny<ArgumentException>(() => new HostedMcpToolboxAITool(name!));
}
[Fact]
public void TryParseToolboxAddress_NameOnly_ReturnsTrue()
{
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(
"foundry-toolbox://my-toolbox", out var name, out var version);
Assert.True(ok);
Assert.Equal("my-toolbox", name);
Assert.Null(version);
}
[Fact]
public void TryParseToolboxAddress_WithVersion_ExtractsVersion()
{
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(
"foundry-toolbox://my-toolbox?version=v3", out var name, out var version);
Assert.True(ok);
Assert.Equal("my-toolbox", name);
Assert.Equal("v3", version);
}
[Theory]
[InlineData("https://example.com/mcp")]
[InlineData("not-a-url")]
[InlineData("")]
[InlineData(null)]
public void TryParseToolboxAddress_NonMarker_ReturnsFalse(string? address)
{
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version);
Assert.False(ok);
Assert.Null(name);
Assert.Null(version);
}
[Fact]
public void TryParseToolboxAddress_RoundTripsFromBuild()
{
var address = HostedMcpToolboxAITool.BuildAddress("box", "2025-06-01");
var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version);
Assert.True(ok);
Assert.Equal("box", name);
Assert.Equal("2025-06-01", version);
}
[Fact]
public void FoundryAITool_CreateHostedMcpToolbox_ReturnsMarker()
{
var tool = FoundryAITool.CreateHostedMcpToolbox("my-toolbox", "v1");
var marker = Assert.IsType<HostedMcpToolboxAITool>(tool);
Assert.Equal("my-toolbox", marker.ToolboxName);
Assert.Equal("v1", marker.Version);
}
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
internal sealed class HttpHandlerAssert : HttpClientHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage>? _assertion;
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>>? _assertionAsync;
public HttpHandlerAssert(Func<HttpRequestMessage, HttpResponseMessage> assertion)
{
this._assertion = assertion;
}
public HttpHandlerAssert(Func<HttpRequestMessage, Task<HttpResponseMessage>> assertionAsync)
{
this._assertionAsync = assertionAsync;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (this._assertionAsync is not null)
{
return await this._assertionAsync.Invoke(request);
}
return this._assertion!.Invoke(request);
}
#if NET
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
{
return this._assertion!(request);
}
#endif
}
@@ -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;
/// <summary>
/// One-shot verification (kept in tree to detect regressions) that MEAI 10.5.1 stamps its own
/// <c>MEAI/{version}</c> User-Agent segment automatically when an <see cref="ResponsesClient"/>
/// is wrapped via <c>AsIChatClient()</c>. 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.
/// </summary>
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<HttpResponseMessage> 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);
}
}
}
@@ -0,0 +1,130 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory;
/// <summary>
/// Tests for <see cref="FoundryMemoryProvider"/> constructor validation.
/// </summary>
/// <remarks>
/// Since <see cref="FoundryMemoryProvider"/> directly uses <see cref="Azure.AI.Projects.AIProjectClient"/>,
/// integration tests are used to verify the memory operations. These unit tests focus on:
/// - Constructor parameter validation
/// - State initializer validation
/// </remarks>
public sealed class FoundryMemoryProviderTests
{
[Fact]
public void Constructor_Throws_WhenClientIsNull()
{
// Act & Assert
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() => new FoundryMemoryProvider(
null!,
"store",
stateInitializer: _ => new(new FoundryMemoryProviderScope("test"))));
Assert.Equal("client", ex.ParamName);
}
[Fact]
public void Constructor_Throws_WhenStateInitializerIsNull()
{
// Arrange
using TestableAIProjectClient testClient = new();
// Act & Assert
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() => new FoundryMemoryProvider(
testClient.Client,
"store",
stateInitializer: null!));
Assert.Equal("stateInitializer", ex.ParamName);
}
[Fact]
public void Constructor_Throws_WhenMemoryStoreNameIsEmpty()
{
// Arrange
using TestableAIProjectClient testClient = new();
// Act & Assert
ArgumentException ex = Assert.Throws<ArgumentException>(() => new FoundryMemoryProvider(
testClient.Client,
"",
stateInitializer: _ => new(new FoundryMemoryProviderScope("test"))));
Assert.Equal("memoryStoreName", ex.ParamName);
}
[Fact]
public void Constructor_Throws_WhenMemoryStoreNameIsNull()
{
// Arrange
using TestableAIProjectClient testClient = new();
// Act & Assert
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() => new FoundryMemoryProvider(
testClient.Client,
null!,
stateInitializer: _ => new(new FoundryMemoryProviderScope("test"))));
Assert.Equal("memoryStoreName", ex.ParamName);
}
[Fact]
public void Scope_Throws_WhenScopeIsNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new FoundryMemoryProviderScope(null!));
}
[Fact]
public void Scope_Throws_WhenScopeIsEmpty()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new FoundryMemoryProviderScope(""));
}
[Fact]
public void StateInitializer_Throws_WhenScopeIsNull()
{
// Arrange
using TestableAIProjectClient testClient = new();
FoundryMemoryProvider sut = new(
testClient.Client,
"store",
stateInitializer: _ => new(null!));
// Act & Assert - state initializer validation is deferred to first use
Assert.Throws<ArgumentNullException>(() =>
{
// Force state initialization by creating a session-like scenario
// The validation happens inside the ValidateStateInitializer wrapper
try
{
// The stateInitializer wraps with validation, so calling it will throw
var field = typeof(FoundryMemoryProvider).GetField("_sessionState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var sessionState = field!.GetValue(sut);
var method = sessionState!.GetType().GetMethod("GetOrInitializeState");
method!.Invoke(sessionState, [null]);
}
catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null)
{
throw tie.InnerException;
}
});
}
[Fact]
public void Constructor_Succeeds_WithValidParameters()
{
// Arrange
using TestableAIProjectClient testClient = new();
// Act
FoundryMemoryProvider sut = new(
testClient.Client,
"my-store",
stateInitializer: _ => new(new FoundryMemoryProviderScope("user-456")));
// Assert
Assert.NotNull(sut);
}
}
@@ -0,0 +1,196 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.Core;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory;
/// <summary>
/// Creates a testable AIProjectClient with a mock HTTP handler.
/// </summary>
internal sealed class TestableAIProjectClient : IDisposable
{
private readonly HttpClient _httpClient;
public TestableAIProjectClient(
string? searchMemoriesResponse = null,
string? updateMemoriesResponse = null,
HttpStatusCode? searchStatusCode = null,
HttpStatusCode? updateStatusCode = null,
HttpStatusCode? deleteStatusCode = null,
HttpStatusCode? createStoreStatusCode = null,
HttpStatusCode? getStoreStatusCode = null)
{
this.Handler = new MockHttpMessageHandler(
searchMemoriesResponse,
updateMemoriesResponse,
searchStatusCode,
updateStatusCode,
deleteStatusCode,
createStoreStatusCode,
getStoreStatusCode);
this._httpClient = new HttpClient(this.Handler);
AIProjectClientOptions options = new()
{
Transport = new HttpClientPipelineTransport(this._httpClient)
};
// Using a valid format endpoint
this.Client = new AIProjectClient(
new Uri("https://test.services.ai.azure.com/api/projects/test-project"),
new MockTokenCredential(),
options);
}
public AIProjectClient Client { get; }
public MockHttpMessageHandler Handler { get; }
public void Dispose()
{
this._httpClient.Dispose();
this.Handler.Dispose();
}
}
/// <summary>
/// Mock HTTP message handler for testing.
/// </summary>
internal sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly string? _searchMemoriesResponse;
private readonly string? _updateMemoriesResponse;
private readonly HttpStatusCode _searchStatusCode;
private readonly HttpStatusCode _updateStatusCode;
private readonly HttpStatusCode _deleteStatusCode;
private readonly HttpStatusCode _createStoreStatusCode;
private readonly HttpStatusCode _getStoreStatusCode;
public MockHttpMessageHandler(
string? searchMemoriesResponse = null,
string? updateMemoriesResponse = null,
HttpStatusCode? searchStatusCode = null,
HttpStatusCode? updateStatusCode = null,
HttpStatusCode? deleteStatusCode = null,
HttpStatusCode? createStoreStatusCode = null,
HttpStatusCode? getStoreStatusCode = null)
{
this._searchMemoriesResponse = searchMemoriesResponse ?? """{"memories":[]}""";
this._updateMemoriesResponse = updateMemoriesResponse ?? """{"update_id":"test-update-id","status":"queued"}""";
this._searchStatusCode = searchStatusCode ?? HttpStatusCode.OK;
this._updateStatusCode = updateStatusCode ?? HttpStatusCode.OK;
this._deleteStatusCode = deleteStatusCode ?? HttpStatusCode.NoContent;
this._createStoreStatusCode = createStoreStatusCode ?? HttpStatusCode.Created;
this._getStoreStatusCode = getStoreStatusCode ?? HttpStatusCode.NotFound;
}
public string? LastRequestUri { get; private set; }
public string? LastRequestBody { get; private set; }
public HttpMethod? LastRequestMethod { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.LastRequestUri = request.RequestUri?.ToString();
this.LastRequestMethod = request.Method;
if (request.Content != null)
{
#if NET472
this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
#else
this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#endif
}
string path = request.RequestUri?.AbsolutePath ?? "";
// Route based on path and method
if (path.Contains("/memory-stores/") && path.Contains("/search") && request.Method == HttpMethod.Post)
{
return CreateResponse(this._searchStatusCode, this._searchMemoriesResponse);
}
if (path.Contains("/memory-stores/") && path.Contains("/memories") && request.Method == HttpMethod.Post)
{
return CreateResponse(this._updateStatusCode, this._updateMemoriesResponse);
}
if (path.Contains("/memory-stores/") && path.Contains("/scopes") && request.Method == HttpMethod.Delete)
{
return CreateResponse(this._deleteStatusCode, "");
}
if (path.Contains("/memory-stores") && request.Method == HttpMethod.Post)
{
return CreateResponse(this._createStoreStatusCode, """{"name":"test-store","status":"active"}""");
}
if (path.Contains("/memory-stores/") && request.Method == HttpMethod.Get)
{
return CreateResponse(this._getStoreStatusCode, """{"name":"test-store","status":"active"}""");
}
// Default response
return CreateResponse(HttpStatusCode.NotFound, "{}");
}
private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string? content)
{
return new HttpResponseMessage(statusCode)
{
Content = new StringContent(content ?? "{}", Encoding.UTF8, "application/json")
};
}
}
/// <summary>
/// Mock token credential for testing.
/// </summary>
internal sealed class MockTokenCredential : TokenCredential
{
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1));
}
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return new ValueTask<AccessToken>(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)));
}
}
/// <summary>
/// Source-generated JSON serializer context for unit test types.
/// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(TestState))]
[JsonSerializable(typeof(TestScope))]
internal sealed partial class TestJsonContext : JsonSerializerContext
{
}
/// <summary>
/// Test state class for deserialization tests.
/// </summary>
internal sealed class TestState
{
public TestScope? Scope { get; set; }
}
/// <summary>
/// Test scope class for deserialization tests.
/// </summary>
internal sealed class TestScope
{
public string? Scope { get; set; }
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<NoWarn>$(NoWarn);NU1605;NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
<!-- Tests requiring net8.0+ (MEAI.Evaluation and some SCM pipeline APIs do not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />
<Compile Remove="FoundryEvalsTests.cs" />
<Compile Remove="ClientHeadersExtensionsTests.cs" />
<Compile Remove="ServedModelTestHelpers.cs" />
<Compile Remove="ServedModelScopeTests.cs" />
<Compile Remove="ServedModelPolicyTests.cs" />
</ItemGroup>
<ItemGroup>
<None Update="TestData\AgentResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\AgentVersionResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\OpenAIDefaultResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,246 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using Azure.AI.Extensions.OpenAI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ProjectResponsesClientExtensions"/> class.
/// </summary>
public sealed class ProjectResponsesClientExtensionsTests
{
private static ProjectResponsesClient CreateTestClient()
{
return new ProjectResponsesClient(new FakeAuthenticationTokenProvider());
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_WithNullClient_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((ProjectResponsesClient)null!).AsIChatClientWithStoredOutputDisabled());
Assert.Equal("responseClient", exception.ParamName);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled wraps the original ProjectResponsesClient,
/// which remains accessible via the service chain.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_InnerResponsesClientIsAccessible()
{
// Arrange
var responseClient = CreateTestClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
// Assert - the inner ProjectResponsesClient should be accessible via GetService
var innerClient = chatClient.GetService<ResponsesClient>();
Assert.NotNull(innerClient);
Assert.Same(responseClient, innerClient);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent false
/// wraps the original ProjectResponsesClient, which remains accessible via the service chain.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_InnerResponsesClientIsAccessible()
{
// Arrange
var responseClient = CreateTestClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false);
// Assert - the inner ProjectResponsesClient should be accessible via GetService
var innerClient = chatClient.GetService<ResponsesClient>();
Assert.NotNull(innerClient);
Assert.Same(responseClient, innerClient);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled with default parameter (includeReasoningEncryptedContent = true)
/// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_Default_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent()
{
// Arrange
var responseClient = CreateTestClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
// Assert
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent explicitly set to true
/// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningTrue_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent()
{
// Arrange
var responseClient = CreateTestClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: true);
// Assert
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent set to false
/// configures StoredOutputEnabled to false and does not include ReasoningEncryptedContent in IncludedProperties.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_ConfiguresStoredOutputDisabledWithoutReasoningEncryptedContent()
{
// Arrange
var responseClient = CreateTestClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false);
// Assert
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled preserves an existing RawRepresentationFactory
/// set on ChatOptions, augmenting it with StoredOutputEnabled and ReasoningEncryptedContent
/// rather than replacing it.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_PreservesExistingRawRepresentationFactory()
{
// Arrange
var responseClient = CreateTestClient();
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
// Simulate a caller setting their own RawRepresentationFactory on ChatOptions
// (e.g., to add WebSearchCallActionSources).
var options = new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions
{
IncludedProperties = { IncludedResponseProperty.WebSearchCallActionSources },
},
};
// Act
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
// Assert
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
Assert.Contains(IncludedResponseProperty.WebSearchCallActionSources, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled does not duplicate ReasoningEncryptedContent
/// when the existing factory already includes it.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_DoesNotDuplicateReasoningEncryptedContent()
{
// Arrange
var responseClient = CreateTestClient();
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled();
// Simulate a caller that already includes ReasoningEncryptedContent
var options = new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions
{
IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent },
},
};
// Act
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient, options);
// Assert - ReasoningEncryptedContent should appear exactly once
Assert.NotNull(createResponseOptions);
int count = 0;
foreach (var prop in createResponseOptions.IncludedProperties)
{
if (prop == IncludedResponseProperty.ReasoningEncryptedContent)
{
count++;
}
}
Assert.Equal(1, count);
}
/// <summary>
/// Verify that AsIChatClientWithStoredOutputDisabled works with an optional deployment name.
/// </summary>
[Fact]
public void AsIChatClientWithStoredOutputDisabled_WithDeploymentName_ConfiguresStoredOutputDisabled()
{
// Arrange
var responseClient = CreateTestClient();
// Act
var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(deploymentName: "my-deployment");
// Assert
var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient);
Assert.NotNull(createResponseOptions);
Assert.False(createResponseOptions.StoredOutputEnabled);
Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties);
}
/// <summary>
/// Extracts the <see cref="CreateResponseOptions"/> produced by the ConfigureOptions pipeline
/// by using reflection to access the configure action and invoking it on a test <see cref="ChatOptions"/>.
/// </summary>
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient)
{
return GetCreateResponseOptionsFromPipeline(chatClient, new ChatOptions());
}
/// <summary>
/// Overload that runs the configure action on caller-supplied <see cref="ChatOptions"/>,
/// useful for testing that existing factories are preserved.
/// </summary>
private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient, ChatOptions options)
{
var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(configureField);
var configureAction = configureField.GetValue(chatClient) as Action<ChatOptions>;
Assert.NotNull(configureAction);
configureAction(options);
Assert.NotNull(options.RawRepresentationFactory);
return options.RawRepresentationFactory(chatClient) as CreateResponseOptions;
}
}
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for <see cref="ServedModelPolicy"/>: the SCM pipeline policy that reads the
/// <c>x-ms-served-model</c> response header and writes it into the active
/// <see cref="ServedModelScope"/> box.
/// </summary>
/// <remarks>
/// Tests drive the policy through a real OpenAI ResponsesClient SCM pipeline against a mock
/// HTTP handler so the policy executes in its production configuration.
/// </remarks>
public sealed class ServedModelPolicyTests
{
[Fact]
public void Instance_IsSingleton()
{
Assert.Same(ServedModelPolicy.Instance, ServedModelPolicy.Instance);
}
[Fact]
public async Task ProcessAsync_HeaderPresent_SetsModelIdOnResponseAsync()
{
// Arrange
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07");
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
// Act
var response = await chatClient.GetResponseAsync("hi");
// Assert
Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId);
}
[Fact]
public async Task ProcessAsync_HeaderAbsent_PreservesModelIdFromBodyAsync()
{
// Arrange
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: null);
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
// Act
var response = await chatClient.GetResponseAsync("hi");
// Assert: ModelId is the deployment alias from the JSON body ("fake").
Assert.Equal("fake", response.ModelId);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task ProcessAsync_EmptyOrWhitespaceHeader_PreservesModelIdFromBodyAsync(string headerValue)
{
// Arrange
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: headerValue);
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
// Act
var response = await chatClient.GetResponseAsync("hi");
// Assert: empty/whitespace header is rejected by the policy, ModelId stays as "fake".
Assert.Equal("fake", response.ModelId);
}
[Fact]
public async Task ProcessAsync_HeaderWithSurroundingWhitespace_TrimsValueAsync()
{
// Arrange
using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: " gpt-5-nano-2025-08-07 ");
IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler);
// Act
var response = await chatClient.GetResponseAsync("hi");
// Assert
Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId);
}
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for <see cref="ServedModelScope"/>: the AsyncLocal carrier that bridges the
/// served-model value from the SCM pipeline policy up to the delegating chat client.
/// </summary>
public sealed class ServedModelScopeTests
{
[Fact]
public void Current_DefaultIsNull()
{
Assert.Null(ServedModelScope.Current);
}
[Fact]
public void Current_SetAndGet_ReturnsBox()
{
// Arrange
var previous = ServedModelScope.Current;
try
{
// Act
var box = new StrongBox<string?>("gpt-5-nano-2025-08-07");
ServedModelScope.Current = box;
// Assert
Assert.Same(box, ServedModelScope.Current);
Assert.Equal("gpt-5-nano-2025-08-07", ServedModelScope.Current!.Value);
}
finally
{
ServedModelScope.Current = previous;
}
}
}
@@ -0,0 +1,80 @@
// 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.Projects;
using Microsoft.Extensions.AI;
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Shared helpers and fake clients used by the served-model test suite
/// (<see cref="ServedModelScopeTests"/>, <see cref="ServedModelPolicyTests"/>).
/// </summary>
internal static class ServedModelTestHelpers
{
public static string MinimalResponseJson() => """
{
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
""";
/// <summary>
/// Creates a <see cref="FoundryChatClient"/> backed by a real OpenAI Responses pipeline
/// routed through the supplied <paramref name="handler"/>. The <see cref="ServedModelPolicy"/>
/// is registered automatically by the <see cref="FoundryChatClient"/> constructor.
/// </summary>
public static IChatClient CreateChatClientWithPolicy(HttpMessageHandler handler)
{
#pragma warning disable CA5399
var http = 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(http) });
return new FoundryChatClient(projectClient, "fake");
}
/// <summary>
/// An <see cref="HttpClientHandler"/> that returns a fixed response body and optionally
/// includes the <c>x-ms-served-model</c> response header.
/// </summary>
public sealed class ServedModelHandler : HttpClientHandler
{
private readonly string _body;
private readonly string? _servedModel;
public ServedModelHandler(string body, string? servedModel)
{
this._body = body;
this._servedModel = servedModel;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var resp = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
RequestMessage = request,
};
if (this._servedModel is not null)
{
resp.Headers.Add("x-ms-served-model", this._servedModel);
}
return Task.FromResult(resp);
}
}
}
@@ -0,0 +1,17 @@
{
"object": "agent",
"id": "agent_abc123",
"name": "agent_abc123",
"versions": {
"latest": {
"metadata": {},
"object": "agent.version",
"id": "agent_abc123:1",
"name": "agent_abc123",
"version": "1",
"description": "",
"created_at": 1761771936,
"definition": "agent-definition-placeholder"
}
}
}
@@ -0,0 +1,9 @@
{
"object": "agent.version",
"id": "agent_abc123:1",
"name": "agent_abc123",
"version": "1",
"description": "",
"created_at": 1761771936,
"definition": "agent-definition-placeholder"
}
@@ -0,0 +1,68 @@
{
"id": "resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7",
"object": "response",
"created_at": 1762941294,
"status": "completed",
"background": false,
"billing": {
"payer": "developer"
},
"error": null,
"incomplete_details": null,
"instructions": null,
"max_output_tokens": null,
"max_tool_calls": null,
"model": "gpt-4o-mini-2024-07-18",
"output": [
{
"id": "msg_0888a46cbf2b1ff3006914596f814481958e8cf500a6dabbec",
"type": "message",
"status": "completed",
"content": [
{
"type": "output_text",
"annotations": [],
"logprobs": [],
"text": "Hello! How can I assist you today?"
}
],
"role": "assistant"
}
],
"parallel_tool_calls": true,
"previous_response_id": null,
"prompt_cache_key": null,
"prompt_cache_retention": null,
"reasoning": {
"effort": null,
"summary": null
},
"safety_identifier": null,
"service_tier": "default",
"store": true,
"temperature": 1.0,
"text": {
"format": {
"type": "text"
},
"verbosity": "medium"
},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled",
"usage": {
"input_tokens": 9,
"input_tokens_details": {
"cached_tokens": 0
},
"output_tokens": 10,
"output_tokens_details": {
"reasoning_tokens": 0
},
"total_tokens": 19
},
"user": null,
"metadata": {}
}
@@ -0,0 +1,165 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using System.IO;
using Azure.AI.Projects.Agents;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Utility class for loading and processing test data files.
/// </summary>
internal static class TestDataUtil
{
private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json");
private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json");
private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json");
private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\"";
private const string DefaultAgentDefinition = """
{
"kind": "prompt",
"model": "gpt-5-mini",
"instructions": "You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.",
"tools": []
}
""";
/// <summary>
/// Gets the agent response JSON with optional placeholder replacements applied.
/// </summary>
public static string GetAgentResponseJson(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
return json;
}
/// <summary>
/// Gets the agent version response JSON with optional placeholder replacements applied.
/// </summary>
public static string GetAgentVersionResponseJson(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentVersionResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
return json;
}
/// <summary>
/// Gets the agent version response JSON with empty version and ID fields for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentVersionResponseJsonWithEmptyVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentVersionResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
// Remove the version and id fields to simulate hosted agents without version
json = json.Replace("\"version\": \"1\",", "\"version\": \"\",")
.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\",");
return json;
}
/// <summary>
/// Gets the agent response JSON with empty version and ID fields in the latest version for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentResponseJsonWithEmptyVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
// Remove the version and id fields to simulate hosted agents without version
json = json.Replace("\"version\": \"1\",", "\"version\": \"\",")
.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\",");
return json;
}
/// <summary>
/// Gets the agent version response JSON with whitespace-only version and ID fields for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentVersionResponseJsonWithWhitespaceVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentVersionResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
// Use whitespace-only version and id fields to simulate hosted agents without version
return json
.Replace("\"version\": \"1\",", "\"version\": \" \",")
.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \" \",");
}
/// <summary>
/// Gets the agent response JSON with whitespace-only version and ID fields in the latest version for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentResponseJsonWithWhitespaceVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
// Use whitespace-only version and id fields to simulate hosted agents without version
return json
.Replace("\"version\": \"1\",", "\"version\": \" \",")
.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \" \",");
}
/// <summary>
/// Gets the OpenAI default response JSON with optional placeholder replacements applied.
/// </summary>
public static string GetOpenAIDefaultResponseJson(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_openAIDefaultResponseJson;
json = ApplyAgentName(json, agentName);
json = ApplyAgentDefinition(json, agentDefinition);
json = ApplyInstructions(json, instructions);
json = ApplyDescription(json, description);
return json;
}
private static string ApplyAgentName(string json, string? agentName)
{
if (!string.IsNullOrEmpty(agentName))
{
return json.Replace("\"agent_abc123\"", $"\"{agentName}\"");
}
return json;
}
private static string ApplyAgentDefinition(string json, ProjectsAgentDefinition? definition)
{
return (definition is not null)
? json.Replace(AgentDefinitionPlaceholder, ModelReaderWriter.Write(definition).ToString())
: json.Replace(AgentDefinitionPlaceholder, DefaultAgentDefinition);
}
private static string ApplyInstructions(string json, string? instructions)
{
if (!string.IsNullOrEmpty(instructions))
{
return json.Replace("You are a storytelling agent. You craft engaging one-line stories based on user prompts and context.", instructions);
}
return json;
}
private static string ApplyDescription(string json, string? description)
{
if (!string.IsNullOrEmpty(description))
{
return json.Replace("\"description\": \"\"", $"\"description\": \"{description}\"");
}
return json;
}
}