chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
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

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
+17
View File
@@ -0,0 +1,17 @@
# Suppressing errors for Test projects under dotnet/tests folder
[*.cs]
dotnet_diagnostic.CA1822.severity = none # Member does not access instance data and can be marked as static
dotnet_diagnostic.CA1873.severity = none # Evaluation of logging arguments may be expensive
dotnet_diagnostic.CA1875.severity = none # Regex.IsMatch/Count instead of Regex.Match(...).Success/Regex.Matches(...).Count
dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task
dotnet_diagnostic.CA2249.severity = none # Use `string.Contains` instead of `string.IndexOf` to improve readability
dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member
dotnet_diagnostic.IDE1006.severity = warning # Naming rule violations
dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave
dotnet_diagnostic.MEAI001.severity = none # [Experimental] APIs in Microsoft.Extensions.AI
dotnet_diagnostic.OPENAI001.severity = none # [Experimental] APIs in OpenAI
dotnet_diagnostic.SKEXP0110.severity = none # [Experimental] APIs in Microsoft.SemanticKernel
+1
View File
@@ -0,0 +1 @@
launchSettings.json
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsTestProject>false</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
</ItemGroup>
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
namespace AgentConformance.IntegrationTests;
/// <summary>
/// Base class for all test classes used for testing agents.
/// </summary>
/// <typeparam name="TAgentFixture">The type of the agent fixture used in these tests.</typeparam>
/// <param name="createAgentFixture">Used to create a new fixture for this test suite.</param>
public abstract class AgentTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : IAsyncLifetime
where TAgentFixture : IAgentFixture
{
protected TAgentFixture Fixture { get; private set; } = default!;
public async ValueTask InitializeAsync()
{
this.Fixture = createAgentFixture();
await this.Fixture.InitializeAsync();
}
public async ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
await this.Fixture.DisposeAsync();
}
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConformance.IntegrationTests;
/// <summary>
/// Conformance tests that are specific to the <see cref="ChatClientAgent"/> in addition to those in <see cref="RunStreamingTests{TAgentFixture}"/>.
/// </summary>
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
public abstract class ChatClientAgentRunStreamingTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
where TAgentFixture : IChatClientAgentFixture
{
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
// Arrange
var agent = await this.Fixture.CreateChatClientAgentAsync(instructions: "Always respond with 'Computer says no', even if there was no user input.");
var session = await agent.CreateSessionAsync();
await using var agentCleanup = new AgentCleanup(agent, this.Fixture);
await using var sessionCleanup = new SessionCleanup(session, this.Fixture);
// Act
var responseUpdates = await agent.RunStreamingAsync(session).ToListAsync();
// Assert
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
Assert.Contains("Computer says no", chatResponseText, StringComparison.OrdinalIgnoreCase);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync()
{
// Arrange
var questionsAndAnswers = new[]
{
(Question: "Hello", ExpectedAnswer: string.Empty),
(Question: "What is the special soup?", ExpectedAnswer: "Clam Chowder"),
(Question: "What is the special drink?", ExpectedAnswer: "Chai Tea"),
(Question: "What is the special salad?", ExpectedAnswer: "Cobb Salad"),
(Question: "Thank you", ExpectedAnswer: string.Empty)
};
var agent = await this.Fixture.CreateChatClientAgentAsync(
aiTools:
[
AIFunctionFactory.Create(MenuPlugin.GetSpecials),
AIFunctionFactory.Create(MenuPlugin.GetItemPrice)
]);
var session = await agent.CreateSessionAsync();
foreach (var questionAndAnswer in questionsAndAnswers)
{
// Act
var responseUpdates = await agent.RunStreamingAsync(
new ChatMessage(ChatRole.User, questionAndAnswer.Question),
session).ToListAsync();
// Assert
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
Assert.Contains(questionAndAnswer.ExpectedAnswer, chatResponseText, StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConformance.IntegrationTests;
/// <summary>
/// Conformance tests that are specific to the <see cref="ChatClientAgent"/> in addition to those in <see cref="RunTests{TAgentFixture}"/>.
/// </summary>
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
public abstract class ChatClientAgentRunTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
where TAgentFixture : IChatClientAgentFixture
{
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
// Arrange
var agent = await this.Fixture.CreateChatClientAgentAsync(instructions: "ALWAYS RESPOND WITH 'Computer says no', even if there was no user input.");
var session = await agent.CreateSessionAsync();
await using var agentCleanup = new AgentCleanup(agent, this.Fixture);
await using var sessionCleanup = new SessionCleanup(session, this.Fixture);
// Act
var response = await agent.RunAsync(session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.False(string.IsNullOrWhiteSpace(response.Text), "Agent should return non-empty response even without user input");
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithFunctionsInvokesFunctionsAndReturnsExpectedResultsAsync()
{
// Arrange
var questionsAndAnswers = new[]
{
(Question: "Hello", ExpectedAnswer: string.Empty),
(Question: "What is the special soup?", ExpectedAnswer: "Clam Chowder"),
(Question: "What is the special drink?", ExpectedAnswer: "Chai Tea"),
(Question: "What is the special salad?", ExpectedAnswer: "Cobb Salad"),
(Question: "Thank you", ExpectedAnswer: string.Empty)
};
var agent = await this.Fixture.CreateChatClientAgentAsync(
aiTools:
[
AIFunctionFactory.Create(MenuPlugin.GetSpecials),
AIFunctionFactory.Create(MenuPlugin.GetItemPrice)
]);
var session = await agent.CreateSessionAsync();
foreach (var questionAndAnswer in questionsAndAnswers)
{
// Act
var result = await agent.RunAsync(
new ChatMessage(ChatRole.User, questionAndAnswer.Question),
session);
// Assert
Assert.NotNull(result);
Assert.Contains(questionAndAnswer.ExpectedAnswer, result.Text);
}
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConformance.IntegrationTests;
/// <summary>
/// Interface for setting up and tearing down agents, to be used in tests.
/// Each agent type should have its own derived class.
/// </summary>
public interface IAgentFixture : IAsyncLifetime
{
AIAgent Agent { get; }
Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session);
Task DeleteSessionAsync(AgentSession session);
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConformance.IntegrationTests;
/// <summary>
/// Interface for setting up and tearing down <see cref="IChatClient"/> based agents, to be used in tests.
/// Each agent type should have its own derived class.
/// </summary>
public interface IChatClientAgentFixture : IAgentFixture
{
IChatClient ChatClient { get; }
Task<ChatClientAgent> CreateChatClientAgentAsync(
string name = "HelpfulAssistant",
string instructions = "You are a helpful assistant.",
IList<AITool>? aiTools = null);
Task DeleteAgentAsync(ChatClientAgent agent);
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
namespace AgentConformance.IntegrationTests;
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
/// <summary>
/// A test plugin used to verify function invocation.
/// </summary>
internal static class MenuPlugin
{
[Description("Provides a list of specials from the menu.")]
public static string GetSpecials() => """
Special Soup: Clam Chowder
Special Salad: Cobb Salad
Special Drink: Chai Tea
""";
[Description("Provides the price of the requested menu item.")]
public static string GetItemPrice(
[Description("The name of the menu item.")]
string menuItem) => "$9.99";
}
@@ -0,0 +1,118 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConformance.IntegrationTests;
/// <summary>
/// Conformance tests for run methods on agents.
/// </summary>
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
public abstract class RunStreamingTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
where TAgentFixture : IAgentFixture
{
public virtual Func<Task<AgentRunOptions?>> AgentRunOptionsFactory { get; set; } = () => Task.FromResult(default(AgentRunOptions));
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithNoMessageDoesNotFailAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var chatResponses = await agent.RunStreamingAsync(session, await this.AgentRunOptionsFactory.Invoke()).ToListAsync();
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithStringReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var responseUpdates = await agent.RunStreamingAsync("What is the capital of France.", session, await this.AgentRunOptionsFactory.Invoke()).ToListAsync();
// Assert
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
Assert.Contains("Paris", chatResponseText);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithChatMessageReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var responseUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "What is the capital of France."), session, await this.AgentRunOptionsFactory.Invoke()).ToListAsync();
// Assert
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
Assert.Contains("Paris", chatResponseText);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithChatMessagesReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var responseUpdates = await agent.RunStreamingAsync(
[
new ChatMessage(ChatRole.User, "Hello."),
new ChatMessage(ChatRole.User, "What is the capital of France.")
],
session,
await this.AgentRunOptionsFactory.Invoke()).ToListAsync();
// Assert
var chatResponseText = string.Concat(responseUpdates.Select(x => x.Text));
Assert.Contains("Paris", chatResponseText);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task SessionMaintainsHistoryAsync()
{
// Arrange
const string Q1 = "What is the capital of France.";
const string Q2 = "And Austria?";
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var options = await this.AgentRunOptionsFactory.Invoke();
var responseUpdates1 = await agent.RunStreamingAsync(Q1, session, options).ToListAsync();
var responseUpdates2 = await agent.RunStreamingAsync(Q2, session, options).ToListAsync();
// Assert
var response1Text = string.Concat(responseUpdates1.Select(x => x.Text));
var response2Text = string.Concat(responseUpdates2.Select(x => x.Text));
Assert.Contains("Paris", response1Text);
Assert.Contains("Vienna", response2Text);
var chatHistory = await this.Fixture.GetChatHistoryAsync(agent, session);
Assert.Equal(4, chatHistory.Count);
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.User));
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.Assistant));
Assert.Equal(Q1, chatHistory[0].Text);
Assert.Equal(Q2, chatHistory[2].Text);
Assert.Contains("Paris", chatHistory[1].Text);
Assert.Contains("Vienna", chatHistory[3].Text);
}
}
@@ -0,0 +1,123 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConformance.IntegrationTests;
/// <summary>
/// Conformance tests for run methods on agents.
/// </summary>
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
public abstract class RunTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
where TAgentFixture : IAgentFixture
{
public virtual Func<Task<AgentRunOptions?>> AgentRunOptionsFactory { get; set; } = () => Task.FromResult(default(AgentRunOptions));
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithNoMessageDoesNotFailAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var chatResponse = await agent.RunAsync(session);
// Assert
Assert.NotNull(chatResponse);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithStringReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var response = await agent.RunAsync("What is the capital of France.", session, await this.AgentRunOptionsFactory.Invoke());
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.Equal(agent.Id, response.AgentId);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithChatMessageReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "What is the capital of France."), session, await this.AgentRunOptionsFactory.Invoke());
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithChatMessagesReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var response = await agent.RunAsync(
[
new ChatMessage(ChatRole.User, "Hello."),
new ChatMessage(ChatRole.User, "What is the capital of France.")
],
session,
await this.AgentRunOptionsFactory.Invoke());
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task SessionMaintainsHistoryAsync()
{
// Arrange
const string Q1 = "What is the capital of France.";
const string Q2 = "And Austria?";
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var options = await this.AgentRunOptionsFactory.Invoke();
var result1 = await agent.RunAsync(Q1, session, options);
var result2 = await agent.RunAsync(Q2, session, options);
// Assert
Assert.Contains("Paris", result1.Text);
Assert.Contains("Vienna", result2.Text);
var chatHistory = await this.Fixture.GetChatHistoryAsync(agent, session);
Assert.Equal(4, chatHistory.Count);
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.User));
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.Assistant));
Assert.Equal(Q1, chatHistory[0].Text);
Assert.Contains("Paris", chatHistory[1].Text);
Assert.Equal(Q2, chatHistory[2].Text);
Assert.Contains("Vienna", chatHistory[3].Text);
}
}
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AgentConformance.IntegrationTests;
/// <summary>
/// Conformance tests for structured output handling for run methods on agents.
/// </summary>
/// <typeparam name="TAgentFixture">The type of test fixture used by the concrete test implementation.</typeparam>
/// <param name="createAgentFixture">Function to create the test fixture with.</param>
public abstract class StructuredOutputRunTests<TAgentFixture>(Func<TAgentFixture> createAgentFixture) : AgentTests<TAgentFixture>(createAgentFixture)
where TAgentFixture : IAgentFixture
{
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithResponseFormatReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
var options = new AgentRunOptions
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<CityInfo>(AgentAbstractionsJsonUtilities.DefaultOptions)
};
// Act
var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session, options);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo));
Assert.Equal("Paris", cityInfo.Name);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithGenericTypeReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>(
new ChatMessage(ChatRole.User, "Provide information about the capital of France."),
session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.NotNull(response.Result);
Assert.Equal("Paris", response.Result.Name);
}
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public virtual async Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act - Request a primitive type, which requires wrapping in an object schema
AgentResponse<int> response = await agent.RunAsync<int>(
new ChatMessage(ChatRole.User, "What is the sum of 15 and 27? Respond with just the number."),
session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Equal(42, response.Result);
}
protected static bool TryDeserialize<T>(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput)
{
try
{
T? deserialized = JsonSerializer.Deserialize<T>(json, jsonSerializerOptions);
if (deserialized is null)
{
structuredOutput = default!;
return false;
}
structuredOutput = deserialized;
return true;
}
catch
{
structuredOutput = default!;
return false;
}
}
}
public sealed class CityInfo
{
public string? Name { get; set; }
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
namespace AgentConformance.IntegrationTests.Support;
/// <summary>
/// Helper class to delete agents after tests.
/// </summary>
/// <param name="agent">The agent to delete.</param>
/// <param name="fixture">The fixture that provides agent specific capabilities.</param>
internal sealed class AgentCleanup(ChatClientAgent agent, IChatClientAgentFixture fixture) : IAsyncDisposable
{
public async ValueTask DisposeAsync() =>
await fixture.DeleteAgentAsync(agent);
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AgentConformance.IntegrationTests.Support;
public static class Constants
{
public const int RetryCount = 3;
public const int RetryDelay = 5000;
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
namespace AgentConformance.IntegrationTests.Support;
/// <summary>
/// Helper class to delete sessions after tests.
/// </summary>
/// <param name="session">The session to delete.</param>
/// <param name="fixture">The fixture that provides agent specific capabilities.</param>
public sealed class SessionCleanup(AgentSession session, IAgentFixture fixture) : IAsyncDisposable
{
public async ValueTask DisposeAsync() =>
await fixture.DeleteSessionAsync(session);
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.Configuration;
namespace AgentConformance.IntegrationTests.Support;
/// <summary>
/// Helper for loading test configuration settings.
/// </summary>
public sealed class TestConfiguration
{
private static readonly IConfiguration s_configuration = new ConfigurationBuilder()
.AddJsonFile(path: "testsettings.development.json", optional: true)
.AddEnvironmentVariables()
.AddUserSecrets<TestConfiguration>()
.Build();
/// <summary>
/// Gets a configuration value by its flat key name.
/// </summary>
/// <param name="key">The configuration key.</param>
/// <returns>The configuration value, or <see langword="null"/> if not found.</returns>
public static string? GetValue(string key) => s_configuration[key];
/// <summary>
/// Gets a required configuration value by its flat key name.
/// </summary>
/// <param name="key">The configuration key.</param>
/// <returns>The configuration value.</returns>
/// <exception cref="InvalidOperationException">Thrown if the configuration value is not found.</exception>
public static string GetRequiredValue(string key) =>
s_configuration[key] ?? throw new InvalidOperationException($"Configuration key '{key}' is required but was not found.");
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Anthropic\Microsoft.Agents.AI.Anthropic.csproj" />
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
public class AnthropicBetaChatCompletionChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
public class AnthropicChatCompletionChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
public class AnthropicChatCompletionChatClientAgentReasoningRunStreamingTests() : ChatClientAgentRunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
public class AnthropicBetaChatCompletionChatClientAgentRunTests()
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
public class AnthropicBetaChatCompletionChatClientAgentReasoningRunTests()
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
public class AnthropicChatCompletionChatClientAgentRunTests()
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
public class AnthropicChatCompletionChatClientAgentReasoningRunTests()
: ChatClientAgentRunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
@@ -0,0 +1,124 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Anthropic;
using Anthropic.Models.Beta.Messages;
using Anthropic.Models.Messages;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
public class AnthropicChatCompletionFixture : IChatClientAgentFixture
{
private readonly bool _useReasoningModel;
private readonly bool _useBeta;
private ChatClientAgent _agent = null!;
public AnthropicChatCompletionFixture(bool useReasoningChatModel, bool useBeta)
{
this._useReasoningModel = useReasoningChatModel;
this._useBeta = useBeta;
}
public AIAgent Agent => this._agent;
public IChatClient ChatClient => this._agent.ChatClient;
public async Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session)
{
var chatHistoryProvider = agent.GetService<ChatHistoryProvider>();
if (chatHistoryProvider is null)
{
return [];
}
return (await chatHistoryProvider.InvokingAsync(new(agent, session, []))).ToList();
}
public Task<ChatClientAgent> CreateChatClientAgentAsync(
string name = "HelpfulAssistant",
string instructions = "You are a helpful assistant.",
IList<AITool>? aiTools = null)
{
var anthropicClient = new AnthropicClient() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
var chatModelName = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
var reasoningModelName = TestConfiguration.GetRequiredValue(TestSettings.AnthropicReasoningModelName);
IChatClient? chatClient = this._useBeta
? anthropicClient
.Beta
.AsIChatClient()
.AsBuilder()
.ConfigureOptions(options
=> options.RawRepresentationFactory = _
=> new Anthropic.Models.Beta.Messages.MessageCreateParams()
{
Model = options.ModelId ?? (this._useReasoningModel ? reasoningModelName : chatModelName),
MaxTokens = options.MaxOutputTokens ?? 4096,
Messages = [],
Thinking = this._useReasoningModel
? new BetaThinkingConfigParam(new BetaThinkingConfigEnabled(2048))
: new BetaThinkingConfigParam(new BetaThinkingConfigDisabled())
}).Build()
: anthropicClient
.AsIChatClient()
.AsBuilder()
.ConfigureOptions(options
=> options.RawRepresentationFactory = _
=> new Anthropic.Models.Messages.MessageCreateParams()
{
Model = options.ModelId ?? (this._useReasoningModel ? reasoningModelName : chatModelName),
MaxTokens = options.MaxOutputTokens ?? 4096,
Messages = [],
Thinking = this._useReasoningModel
? new ThinkingConfigParam(new ThinkingConfigEnabled(2048))
: new ThinkingConfigParam(new ThinkingConfigDisabled())
}).Build();
return Task.FromResult(new ChatClientAgent(chatClient, options: new()
{
Name = name,
ChatOptions = new() { Instructions = instructions, Tools = aiTools }
}));
}
public Task DeleteAgentAsync(ChatClientAgent agent) =>
// Chat Completion does not require/support deleting agents, so this is a no-op.
Task.CompletedTask;
public Task DeleteSessionAsync(AgentSession session) =>
// Chat Completion does not require/support deleting sessions, so this is a no-op.
Task.CompletedTask;
public async ValueTask InitializeAsync()
{
try
{
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey);
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicReasoningModelName);
}
catch (InvalidOperationException ex)
{
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
}
this._agent = await this.CreateChatClientAgentAsync();
}
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
return default;
}
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
public class AnthropicBetaChatCompletionRunStreamingTests()
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
public class AnthropicBetaChatCompletionReasoningRunStreamingTests()
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
public class AnthropicChatCompletionRunStreamingTests()
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
public class AnthropicChatCompletionReasoningRunStreamingTests()
: RunStreamingTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
public class AnthropicBetaChatCompletionRunTests()
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: true));
public class AnthropicBetaChatCompletionReasoningRunTests()
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: true));
public class AnthropicChatCompletionRunTests()
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: false, useBeta: false));
public class AnthropicChatCompletionReasoningRunTests()
: RunTests<AnthropicChatCompletionFixture>(() => new(useReasoningChatModel: true, useBeta: false));
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Anthropic;
using Anthropic.Models.Beta;
using Anthropic.Models.Beta.Messages;
using Anthropic.Models.Beta.Skills;
using Anthropic.Services;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace AnthropicChatCompletion.IntegrationTests;
/// <summary>
/// Integration tests for Anthropic Skills functionality.
/// These tests are designed to be run locally with a valid Anthropic API key.
/// </summary>
[Trait("Category", "Integration")]
public sealed class AnthropicSkillsIntegrationTests
{
[Fact]
public async Task CreateAgentWithPptxSkillAsync()
{
AnthropicClient? anthropicClient;
string? model;
try
{
anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
model = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
}
catch (InvalidOperationException ex)
{
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
return;
}
BetaSkillParams pptxSkill = new()
{
Type = BetaSkillParamsType.Anthropic,
SkillID = "pptx",
Version = "latest"
};
ChatClientAgent agent = anthropicClient.Beta.AsAIAgent(
model: model,
instructions: "You are a helpful agent for creating PowerPoint presentations.",
tools: [pptxSkill.AsAITool()]);
// Act
AgentResponse response = await agent.RunAsync(
"Create a simple 2-slide presentation: a title slide and one content slide about AI.");
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Text);
Assert.NotEmpty(response.Text);
}
[Fact]
public async Task ListAnthropicManagedSkillsAsync()
{
AnthropicClient? anthropicClient;
try
{
anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
}
catch (InvalidOperationException ex)
{
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
return;
}
// Act
SkillListPage skills = await anthropicClient.Beta.Skills.List(
new SkillListParams { Source = "anthropic", Betas = [AnthropicBeta.Skills2025_10_02] });
// Assert
Assert.NotNull(skills);
Assert.NotNull(skills.Items);
Assert.Contains(skills.Items, skill => skill.ID == "pptx");
}
}
@@ -0,0 +1,184 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AgentEntityInfo"/> record.
/// </summary>
public class AgentEntityInfoTests
{
#region Constructor Tests
/// <summary>
/// Verifies that the Id property is set from the constructor parameter.
/// </summary>
[Fact]
public void Constructor_WithId_SetsIdProperty()
{
// Arrange & Act
var info = new AgentEntityInfo("test-agent");
// Assert
Assert.Equal("test-agent", info.Id);
}
/// <summary>
/// Verifies that the Description property is set when provided.
/// </summary>
[Fact]
public void Constructor_WithDescription_SetsDescriptionProperty()
{
// Arrange & Act
var info = new AgentEntityInfo("test-agent", "A test agent");
// Assert
Assert.Equal("A test agent", info.Description);
}
/// <summary>
/// Verifies that the Description property is null when not provided.
/// </summary>
[Fact]
public void Constructor_WithoutDescription_DescriptionIsNull()
{
// Arrange & Act
var info = new AgentEntityInfo("test-agent");
// Assert
Assert.Null(info.Description);
}
#endregion
#region Default Value Tests
/// <summary>
/// Verifies that Name defaults to the Id value when not explicitly set.
/// </summary>
[Fact]
public void Name_NotSet_DefaultsToId()
{
// Arrange & Act
var info = new AgentEntityInfo("test-agent");
// Assert
Assert.Equal("test-agent", info.Name);
}
/// <summary>
/// Verifies that Name can be overridden with a custom value.
/// </summary>
[Fact]
public void Name_Set_ReturnsCustomValue()
{
// Arrange & Act
var info = new AgentEntityInfo("test-agent") { Name = "Custom Name" };
// Assert
Assert.Equal("Custom Name", info.Name);
}
/// <summary>
/// Verifies that Type defaults to "agent".
/// </summary>
[Fact]
public void Type_NotSet_DefaultsToAgent()
{
// Arrange & Act
var info = new AgentEntityInfo("test-agent");
// Assert
Assert.Equal("agent", info.Type);
}
/// <summary>
/// Verifies that Type can be overridden with a custom value.
/// </summary>
[Fact]
public void Type_Set_ReturnsCustomValue()
{
// Arrange & Act
var info = new AgentEntityInfo("test-agent") { Type = "workflow" };
// Assert
Assert.Equal("workflow", info.Type);
}
/// <summary>
/// Verifies that Framework defaults to "agent_framework".
/// </summary>
[Fact]
public void Framework_NotSet_DefaultsToAgentFramework()
{
// Arrange & Act
var info = new AgentEntityInfo("test-agent");
// Assert
Assert.Equal("agent_framework", info.Framework);
}
/// <summary>
/// Verifies that Framework can be overridden with a custom value.
/// </summary>
[Fact]
public void Framework_Set_ReturnsCustomValue()
{
// Arrange & Act
var info = new AgentEntityInfo("test-agent") { Framework = "custom_framework" };
// Assert
Assert.Equal("custom_framework", info.Framework);
}
#endregion
#region Record Equality Tests
/// <summary>
/// Verifies that two AgentEntityInfo records with identical values are equal.
/// </summary>
[Fact]
public void Equality_SameValues_AreEqual()
{
// Arrange
var info1 = new AgentEntityInfo("agent", "description");
var info2 = new AgentEntityInfo("agent", "description");
// Assert
Assert.Equal(info1, info2);
}
/// <summary>
/// Verifies that two AgentEntityInfo records with different Ids are not equal.
/// </summary>
[Fact]
public void Equality_DifferentIds_AreNotEqual()
{
// Arrange
var info1 = new AgentEntityInfo("agent1");
var info2 = new AgentEntityInfo("agent2");
// Assert
Assert.NotEqual(info1, info2);
}
/// <summary>
/// Verifies that with-expression creates a modified copy.
/// </summary>
[Fact]
public void WithExpression_ModifiesProperty_CreatesNewInstance()
{
// Arrange
var original = new AgentEntityInfo("agent", "Original description");
// Act
var modified = original with { Description = "Modified description" };
// Assert
Assert.Equal("Original description", original.Description);
Assert.Equal("Modified description", modified.Description);
Assert.Equal(original.Id, modified.Id);
}
#endregion
}
@@ -0,0 +1,567 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using Aspire.Hosting.ApplicationModel;
using Moq;
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AgentFrameworkBuilderExtensions"/> class.
/// </summary>
public class AgentFrameworkBuilderExtensionsTests
{
#region AddDevUI Validation Tests
/// <summary>
/// Verifies that AddDevUI throws ArgumentNullException when builder is null.
/// </summary>
[Fact]
public void AddDevUI_NullBuilder_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(
() => AgentFrameworkBuilderExtensions.AddDevUI(null!, "devui"));
Assert.Equal("builder", exception.ParamName);
}
/// <summary>
/// Verifies that AddDevUI throws ArgumentNullException when name is null.
/// </summary>
[Fact]
public void AddDevUI_NullName_ThrowsArgumentNullException()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(
() => builder.AddDevUI(null!));
Assert.Equal("name", exception.ParamName);
}
/// <summary>
/// Verifies that AddDevUI creates a resource with the specified name.
/// </summary>
[Fact]
public void AddDevUI_ValidName_CreatesResourceWithName()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Act
var resourceBuilder = builder.AddDevUI("my-devui");
// Assert
Assert.Equal("my-devui", resourceBuilder.Resource.Name);
}
/// <summary>
/// Verifies that AddDevUI creates a DevUIResource.
/// </summary>
[Fact]
public void AddDevUI_ReturnsDevUIResourceBuilder()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Act
var resourceBuilder = builder.AddDevUI("devui");
// Assert
Assert.IsType<DevUIResource>(resourceBuilder.Resource);
}
/// <summary>
/// Verifies that AddDevUI with port configures the endpoint.
/// </summary>
[Fact]
public void AddDevUI_WithPort_ConfiguresEndpointWithPort()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Act
var resourceBuilder = builder.AddDevUI("devui", port: 8090);
// Assert
var endpoint = resourceBuilder.Resource.Annotations
.OfType<EndpointAnnotation>()
.FirstOrDefault(e => e.Name == "http");
Assert.NotNull(endpoint);
Assert.Equal(8090, endpoint.Port);
}
/// <summary>
/// Verifies that AddDevUI without port leaves port as null for dynamic allocation.
/// </summary>
[Fact]
public void AddDevUI_WithoutPort_EndpointHasDynamicPort()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
// Act
var resourceBuilder = builder.AddDevUI("devui");
// Assert
var endpoint = resourceBuilder.Resource.Annotations
.OfType<EndpointAnnotation>()
.FirstOrDefault(e => e.Name == "http");
Assert.NotNull(endpoint);
Assert.Null(endpoint.Port);
}
#endregion
#region WithAgentService Validation Tests
/// <summary>
/// Verifies that WithAgentService throws ArgumentNullException when builder is null.
/// </summary>
[Fact]
public void WithAgentService_NullBuilder_ThrowsArgumentNullException()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var mockAgentService = CreateMockAgentServiceBuilder(appBuilder, "agent-service");
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(
() => AgentFrameworkBuilderExtensions.WithAgentService(null!, mockAgentService));
Assert.Equal("builder", exception.ParamName);
}
/// <summary>
/// Verifies that WithAgentService throws ArgumentNullException when agentService is null.
/// </summary>
[Fact]
public void WithAgentService_NullAgentService_ThrowsArgumentNullException()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(
() => devuiBuilder.WithAgentService<IResourceWithEndpoints>(null!));
Assert.Equal("agentService", exception.ParamName);
}
#endregion
#region WithAgentService Annotation Tests
/// <summary>
/// Verifies that WithAgentService adds an AgentServiceAnnotation to the resource.
/// </summary>
[Fact]
public void WithAgentService_ValidService_AddsAnnotation()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
// Act
devuiBuilder.WithAgentService(agentService);
// Assert
var annotation = devuiBuilder.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.FirstOrDefault();
Assert.NotNull(annotation);
Assert.Same(agentService.Resource, annotation.AgentService);
}
/// <summary>
/// Verifies that WithAgentService defaults to agent name being the resource name.
/// </summary>
[Fact]
public void WithAgentService_NoAgents_DefaultsToResourceNameAsAgent()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
// Act
devuiBuilder.WithAgentService(agentService);
// Assert
var annotation = devuiBuilder.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.First();
Assert.Single(annotation.Agents);
Assert.Equal("writer-agent", annotation.Agents[0].Id);
}
/// <summary>
/// Verifies that WithAgentService with explicit agents uses those agents.
/// </summary>
[Fact]
public void WithAgentService_WithAgents_UsesProvidedAgents()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "multi-agent-service");
var agents = new[]
{
new AgentEntityInfo("agent1", "First agent"),
new AgentEntityInfo("agent2", "Second agent")
};
// Act
devuiBuilder.WithAgentService(agentService, agents: agents);
// Assert
var annotation = devuiBuilder.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.First();
Assert.Equal(2, annotation.Agents.Count);
Assert.Equal("agent1", annotation.Agents[0].Id);
Assert.Equal("agent2", annotation.Agents[1].Id);
}
/// <summary>
/// Verifies that WithAgentService with custom prefix uses that prefix.
/// </summary>
[Fact]
public void WithAgentService_WithEntityIdPrefix_UsesProvidedPrefix()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
// Act
devuiBuilder.WithAgentService(agentService, entityIdPrefix: "custom-prefix");
// Assert
var annotation = devuiBuilder.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.First();
Assert.Equal("custom-prefix", annotation.EntityIdPrefix);
}
/// <summary>
/// Verifies that WithAgentService without prefix leaves EntityIdPrefix null.
/// </summary>
[Fact]
public void WithAgentService_NoEntityIdPrefix_PrefixIsNull()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
// Act
devuiBuilder.WithAgentService(agentService);
// Assert
var annotation = devuiBuilder.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.First();
Assert.Null(annotation.EntityIdPrefix);
}
#endregion
#region Chaining Tests
/// <summary>
/// Verifies that WithAgentService returns the builder for chaining.
/// </summary>
[Fact]
public void WithAgentService_ReturnsSameBuilder_ForChaining()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
// Act
var result = devuiBuilder.WithAgentService(agentService);
// Assert
Assert.Same(devuiBuilder, result);
}
/// <summary>
/// Verifies that multiple WithAgentService calls can be chained.
/// </summary>
[Fact]
public void WithAgentService_MultipleCalls_AddsMultipleAnnotations()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var writerService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
var editorService = CreateMockAgentServiceBuilder(appBuilder, "editor-agent");
// Act
devuiBuilder
.WithAgentService(writerService)
.WithAgentService(editorService);
// Assert
var annotations = devuiBuilder.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.ToList();
Assert.Equal(2, annotations.Count);
Assert.Contains(annotations, a => a.AgentService.Name == "writer-agent");
Assert.Contains(annotations, a => a.AgentService.Name == "editor-agent");
}
/// <summary>
/// Verifies that AddDevUI returns a builder that can be chained with WithAgentService.
/// </summary>
[Fact]
public void AddDevUI_CanChainWithAgentService()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
// Act - Chain AddDevUI with WithAgentService
var result = appBuilder.AddDevUI("devui").WithAgentService(agentService);
// Assert
Assert.NotNull(result);
var annotation = result.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.FirstOrDefault();
Assert.NotNull(annotation);
}
#endregion
#region Relationship Tests
/// <summary>
/// Verifies that WithAgentService creates a relationship annotation.
/// </summary>
[Fact]
public void WithAgentService_CreatesRelationshipAnnotation()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
// Act
devuiBuilder.WithAgentService(agentService);
// Assert
var relationship = devuiBuilder.Resource.Annotations
.OfType<ResourceRelationshipAnnotation>()
.FirstOrDefault();
Assert.NotNull(relationship);
Assert.Equal("agent-backend", relationship.Type);
}
/// <summary>
/// Verifies that multiple WithAgentService calls create multiple relationship annotations.
/// </summary>
[Fact]
public void WithAgentService_MultipleCalls_CreatesMultipleRelationships()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var writerService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
var editorService = CreateMockAgentServiceBuilder(appBuilder, "editor-agent");
// Act
devuiBuilder
.WithAgentService(writerService)
.WithAgentService(editorService);
// Assert
var relationships = devuiBuilder.Resource.Annotations
.OfType<ResourceRelationshipAnnotation>()
.ToList();
Assert.Equal(2, relationships.Count);
Assert.All(relationships, r => Assert.Equal("agent-backend", r.Type));
}
#endregion
#region Agent Metadata Tests
/// <summary>
/// Verifies that agent description is preserved when specified.
/// </summary>
[Fact]
public void WithAgentService_AgentWithDescription_PreservesDescription()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
var agents = new[] { new AgentEntityInfo("writer", "Writes creative stories") };
// Act
devuiBuilder.WithAgentService(agentService, agents: agents);
// Assert
var annotation = devuiBuilder.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.First();
Assert.Equal("Writes creative stories", annotation.Agents[0].Description);
}
/// <summary>
/// Verifies that custom agent properties are preserved.
/// </summary>
[Fact]
public void WithAgentService_CustomAgentProperties_ArePreserved()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "custom-service");
var agents = new[]
{
new AgentEntityInfo("custom-agent")
{
Name = "Custom Display Name",
Type = "workflow",
Framework = "custom_framework"
}
};
// Act
devuiBuilder.WithAgentService(agentService, agents: agents);
// Assert
var annotation = devuiBuilder.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.First();
var agent = annotation.Agents[0];
Assert.Equal("custom-agent", agent.Id);
Assert.Equal("Custom Display Name", agent.Name);
Assert.Equal("workflow", agent.Type);
Assert.Equal("custom_framework", agent.Framework);
}
/// <summary>
/// Verifies that empty agents array can be explicitly provided and is respected.
/// </summary>
[Fact]
public void WithAgentService_EmptyAgentsArray_UsesEmptyArray()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devuiBuilder = appBuilder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
var emptyAgents = Array.Empty<AgentEntityInfo>();
// Act
devuiBuilder.WithAgentService(agentService, agents: emptyAgents);
// Assert
var annotation = devuiBuilder.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.First();
// When explicitly passing an empty array, the extension method respects it
// This is the expected behavior - explicit empty means "discover at runtime"
Assert.Empty(annotation.Agents);
}
#endregion
#region Edge Case Tests
/// <summary>
/// Verifies that AddDevUI can be called multiple times with different names.
/// </summary>
[Fact]
public void AddDevUI_MultipleCalls_CreatesSeparateResources()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
// Act
var devui1 = appBuilder.AddDevUI("devui1");
var devui2 = appBuilder.AddDevUI("devui2");
// Assert
Assert.NotSame(devui1.Resource, devui2.Resource);
Assert.Equal("devui1", devui1.Resource.Name);
Assert.Equal("devui2", devui2.Resource.Name);
}
/// <summary>
/// Verifies that same agent service can be added to multiple DevUI resources.
/// </summary>
[Fact]
public void WithAgentService_SameServiceToMultipleDevUI_Works()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devui1 = appBuilder.AddDevUI("devui1");
var devui2 = appBuilder.AddDevUI("devui2");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "shared-agent");
// Act
devui1.WithAgentService(agentService);
devui2.WithAgentService(agentService);
// Assert
var annotation1 = devui1.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
var annotation2 = devui2.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
Assert.Same(annotation1.AgentService, annotation2.AgentService);
}
/// <summary>
/// Verifies that WithAgentService works with different entity ID prefixes for the same service.
/// </summary>
[Fact]
public void WithAgentService_DifferentPrefixesToDifferentDevUI_Works()
{
// Arrange
var appBuilder = DistributedApplication.CreateBuilder();
var devui1 = appBuilder.AddDevUI("devui1");
var devui2 = appBuilder.AddDevUI("devui2");
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
// Act
devui1.WithAgentService(agentService, entityIdPrefix: "prefix1");
devui2.WithAgentService(agentService, entityIdPrefix: "prefix2");
// Assert
var annotation1 = devui1.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
var annotation2 = devui2.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
Assert.Equal("prefix1", annotation1.EntityIdPrefix);
Assert.Equal("prefix2", annotation2.EntityIdPrefix);
}
#endregion
#region Helper Methods
/// <summary>
/// Creates a mock agent service builder for testing.
/// Uses a minimal resource implementation that satisfies IResourceWithEndpoints.
/// </summary>
private static IResourceBuilder<IResourceWithEndpoints> CreateMockAgentServiceBuilder(
IDistributedApplicationBuilder appBuilder,
string name)
{
// Create a mock resource that implements IResourceWithEndpoints
var mockResource = new Mock<IResourceWithEndpoints>();
mockResource.Setup(r => r.Name).Returns(name);
mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection());
var mockBuilder = new Mock<IResourceBuilder<IResourceWithEndpoints>>();
mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object);
mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder);
return mockBuilder.Object;
}
#endregion
}
@@ -0,0 +1,167 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Aspire.Hosting.ApplicationModel;
using Moq;
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AgentServiceAnnotation"/> class.
/// </summary>
public class AgentServiceAnnotationTests
{
#region Constructor Validation Tests
/// <summary>
/// Verifies that passing null for agentService throws ArgumentNullException.
/// </summary>
[Fact]
public void Constructor_NullAgentService_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AgentServiceAnnotation(null!));
}
/// <summary>
/// Verifies that a valid agentService can be used to create the annotation.
/// </summary>
[Fact]
public void Constructor_ValidAgentService_CreatesAnnotation()
{
// Arrange
var mockResource = new Mock<IResource>();
mockResource.Setup(r => r.Name).Returns("test-service");
// Act
var annotation = new AgentServiceAnnotation(mockResource.Object);
// Assert
Assert.NotNull(annotation);
Assert.Same(mockResource.Object, annotation.AgentService);
}
#endregion
#region Property Tests
/// <summary>
/// Verifies that AgentService property returns the value passed to constructor.
/// </summary>
[Fact]
public void AgentService_ReturnsConstructorValue()
{
// Arrange
var mockResource = new Mock<IResource>();
mockResource.Setup(r => r.Name).Returns("my-service");
// Act
var annotation = new AgentServiceAnnotation(mockResource.Object);
// Assert
Assert.Same(mockResource.Object, annotation.AgentService);
}
/// <summary>
/// Verifies that EntityIdPrefix returns null when not specified.
/// </summary>
[Fact]
public void EntityIdPrefix_NotSpecified_ReturnsNull()
{
// Arrange
var mockResource = new Mock<IResource>();
mockResource.Setup(r => r.Name).Returns("test-service");
// Act
var annotation = new AgentServiceAnnotation(mockResource.Object);
// Assert
Assert.Null(annotation.EntityIdPrefix);
}
/// <summary>
/// Verifies that EntityIdPrefix returns the value passed to constructor.
/// </summary>
[Fact]
public void EntityIdPrefix_Specified_ReturnsValue()
{
// Arrange
var mockResource = new Mock<IResource>();
mockResource.Setup(r => r.Name).Returns("test-service");
// Act
var annotation = new AgentServiceAnnotation(mockResource.Object, entityIdPrefix: "custom-prefix");
// Assert
Assert.Equal("custom-prefix", annotation.EntityIdPrefix);
}
/// <summary>
/// Verifies that Agents returns empty collection when not specified.
/// </summary>
[Fact]
public void Agents_NotSpecified_ReturnsEmptyCollection()
{
// Arrange
var mockResource = new Mock<IResource>();
mockResource.Setup(r => r.Name).Returns("test-service");
// Act
var annotation = new AgentServiceAnnotation(mockResource.Object);
// Assert
Assert.NotNull(annotation.Agents);
Assert.Empty(annotation.Agents);
}
/// <summary>
/// Verifies that Agents returns the list passed to constructor.
/// </summary>
[Fact]
public void Agents_Specified_ReturnsValue()
{
// Arrange
var mockResource = new Mock<IResource>();
mockResource.Setup(r => r.Name).Returns("test-service");
var agents = new[] { new AgentEntityInfo("agent1"), new AgentEntityInfo("agent2") };
// Act
var annotation = new AgentServiceAnnotation(mockResource.Object, agents: agents);
// Assert
Assert.Equal(2, annotation.Agents.Count);
Assert.Equal("agent1", annotation.Agents[0].Id);
Assert.Equal("agent2", annotation.Agents[1].Id);
}
#endregion
#region Full Constructor Tests
/// <summary>
/// Verifies that all constructor parameters are correctly stored.
/// </summary>
[Fact]
public void Constructor_AllParameters_SetsAllProperties()
{
// Arrange
var mockResource = new Mock<IResource>();
mockResource.Setup(r => r.Name).Returns("full-service");
var agents = new[] { new AgentEntityInfo("writer", "Writes stories") };
// Act
var annotation = new AgentServiceAnnotation(
mockResource.Object,
entityIdPrefix: "writer-backend",
agents: agents);
// Assert
Assert.Same(mockResource.Object, annotation.AgentService);
Assert.Equal("writer-backend", annotation.EntityIdPrefix);
Assert.Single(annotation.Agents);
Assert.Equal("writer", annotation.Agents[0].Id);
Assert.Equal("Writes stories", annotation.Agents[0].Description);
}
#endregion
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Aspire.Hosting.AgentFramework.DevUI\Aspire.Hosting.AgentFramework.DevUI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,613 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Aspire.Hosting.ApplicationModel;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="DevUIAggregatorHostedService"/> class.
/// </summary>
public class DevUIAggregatorHostedServiceTests
{
#region RewriteAgentIdInQueryString Tests
/// <summary>
/// Verifies that RewriteAgentIdInQueryString returns empty string when query string has no value.
/// </summary>
[Fact]
public void RewriteAgentIdInQueryString_EmptyQueryString_ReturnsEmptyString()
{
// Arrange
var queryString = QueryString.Empty;
// Act
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
// Assert
Assert.Equal(string.Empty, result);
}
/// <summary>
/// Verifies that RewriteAgentIdInQueryString rewrites agent_id to the un-prefixed value.
/// </summary>
[Fact]
public void RewriteAgentIdInQueryString_WithPrefixedAgentId_RewritesToUnprefixed()
{
// Arrange
var queryString = new QueryString("?agent_id=writer-agent%2Fwriter");
// Act
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
// Assert
Assert.Contains("agent_id=writer", result);
Assert.DoesNotContain("writer-agent", result);
}
/// <summary>
/// Verifies that RewriteAgentIdInQueryString preserves other query parameters.
/// </summary>
[Fact]
public void RewriteAgentIdInQueryString_WithOtherParams_PreservesOtherParams()
{
// Arrange
var queryString = new QueryString("?agent_id=writer-agent%2Fwriter&conversation_id=123&page=5");
// Act
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
// Assert
Assert.Contains("agent_id=writer", result);
Assert.Contains("conversation_id=123", result);
Assert.Contains("page=5", result);
}
/// <summary>
/// Verifies that RewriteAgentIdInQueryString works when agent_id is not the first parameter.
/// </summary>
[Fact]
public void RewriteAgentIdInQueryString_AgentIdNotFirst_StillRewrites()
{
// Arrange
var queryString = new QueryString("?page=1&agent_id=editor-agent%2Feditor&limit=10");
// Act
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "editor");
// Assert
Assert.Contains("agent_id=editor", result);
Assert.DoesNotContain("editor-agent", result);
}
/// <summary>
/// Verifies that RewriteAgentIdInQueryString handles special characters in actual agent ID.
/// </summary>
[Fact]
public void RewriteAgentIdInQueryString_SpecialCharsInAgentId_UrlEncodesCorrectly()
{
// Arrange
var queryString = new QueryString("?agent_id=prefix%2Fmy-agent");
// Act
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "my-agent");
// Assert
// The result should contain the agent_id with the value properly encoded if needed
Assert.Contains("agent_id=my-agent", result);
}
/// <summary>
/// Verifies that RewriteAgentIdInQueryString handles an agent_id with no prefix.
/// </summary>
[Fact]
public void RewriteAgentIdInQueryString_NoPrefix_SetsDirectly()
{
// Arrange
var queryString = new QueryString("?agent_id=simple");
// Act
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "new-value");
// Assert
Assert.Contains("agent_id=new-value", result);
Assert.DoesNotContain("simple", result);
}
/// <summary>
/// Verifies that RewriteAgentIdInQueryString adds agent_id even if not originally present.
/// </summary>
[Fact]
public void RewriteAgentIdInQueryString_NoAgentId_AddsAgentId()
{
// Arrange
var queryString = new QueryString("?page=1&limit=10");
// Act
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
// Assert
Assert.Contains("agent_id=writer", result);
Assert.Contains("page=1", result);
Assert.Contains("limit=10", result);
}
/// <summary>
/// Verifies that RewriteAgentIdInQueryString returns proper format starting with ?.
/// </summary>
[Fact]
public void RewriteAgentIdInQueryString_ValidQuery_ReturnsQueryStringFormat()
{
// Arrange
var queryString = new QueryString("?agent_id=test");
// Act
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
// Assert
Assert.StartsWith("?", result);
}
#endregion
#region Backend Resolution Behavior Tests
/// <summary>
/// Verifies that ResolveBackends returns empty dictionary when no annotations are present.
/// These tests verify the expected behavior of the aggregator via the DevUI resource annotations.
/// </summary>
[Fact]
public void DevUIResource_NoAnnotations_ResolveBackendsReturnsEmpty()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
var devui = builder.AddDevUI("devui");
// Act
var annotations = devui.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.ToList();
// Assert - no AgentServiceAnnotation means no backends
Assert.Empty(annotations);
}
/// <summary>
/// Verifies that WithAgentService adds proper annotations for backend resolution.
/// </summary>
[Fact]
public void WithAgentService_AddsAnnotation_ForBackendResolution()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
var devui = builder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent");
// Act
devui.WithAgentService(agentService);
// Assert
var annotation = devui.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.FirstOrDefault();
Assert.NotNull(annotation);
Assert.Equal("writer-agent", annotation.AgentService.Name);
}
/// <summary>
/// Verifies that custom EntityIdPrefix is properly stored in the annotation.
/// </summary>
[Fact]
public void WithAgentService_CustomPrefix_StoresInAnnotation()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
var devui = builder.AddDevUI("devui");
var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent");
// Act
devui.WithAgentService(agentService, entityIdPrefix: "custom-writer");
// Assert
var annotation = devui.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.First();
Assert.Equal("custom-writer", annotation.EntityIdPrefix);
}
/// <summary>
/// Verifies that multiple agent services create multiple annotations for backend resolution.
/// </summary>
[Fact]
public void WithAgentService_MultipleServices_CreatesMultipleAnnotations()
{
// Arrange
var builder = DistributedApplication.CreateBuilder();
var devui = builder.AddDevUI("devui");
var writerService = CreateMockAgentServiceBuilder(builder, "writer-agent");
var editorService = CreateMockAgentServiceBuilder(builder, "editor-agent");
// Act
devui.WithAgentService(writerService);
devui.WithAgentService(editorService);
// Assert
var annotations = devui.Resource.Annotations
.OfType<AgentServiceAnnotation>()
.ToList();
Assert.Equal(2, annotations.Count);
Assert.Contains(annotations, a => a.AgentService.Name == "writer-agent");
Assert.Contains(annotations, a => a.AgentService.Name == "editor-agent");
}
#endregion
#region Backend Endpoint Selection Tests
/// <summary>
/// Verifies that ResolveBackends prefers the HTTPS endpoint when both HTTP and HTTPS are allocated.
/// </summary>
[Fact]
public void ResolveBackends_WithHttpAndHttpsEndpoints_PrefersHttps()
{
// Arrange
var devui = new DevUIResource("devui");
var agentService = new TestEndpointResource("writer-agent");
AddAllocatedEndpoint(agentService, "http", "http", 5050);
AddAllocatedEndpoint(agentService, "https", "https", 7443);
devui.Annotations.Add(new AgentServiceAnnotation(agentService));
var aggregator = new DevUIAggregatorHostedService(devui, NullLogger.Instance);
// Act
var backends = aggregator.ResolveBackends();
// Assert
Assert.Equal("https://localhost:7443", backends["writer-agent"]);
}
/// <summary>
/// Verifies that ResolveBackends falls back to HTTP when the HTTPS endpoint is not present.
/// </summary>
[Fact]
public void ResolveBackends_WithOnlyHttpEndpoint_UsesHttp()
{
// Arrange
var devui = new DevUIResource("devui");
var agentService = new TestEndpointResource("writer-agent");
AddAllocatedEndpoint(agentService, "http", "http", 5050);
devui.Annotations.Add(new AgentServiceAnnotation(agentService));
var aggregator = new DevUIAggregatorHostedService(devui, NullLogger.Instance);
// Act
var backends = aggregator.ResolveBackends();
// Assert
Assert.Equal("http://localhost:5050", backends["writer-agent"]);
}
/// <summary>
/// Verifies that ResolveBackends falls back to HTTP when the HTTPS endpoint has not been allocated yet.
/// </summary>
[Fact]
public void ResolveBackends_WithUnallocatedHttpsEndpoint_UsesHttp()
{
// Arrange
var devui = new DevUIResource("devui");
var agentService = new TestEndpointResource("writer-agent");
AddEndpoint(agentService, "https", "https");
AddAllocatedEndpoint(agentService, "http", "http", 5050);
devui.Annotations.Add(new AgentServiceAnnotation(agentService));
var aggregator = new DevUIAggregatorHostedService(devui, NullLogger.Instance);
// Act
var backends = aggregator.ResolveBackends();
// Assert
Assert.Equal("http://localhost:5050", backends["writer-agent"]);
}
#endregion
#region Entity ID Parsing Tests
/// <summary>
/// Verifies the expected format for prefixed entity IDs in the aggregator.
/// </summary>
[Theory]
[InlineData("writer-agent/writer", "writer-agent", "writer")]
[InlineData("editor-agent/editor", "editor-agent", "editor")]
[InlineData("custom/my-agent", "custom", "my-agent")]
[InlineData("prefix/sub/path", "prefix", "sub/path")]
public void PrefixedEntityId_Format_ExtractsCorrectly(string prefixedId, string expectedPrefix, string expectedRest)
{
// This test documents the expected format for prefixed entity IDs
// The aggregator uses "prefix/entityId" format where:
// - prefix is typically the resource name or custom prefix
// - entityId is the original entity identifier from the backend
// Act
var slashIndex = prefixedId.IndexOf('/');
var prefix = prefixedId[..slashIndex];
var rest = prefixedId[(slashIndex + 1)..];
// Assert
Assert.Equal(expectedPrefix, prefix);
Assert.Equal(expectedRest, rest);
}
#endregion
#region Helper Methods
/// <summary>
/// Creates a mock agent service builder for testing.
/// Uses a minimal resource implementation that satisfies IResourceWithEndpoints.
/// </summary>
private static IResourceBuilder<IResourceWithEndpoints> CreateMockAgentServiceBuilder(
IDistributedApplicationBuilder appBuilder,
string name)
{
// Create a mock resource that implements IResourceWithEndpoints
var mockResource = new Moq.Mock<IResourceWithEndpoints>();
mockResource.Setup(r => r.Name).Returns(name);
mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection());
var mockBuilder = new Moq.Mock<IResourceBuilder<IResourceWithEndpoints>>();
mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object);
mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder);
return mockBuilder.Object;
}
private static void AddAllocatedEndpoint(
TestEndpointResource resource,
string name,
string uriScheme,
int port)
{
var endpoint = AddEndpoint(resource, name, uriScheme);
endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", port);
}
private static EndpointAnnotation AddEndpoint(
TestEndpointResource resource,
string name,
string uriScheme)
{
var endpoint = new EndpointAnnotation(
ProtocolType.Tcp,
uriScheme: uriScheme,
name: name,
port: null,
isProxied: false);
resource.Annotations.Add(endpoint);
return endpoint;
}
private sealed class TestEndpointResource(string name) : Resource(name), IResourceWithEndpoints;
#endregion
#region Proxy Target Validation Tests
[Theory]
[InlineData("http://localhost:5000", "/v1/conversations")]
[InlineData("http://localhost:5000", "/devui/index.html?v=1")]
public void ValidateProxyTarget_TargetStaysOnConfiguredBackend_ReturnsTargetUri(string backendUrl, string path)
{
// Arrange
var backendUri = new Uri(backendUrl);
// Act
var target = DevUIAggregatorHostedService.ValidateProxyTarget(backendUrl, path);
// Assert
Assert.NotNull(target);
Assert.Equal(backendUri.Host, target!.Host);
Assert.Equal(backendUri.Scheme, target.Scheme);
Assert.Equal(backendUri.Port, target.Port);
}
[Theory]
[InlineData("http://localhost:5000", "http://alternate.example/data")] // absolute path overrides the host
[InlineData("http://localhost:5000", "//alternate.example/data")] // protocol-relative path overrides the host
[InlineData("http://localhost:5000", "https://localhost:5000/data")] // scheme differs from the backend
[InlineData("http://localhost:5000", "http://localhost:6000/data")] // port differs from the backend
[InlineData("this is not a url", "/v1/conversations")] // malformed backend url
public void ValidateProxyTarget_TargetLeavesConfiguredBackend_ReturnsNull(string backendUrl, string path)
{
// Act
var target = DevUIAggregatorHostedService.ValidateProxyTarget(backendUrl, path);
// Assert
Assert.Null(target);
}
[Fact]
public async Task ProxyRequest_ConversationRoute_ForwardsToConfiguredBackendAsync()
{
// Arrange
await using var proxy = await ProxyTestContext.StartAsync();
// Act
var response = await proxy.SendAsync("/v1/conversations?limit=10");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var forwarded = Assert.Single(proxy.BackendRequests);
Assert.Equal("/v1/conversations", forwarded.Path);
Assert.Equal("?limit=10", forwarded.QueryString);
}
[Fact]
public async Task ProxyRequest_DevUIRoute_ForwardsToConfiguredBackendAsync()
{
// Arrange
await using var proxy = await ProxyTestContext.StartAsync();
// Act
var response = await proxy.SendAsync("/devui/index.html?v=1");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var forwarded = Assert.Single(proxy.BackendRequests);
Assert.Equal("/devui/index.html", forwarded.Path);
Assert.Equal("?v=1", forwarded.QueryString);
}
[Theory]
[InlineData("/v1/conversations/../conversations")]
[InlineData("/devui/../devui/index.html")]
public async Task ProxyRequest_NormalizedPath_ForwardsToConfiguredBackendAsync(string requestPath)
{
// Arrange
await using var proxy = await ProxyTestContext.StartAsync();
// Act
var response = await proxy.SendAsync(requestPath);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Single(proxy.BackendRequests);
}
#region Proxy Test Helpers
/// <summary>
/// Hosts a stub backend together with a DevUI aggregator wired to it, and exposes an
/// <see cref="HttpClient"/> targeting the aggregator so proxied requests can be observed
/// on the backend.
/// </summary>
private sealed class ProxyTestContext : IAsyncDisposable
{
private readonly WebApplication _backend;
private readonly DevUIAggregatorHostedService _aggregator;
private readonly HttpClient _client;
private readonly List<(string Path, string QueryString)> _backendRequests;
private ProxyTestContext(
WebApplication backend,
DevUIAggregatorHostedService aggregator,
HttpClient client,
List<(string Path, string QueryString)> backendRequests)
{
this._backend = backend;
this._aggregator = aggregator;
this._client = client;
this._backendRequests = backendRequests;
}
/// <summary>Gets the requests received by the stub backend, in arrival order.</summary>
public IReadOnlyList<(string Path, string QueryString)> BackendRequests => this._backendRequests;
public static async Task<ProxyTestContext> StartAsync()
{
var backendRequests = new List<(string Path, string QueryString)>();
var backend = await StartStubBackendAsync(backendRequests).ConfigureAwait(false);
var aggregator = await StartAggregatorAsync(GetBaseAddress(backend)).ConfigureAwait(false);
var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{aggregator.AllocatedPort}") };
return new ProxyTestContext(backend, aggregator, client, backendRequests);
}
/// <summary>Sends a GET request to the aggregator using the given relative path.</summary>
public Task<HttpResponseMessage> SendAsync(string relativePath)
=> this._client.GetAsync(new Uri(relativePath, UriKind.Relative));
public async ValueTask DisposeAsync()
{
this._client.Dispose();
await this._aggregator.DisposeAsync().ConfigureAwait(false);
await this._backend.StopAsync().ConfigureAwait(false);
await this._backend.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Starts a minimal backend that records the path and query string of every request it receives.
/// </summary>
private static async Task<WebApplication> StartStubBackendAsync(List<(string Path, string QueryString)> requests)
{
var builder = WebApplication.CreateSlimBuilder();
builder.Logging.ClearProviders();
var app = builder.Build();
app.Urls.Add("http://127.0.0.1:0");
app.Map("{**path}", (HttpContext context) =>
{
requests.Add((context.Request.Path.Value ?? string.Empty, context.Request.QueryString.Value ?? string.Empty));
return Results.Json(new { ok = true });
});
await app.StartAsync().ConfigureAwait(false);
return app;
}
/// <summary>
/// Starts a DevUI aggregator configured with a single backend pointing at <paramref name="backendUrl"/>.
/// </summary>
private static async Task<DevUIAggregatorHostedService> StartAggregatorAsync(string backendUrl)
{
var resource = new DevUIResource("test-devui");
resource.Annotations.Add(new AgentServiceAnnotation(CreateBackendResource(backendUrl)));
using var loggerFactory = LoggerFactory.Create(_ => { });
var aggregator = new DevUIAggregatorHostedService(
resource,
loggerFactory.CreateLogger<DevUIAggregatorHostedService>());
await aggregator.StartAsync(CancellationToken.None).ConfigureAwait(false);
return aggregator;
}
/// <summary>
/// Creates a backend resource whose "http" endpoint is allocated to <paramref name="backendUrl"/>.
/// </summary>
private static TestBackendResource CreateBackendResource(string backendUrl)
{
var backendUri = new Uri(backendUrl);
var resource = new TestBackendResource("test-backend");
var endpoint = new EndpointAnnotation(
ProtocolType.Tcp,
uriScheme: "http",
name: "http",
port: backendUri.Port,
isProxied: false)
{
TargetHost = backendUri.Host
};
endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, backendUri.Host, backendUri.Port);
resource.Annotations.Add(endpoint);
return resource;
}
private static string GetBaseAddress(WebApplication app)
=> app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
private sealed class TestBackendResource(string name) : Resource(name), IResourceWithEndpoints;
#endregion
#endregion
}
@@ -0,0 +1,195 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Net.Sockets;
using Aspire.Hosting.ApplicationModel;
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="DevUIResource"/> class.
/// </summary>
public class DevUIResourceTests
{
#region Constructor Tests
/// <summary>
/// Verifies that the resource name is correctly set.
/// </summary>
[Fact]
public void Constructor_WithName_SetsName()
{
// Arrange & Act
var resource = new DevUIResource("test-devui");
// Assert
Assert.Equal("test-devui", resource.Name);
}
/// <summary>
/// Verifies that the resource implements IResourceWithEndpoints.
/// </summary>
[Fact]
public void Resource_ImplementsIResourceWithEndpoints()
{
// Arrange & Act
var resource = new DevUIResource("test-devui");
// Assert
Assert.IsAssignableFrom<IResourceWithEndpoints>(resource);
}
/// <summary>
/// Verifies that the resource implements IResourceWithWaitSupport.
/// </summary>
[Fact]
public void Resource_ImplementsIResourceWithWaitSupport()
{
// Arrange & Act
var resource = new DevUIResource("test-devui");
// Assert
Assert.IsAssignableFrom<IResourceWithWaitSupport>(resource);
}
#endregion
#region Endpoint Annotation Tests
/// <summary>
/// Verifies that the resource has an HTTP endpoint annotation when port is specified.
/// </summary>
[Fact]
public void Constructor_WithPort_AddsEndpointAnnotation()
{
// Arrange & Act
var resource = CreateResourceWithPort(8090);
// Assert
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().FirstOrDefault();
Assert.NotNull(endpoint);
Assert.Equal("http", endpoint.Name);
Assert.Equal(8090, endpoint.Port);
}
/// <summary>
/// Verifies that the endpoint annotation has correct protocol type.
/// </summary>
[Fact]
public void EndpointAnnotation_HasTcpProtocol()
{
// Arrange
var resource = CreateResourceWithPort(8080);
// Act
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
// Assert
Assert.Equal(ProtocolType.Tcp, endpoint.Protocol);
}
/// <summary>
/// Verifies that the endpoint annotation has HTTP URI scheme.
/// </summary>
[Fact]
public void EndpointAnnotation_HasHttpUriScheme()
{
// Arrange
var resource = CreateResourceWithPort(8080);
// Act
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
// Assert
Assert.Equal("http", endpoint.UriScheme);
}
/// <summary>
/// Verifies that the endpoint is not proxied.
/// </summary>
[Fact]
public void EndpointAnnotation_IsNotProxied()
{
// Arrange
var resource = CreateResourceWithPort(8080);
// Act
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
// Assert
Assert.False(endpoint.IsProxied);
}
/// <summary>
/// Verifies that the endpoint target host is localhost.
/// </summary>
[Fact]
public void EndpointAnnotation_TargetHostIsLocalhost()
{
// Arrange
var resource = CreateResourceWithPort(8080);
// Act
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
// Assert
Assert.Equal("localhost", endpoint.TargetHost);
}
/// <summary>
/// Verifies that the endpoint has no fixed port when null is passed.
/// </summary>
[Fact]
public void Constructor_WithNullPort_EndpointHasNullPort()
{
// Arrange & Act
var resource = CreateResourceWithPort(null);
// Assert
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().FirstOrDefault();
Assert.NotNull(endpoint);
Assert.Null(endpoint.Port);
}
#endregion
#region PrimaryEndpoint Tests
/// <summary>
/// Verifies that PrimaryEndpoint returns an endpoint reference.
/// </summary>
[Fact]
public void PrimaryEndpoint_ReturnsEndpointReference()
{
// Arrange
var resource = CreateResourceWithPort(8080);
// Act
var endpoint = resource.PrimaryEndpoint;
// Assert
Assert.NotNull(endpoint);
Assert.Same(resource, endpoint.Resource);
}
/// <summary>
/// Verifies that PrimaryEndpoint returns the same instance on multiple calls.
/// </summary>
[Fact]
public void PrimaryEndpoint_MultipleCalls_ReturnsSameInstance()
{
// Arrange
var resource = CreateResourceWithPort(8080);
// Act
var endpoint1 = resource.PrimaryEndpoint;
var endpoint2 = resource.PrimaryEndpoint;
// Assert
Assert.Same(endpoint1, endpoint2);
}
#endregion
private static DevUIResource CreateResourceWithPort(int? port) => new("test-devui", port);
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace AzureAIAgentsPersistent.IntegrationTests;
[Trait("Category", "Integration")]
public class AzureAIAgentsChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AzureAIAgentsPersistentFixture>(() => new())
{
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace AzureAIAgentsPersistent.IntegrationTests;
[Trait("Category", "Integration")]
public class AzureAIAgentsChatClientAgentRunTests() : ChatClientAgentRunTests<AzureAIAgentsPersistentFixture>(() => new())
{
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Agents.Persistent" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
</Project>
@@ -0,0 +1,285 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Type or member is obsolete - testing deprecated PersistentAgentsClientExtensions
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Agents.Persistent;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace AzureAIAgentsPersistent.IntegrationTests;
[Trait("Category", "Integration")]
public class AzureAIAgentsPersistentCreateTests
{
private const string SkipCodeInterpreterReason = "Azure AI Code Interpreter intermittently fails to execute uploaded files in CI";
private readonly PersistentAgentsClient _persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), TestAzureCliCredentials.CreateAzureCliCredential());
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithFoundryOptionsAsync")]
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism)
{
// Arrange.
const string AgentName = "IntegrationTestAgent";
const string AgentDescription = "An agent created during integration tests";
const string AgentInstructions = "You are an integration test agent";
// Act.
var agent = createMechanism switch
{
"CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
options: new ChatClientAgentOptions()
{
ChatOptions = new() { Instructions = AgentInstructions },
Name = AgentName,
Description = AgentDescription
}),
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: AgentInstructions,
name: AgentName,
description: AgentDescription),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
try
{
// Assert.
Assert.NotNull(agent);
Assert.Equal(AgentName, agent.Name);
Assert.Equal(AgentDescription, agent.Description);
Assert.Equal(AgentInstructions, agent.Instructions);
var retrievedAgentMetadata = await this._persistentAgentsClient.Administration.GetAgentAsync(agent.Id);
Assert.NotNull(retrievedAgentMetadata);
Assert.Equal(AgentName, retrievedAgentMetadata.Value.Name);
Assert.Equal(AgentDescription, retrievedAgentMetadata.Value.Description);
Assert.Equal(AgentInstructions, retrievedAgentMetadata.Value.Instructions);
}
finally
{
// Cleanup.
await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
}
}
[Theory(Skip = "For manual testing only")]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithFoundryOptionsAsync")]
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism)
{
// Arrange.
const string AgentInstructions = """
You are a helpful agent that can help fetch data from files you know about.
Use the File Search Tool to look up codes for words.
Do not answer a question unless you can find the answer using the File Search Tool.
""";
// Create a vector store.
var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt";
File.WriteAllText(
path: searchFilePath,
contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457."
);
PersistentAgentFileInfo uploadedAgentFile = this._persistentAgentsClient.Files.UploadFile(
filePath: searchFilePath,
purpose: PersistentAgentFilePurpose.Agents
);
var vectorStoreMetadata = await this._persistentAgentsClient.VectorStores.CreateVectorStoreAsync([uploadedAgentFile.Id], name: "WordCodeLookup_VectorStore");
// Wait for vector store indexing to complete before using it
await this.WaitForVectorStoreReadyAsync(this._persistentAgentsClient, vectorStoreMetadata.Value.Id);
// Act.
var agent = createMechanism switch
{
"CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = AgentInstructions,
Tools = [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]
}
}),
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: AgentInstructions,
tools: [new FileSearchToolDefinition()],
toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
try
{
// Assert.
// Verify that the agent can use the vector store to answer a question.
var result = await agent.RunAsync("Can you give me the documented code for 'banana'?");
Assert.Contains("673457", result.ToString());
}
finally
{
// Cleanup.
await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
await this._persistentAgentsClient.VectorStores.DeleteVectorStoreAsync(vectorStoreMetadata.Value.Id);
await this._persistentAgentsClient.Files.DeleteFileAsync(uploadedAgentFile.Id);
File.Delete(searchFilePath);
}
}
[Fact(Skip = SkipCodeInterpreterReason)]
public Task CreateAgent_CreatesAgentWithCodeInterpreter_ChatClientAgentOptionsAsync()
=> this.CreateAgent_CreatesAgentWithCodeInterpreterAsync("CreateWithChatClientAgentOptionsAsync");
[Fact(Skip = SkipCodeInterpreterReason)]
public Task CreateAgent_CreatesAgentWithCodeInterpreter_FoundryOptionsAsync()
=> this.CreateAgent_CreatesAgentWithCodeInterpreterAsync("CreateWithFoundryOptionsAsync");
private async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
{
// Arrange.
const string AgentInstructions = """
You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file
and report the SECRET_NUMBER value it prints. Respond only with the number.
""";
// Create a python file that prints a known value.
var codeFilePath = Path.GetTempFileName() + "secret_number.py";
File.WriteAllText(
path: codeFilePath,
contents: "print(\"SECRET_NUMBER=24601\")" // Deterministic output we will look for.
);
PersistentAgentFileInfo uploadedCodeFile = this._persistentAgentsClient.Files.UploadFile(
filePath: codeFilePath,
purpose: PersistentAgentFilePurpose.Agents
);
CodeInterpreterToolResource toolResource = new();
toolResource.FileIds.Add(uploadedCodeFile.Id);
// Act.
var agent = createMechanism switch
{
// Hosted tool path (tools supplied via ChatClientAgentOptions)
"CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = AgentInstructions,
Tools = [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]
}
}),
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: AgentInstructions,
tools: [new CodeInterpreterToolDefinition()],
toolResources: new ToolResources() { CodeInterpreter = toolResource }),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
try
{
// Assert.
var result = await agent.RunAsync("What is the SECRET_NUMBER?");
// We expect the model to run the code and surface the number.
Assert.Contains("24601", result.ToString());
}
finally
{
// Cleanup.
await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
await this._persistentAgentsClient.Files.DeleteFileAsync(uploadedCodeFile.Id);
File.Delete(codeFilePath);
}
}
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism)
{
// Arrange.
const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather.";
static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C.";
var weatherFunction = AIFunctionFactory.Create(GetWeather);
ChatClientAgent agent = createMechanism switch
{
"CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = AgentInstructions,
Tools = [weatherFunction]
}
}),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
try
{
// Act.
var response = await agent.RunAsync("What is the weather like in Amsterdam?");
// Assert - ensure function was invoked and its output surfaced.
var text = response.Text;
Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
}
}
/// <summary>
/// Waits for a vector store to complete indexing by polling its status.
/// </summary>
/// <param name="client">The persistent agents client.</param>
/// <param name="vectorStoreId">The ID of the vector store.</param>
/// <param name="maxWaitSeconds">Maximum time to wait in seconds (default: 30).</param>
/// <returns>A task that completes when the vector store is ready or throws on timeout/failure.</returns>
private async Task WaitForVectorStoreReadyAsync(
PersistentAgentsClient client,
string vectorStoreId,
int maxWaitSeconds = 30)
{
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < maxWaitSeconds)
{
PersistentAgentsVectorStore vectorStore = await client.VectorStores.GetVectorStoreAsync(vectorStoreId);
if (vectorStore.Status == VectorStoreStatus.Completed)
{
if (vectorStore.FileCounts.Failed > 0)
{
throw new InvalidOperationException("Vector store indexing failed for some files");
}
return;
}
if (vectorStore.Status == VectorStoreStatus.Expired)
{
throw new InvalidOperationException("Vector store has expired");
}
await Task.Delay(1000);
}
throw new TimeoutException($"Vector store did not complete indexing within {maxWaitSeconds}s");
}
}
@@ -0,0 +1,104 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Azure;
using Azure.AI.Agents.Persistent;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace AzureAIAgentsPersistent.IntegrationTests;
public class AzureAIAgentsPersistentFixture : IChatClientAgentFixture
{
private ChatClientAgent _agent = null!;
private PersistentAgentsClient _persistentAgentsClient = null!;
public IChatClient ChatClient => this._agent.ChatClient;
public AIAgent Agent => this._agent;
public async Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session)
{
List<ChatMessage> messages = [];
var typedSession = (ChatClientAgentSession)session;
await foreach (var threadMessage in (AsyncPageable<PersistentThreadMessage>)this._persistentAgentsClient.Messages.GetMessagesAsync(
threadId: typedSession.ConversationId, order: ListSortOrder.Ascending))
{
var message = new ChatMessage
{
Role = threadMessage.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant
};
foreach (var content in threadMessage.ContentItems)
{
if (content is MessageTextContent textContent)
{
message.Contents.Add(new TextContent(textContent.Text));
}
}
messages.Add(message);
}
return messages;
}
public async Task<ChatClientAgent> CreateChatClientAgentAsync(
string name = "HelpfulAssistant",
string instructions = "You are a helpful assistant.",
IList<AITool>? aiTools = null)
{
var persistentAgentResponse = await this._persistentAgentsClient.Administration.CreateAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
name: name,
instructions: instructions);
var persistentAgent = persistentAgentResponse.Value;
return new ChatClientAgent(
this._persistentAgentsClient.AsIChatClient(persistentAgent.Id),
options: new()
{
Id = persistentAgent.Id,
ChatOptions = new() { Tools = aiTools }
});
}
public Task DeleteAgentAsync(ChatClientAgent agent) =>
this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
public Task DeleteSessionAsync(AgentSession session)
{
var typedSession = (ChatClientAgentSession)session;
if (typedSession?.ConversationId is not null)
{
return this._persistentAgentsClient.Threads.DeleteThreadAsync(typedSession.ConversationId);
}
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
if (this._persistentAgentsClient is not null && this._agent is not null)
{
return new ValueTask(this._persistentAgentsClient.Administration.DeleteAgentAsync(this._agent.Id));
}
return default;
}
public async ValueTask InitializeAsync()
{
this._persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), TestAzureCliCredentials.CreateAzureCliCredential());
this._agent = await this.CreateChatClientAgentAsync();
}
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace AzureAIAgentsPersistent.IntegrationTests;
[Trait("Category", "Integration")]
public class AzureAIAgentsPersistentRunStreamingTests() : RunStreamingTests<AzureAIAgentsPersistentFixture>(() => new())
{
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentConformance.IntegrationTests;
namespace AzureAIAgentsPersistent.IntegrationTests;
[Trait("Category", "Integration")]
public class AzureAIAgentsPersistentRunTests() : RunTests<AzureAIAgentsPersistentFixture>(() => new())
{
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AzureAIAgentsPersistent.IntegrationTests;
[Trait("Category", "Integration")]
public class AzureAIAgentsPersistentStructuredOutputRunTests() : StructuredOutputRunTests<AzureAIAgentsPersistentFixture>(() => new())
{
private const string SkipReason = "Fails intermittently on the build agent/CI";
public override Task RunWithResponseFormatReturnsExpectedResultAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
return base.RunWithResponseFormatReturnsExpectedResultAsync();
}
public override Task RunWithGenericTypeReturnsExpectedResultAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
return base.RunWithGenericTypeReturnsExpectedResultAsync();
}
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
{
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
return base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.CopilotStudio\Microsoft.Agents.AI.CopilotStudio.csproj" />
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" />
</ItemGroup>
</Project>
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using CopilotStudio.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.CopilotStudio;
using Microsoft.Agents.CopilotStudio.Client;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shared.IntegrationTests;
namespace CopilotStudio.IntegrationTests;
public class CopilotStudioFixture : IAgentFixture
{
public AIAgent Agent { get; private set; } = null!;
public Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session) =>
throw new NotSupportedException("CopilotStudio doesn't allow retrieval of chat history.");
public Task DeleteSessionAsync(AgentSession session) =>
// Chat Completion does not require/support deleting threads, so this is a no-op.
Task.CompletedTask;
public ValueTask InitializeAsync()
{
const string CopilotStudioHttpClientName = nameof(CopilotStudioAgent);
CopilotStudioConnectionSettings? settings = null;
try
{
settings = new CopilotStudioConnectionSettings(
TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioTenantId),
TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioAgentAppId))
{
DirectConnectUrl = TestConfiguration.GetRequiredValue(TestSettings.CopilotStudioDirectConnectUrl),
};
}
catch (InvalidOperationException ex)
{
Assert.Skip("CopilotStudio configuration could not be loaded. Error:" + ex.Message);
}
ServiceCollection services = new();
services
.AddSingleton(settings)
.AddSingleton<CopilotStudioTokenHandler>()
.AddHttpClient(CopilotStudioHttpClientName)
.ConfigurePrimaryHttpMessageHandler<CopilotStudioTokenHandler>();
IHttpClientFactory httpClientFactory =
services
.BuildServiceProvider()
.GetRequiredService<IHttpClientFactory>();
CopilotClient client = new(settings, httpClientFactory, NullLogger.Instance, CopilotStudioHttpClientName);
this.Agent = new CopilotStudioAgent(client);
return default;
}
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
return default;
}
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace CopilotStudio.IntegrationTests;
public class CopilotStudioRunStreamingTests() : RunStreamingTests<CopilotStudioFixture>(() => new())
{
// Set to null to run the tests.
private const string ManualVerification = "For manual verification";
public override Task SessionMaintainsHistoryAsync()
{
Assert.Skip("Copilot Studio does not support session history retrieval, so this test is not applicable.");
return base.SessionMaintainsHistoryAsync();
}
public override Task RunWithChatMessageReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithChatMessageReturnsExpectedResultAsync();
}
public override Task RunWithChatMessagesReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithChatMessagesReturnsExpectedResultAsync();
}
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithNoMessageDoesNotFailAsync();
}
public override Task RunWithStringReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithStringReturnsExpectedResultAsync();
}
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace CopilotStudio.IntegrationTests;
public class CopilotStudioRunTests() : RunTests<CopilotStudioFixture>(() => new())
{
// Set to null to run the tests.
private const string ManualVerification = "For manual verification";
public override Task SessionMaintainsHistoryAsync()
{
Assert.Skip("Copilot Studio does not support session history retrieval, so this test is not applicable.");
return base.SessionMaintainsHistoryAsync();
}
public override Task RunWithChatMessageReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithChatMessageReturnsExpectedResultAsync();
}
public override Task RunWithChatMessagesReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithChatMessagesReturnsExpectedResultAsync();
}
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithNoMessageDoesNotFailAsync();
}
public override Task RunWithStringReturnsExpectedResultAsync()
{
Assert.SkipWhen(ManualVerification is not null, ManualVerification ?? string.Empty);
return base.RunWithStringReturnsExpectedResultAsync();
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.CopilotStudio.Client;
using Microsoft.Agents.CopilotStudio.Client.Discovery;
using Microsoft.Extensions.Configuration;
namespace CopilotStudio.IntegrationTests.Support;
/// <summary>
/// <see cref="ConnectionSettings"/> with additional properties to specify Application (Client) Id,
/// Tenant Id, and optionally the Application Client secret.
/// </summary>
internal sealed class CopilotStudioConnectionSettings : ConnectionSettings
{
/// <summary>
/// Application ID for creating the authentication for the connection
/// </summary>
public string AppClientId { get; }
/// <summary>
/// Application secret for creating the authentication for the connection
/// </summary>
public string? AppClientSecret { get; }
/// <summary>
/// Tenant ID for creating the authentication for the connection
/// </summary>
public string TenantId { get; }
/// <summary>
/// Use interactive or service connection for authentication.
/// Defaults to true, meaning interactive authentication will be used.
/// </summary>
public bool UseInteractiveAuthentication { get; set; } = true;
/// <summary>
/// Instantiate a new instance of the <see cref="CopilotStudioConnectionSettings"/> from provided settings.
/// </summary>
public CopilotStudioConnectionSettings(string tenantId, string appClientId, string? appClientSecret = null)
{
this.TenantId = tenantId;
this.AppClientId = appClientId;
this.AppClientSecret = appClientSecret;
this.Cloud = PowerPlatformCloud.Prod;
this.CopilotAgentType = AgentType.Published;
}
/// <summary>
/// Instantiate a new instance of the <see cref="CopilotStudioConnectionSettings"/> from a configuration section.
/// </summary>
/// <param name="config"></param>
/// <exception cref="ArgumentException"></exception>
public CopilotStudioConnectionSettings(IConfigurationSection config)
: base(config)
{
this.AppClientId = config[nameof(this.AppClientId)] ?? throw new ArgumentException($"{nameof(this.AppClientId)} not found in config");
this.TenantId = config[nameof(this.TenantId)] ?? throw new ArgumentException($"{nameof(this.TenantId)} not found in config");
this.AppClientSecret = config[nameof(this.AppClientSecret)];
}
}
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.CopilotStudio.Client;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Extensions.Msal;
using Microsoft.Shared.Diagnostics;
namespace CopilotStudio.IntegrationTests.Support;
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
/// <summary>
/// A <see cref="HttpClientHandler"/> that adds an authentication token to the request headers for Copilot Studio API calls.
/// </summary>
/// <remarks>
/// For more information on how to setup various authentication flows, see the Microsoft Identity documentation at https://aka.ms/msal.
/// </remarks>
internal sealed class CopilotStudioTokenHandler : HttpClientHandler
{
private const string AuthenticationHeader = "Bearer";
private const string CacheFolderName = "mcs_client_console";
private const string KeyChainServiceName = "copilot_studio_client_app";
private const string KeyChainAccountName = "copilot_studio_client";
private readonly CopilotStudioConnectionSettings _settings;
private readonly string[] _scopes;
private IConfidentialClientApplication? _clientApplication;
/// <summary>
/// Initializes a new instance of the <see cref="CopilotStudioTokenHandler"/> class with the specified connection settings.
/// </summary>
/// <param name="settings">The connection settings for Copilot Studio.</param>
public CopilotStudioTokenHandler(CopilotStudioConnectionSettings settings)
{
Throw.IfNull(settings);
this._settings = settings;
this._scopes = [CopilotClient.ScopeFromSettings(this._settings)];
}
/// <inheritdoc/>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.Headers.Authorization is null)
{
AuthenticationResult authResponse = await this.AuthenticateAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue(AuthenticationHeader, authResponse.AccessToken);
}
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
private Task<AuthenticationResult> AuthenticateAsync(CancellationToken cancellationToken) =>
this._settings.UseInteractiveAuthentication ?
this.AuthenticateInteractiveAsync(cancellationToken) :
this.AuthenticateServiceAsync(cancellationToken);
private async Task<AuthenticationResult> AuthenticateServiceAsync(CancellationToken cancellationToken)
{
if (this._clientApplication is null)
{
this._clientApplication = ConfidentialClientApplicationBuilder.Create(this._settings.AppClientId)
.WithAuthority(AzureCloudInstance.AzurePublic, this._settings.TenantId)
.WithClientSecret(this._settings.AppClientSecret)
.Build();
MsalCacheHelper tokenCacheHelper = await CreateCacheHelperAsync("AppTokenCache").ConfigureAwait(false);
tokenCacheHelper.RegisterCache(this._clientApplication.AppTokenCache);
}
AuthenticationResult authResponse;
authResponse = await this._clientApplication.AcquireTokenForClient(this._scopes).ExecuteAsync(cancellationToken).ConfigureAwait(false);
return authResponse;
}
private async Task<AuthenticationResult> AuthenticateInteractiveAsync(CancellationToken cancellationToken = default!)
{
IPublicClientApplication app =
PublicClientApplicationBuilder.Create(this._settings.AppClientId)
.WithAuthority(AadAuthorityAudience.AzureAdMyOrg)
.WithTenantId(this._settings.TenantId)
.WithRedirectUri("http://localhost")
.Build();
MsalCacheHelper tokenCacheHelper = await CreateCacheHelperAsync("TokenCache").ConfigureAwait(false);
tokenCacheHelper.RegisterCache(app.UserTokenCache);
IEnumerable<IAccount> accounts = await app.GetAccountsAsync().ConfigureAwait(false);
IAccount? account = accounts.FirstOrDefault();
AuthenticationResult authResponse;
try
{
authResponse = await app.AcquireTokenSilent(this._scopes, account).ExecuteAsync(cancellationToken).ConfigureAwait(false);
}
catch (MsalUiRequiredException)
{
authResponse = await app.AcquireTokenInteractive(this._scopes).ExecuteAsync(cancellationToken).ConfigureAwait(false);
}
return authResponse;
}
private static async Task<MsalCacheHelper> CreateCacheHelperAsync(string cacheFileName)
{
string currentDir = Path.Combine(AppContext.BaseDirectory, CacheFolderName);
if (!Directory.Exists(currentDir))
{
Directory.CreateDirectory(currentDir);
}
StorageCreationPropertiesBuilder storageProperties = new(cacheFileName, currentDir);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
storageProperties.WithLinuxUnprotectedFile();
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
storageProperties.WithMacKeyChain(KeyChainServiceName, KeyChainAccountName);
}
return await MsalCacheHelper.CreateAsync(storageProperties.Build()).ConfigureAwait(false);
}
}
+31
View File
@@ -0,0 +1,31 @@
<Project>
<Import Project="../Directory.Build.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<IsAotCompatible>false</IsAotCompatible>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0;net472</TargetFrameworks>
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
<NoWarn>$(NoWarn);Moq1410;xUnit1051;MAAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xRetry.v3" />
<PackageReference Include="xunit.v3.mtp-v2" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="xRetry.v3" />
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
**/bin/
**/obj/
.git/
.gitignore
.dockerignore
README.md
*.user
*.suo
@@ -0,0 +1,6 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "foundry-hosting-it-test-container.dll"]
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Foundry.Hosting.IntegrationTests.TestContainer</RootNamespace>
<AssemblyName>foundry-hosting-it-test-container</AssemblyName>
<IsPackable>false</IsPackable>
<IsTestProject>false</IsTestProject>
<UseMicrosoftTestingPlatformRunner>false</UseMicrosoftTestingPlatformRunner>
<TestingPlatformDotnetTestSupport>false</TestingPlatformDotnetTestSupport>
<NoWarn>$(NoWarn);NU1605;NU1903;AAIP001;OPENAI001</NoWarn>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageReference Remove="xunit.v3.mtp-v2" />
<PackageReference Remove="xunit.runner.visualstudio" />
<PackageReference Remove="Moq" />
<PackageReference Remove="xRetry.v3" />
<PackageReference Remove="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Remove="Microsoft.NET.Test.Sdk" />
<Using Remove="Xunit" />
<Using Remove="xRetry.v3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
</Project>
@@ -0,0 +1,408 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Azure;
using Azure.AI.Projects;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
// Foundry hosted agent test container for Foundry.Hosting.IntegrationTests.
//
// One image, many scenarios. The IT_SCENARIO environment variable selects which agent
// behavior is wired up at startup. Each scenario corresponds to one test fixture and
// one set of tests in the IT project.
//
// The platform injects FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_AGENT_NAME, FOUNDRY_AGENT_VERSION,
// PORT, and APPLICATIONINSIGHTS_CONNECTION_STRING. We never set FOUNDRY_* or AGENT_* names
// from the test side because they are reserved by the platform.
var scenario = Environment.GetEnvironmentVariable("IT_SCENARIO") ?? "happy-path";
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
var credential = new DefaultAzureCredential();
var projectClient = new AIProjectClient(projectEndpoint, credential);
AIAgent agent = scenario switch
{
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
"unsupported-protocol" => CreateHappyPathAgent(projectClient, deployment),
"store-config" => CreateStoreConfigAgent(projectClient, deployment),
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
"toolbox-oauth-consent" => CreateToolboxOAuthConsentAgent(projectClient, deployment),
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
"memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false),
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
"agent-skills" => CreateAgentSkillsAgent(projectClient, deployment),
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
};
var builder = WebApplication.CreateBuilder(args);
var port = Environment.GetEnvironmentVariable("PORT");
if (!string.IsNullOrEmpty(port))
{
builder.WebHost.UseUrls($"http://+:{port}");
}
builder.Services.AddFoundryResponses(agent);
// toolbox-oauth-consent scenario: pre-register a Foundry toolbox whose tool source is fronted by a
// per-user OAuth connection. IT_TOOLBOX_NAME names that toolbox (the fixture sets it). With the
// startup-deferral fix the container stays routable even though the toolbox cannot enumerate without
// a consented user, and the first user request surfaces an oauth_consent_request.
var consentToolboxName = Environment.GetEnvironmentVariable("IT_TOOLBOX_NAME");
if (!string.IsNullOrEmpty(consentToolboxName))
{
builder.Services.AddFoundryToolboxes(credential, consentToolboxName);
}
var app = builder.Build();
app.MapFoundryResponses();
app.Run();
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant. Answer the user's question concisely and accurately. " +
"At the very end of every reply, append the marker token CONTAINER-OK on its own line.",
name: "happy-path-agent",
description: "Round trip and conversation test agent.");
// store-config scenario: a neutral assistant used to exercise store/session semantics
// (store=true/false, previous_response_id and conversation_id forks, multi-turn recall). It has no
// marker instruction so it never contaminates the content/recall assertions.
static AIAgent CreateStoreConfigAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant. Answer the user's question concisely and accurately, " +
"and use any facts the user told you earlier in the conversation.",
name: "store-config-agent",
description: "Store and session semantics test agent.");
static AIAgent CreateToolCallingAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant. Use the GetUtcNow and Multiply tools when appropriate.",
name: "tool-calling-agent",
description: "Server side tool calling test agent.",
tools: [
AIFunctionFactory.Create(GetUtcNow),
AIFunctionFactory.Create(Multiply)
]);
static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string deployment) =>
// TODO: wire approval required AIFunction once the public surface is finalized.
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant. Use the SendEmail tool when asked to send a message; it requires user approval before running.",
name: "tool-calling-approval-agent",
description: "Approval flow test agent (placeholder).",
tools: [
AIFunctionFactory.Create(SendEmail)
]);
static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp.
client.AsAIAgent(
model: deployment,
instructions: "You are an assistant with access to Microsoft Learn documentation via MCP.",
name: "mcp-toolbox-agent",
description: "MCP toolbox test agent (placeholder).");
// toolbox-oauth-consent scenario: a plain agent whose tools come from a pre-registered Foundry
// toolbox (wired via AddFoundryToolboxes from IT_TOOLBOX_NAME). The toolbox's tool source requires
// per-user OAuth consent, so the first request that needs the tool surfaces an oauth_consent_request
// instead of running the tool.
static AIAgent CreateToolboxOAuthConsentAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: "You are an assistant that can act on the user's behalf using OAuth-protected tools. " +
"When the user asks you to do something that needs such a tool, call it.",
name: "toolbox-oauth-consent-agent",
description: "Per-user OAuth toolbox consent test agent.");
static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deployment) =>
// TODO: substitute custom IResponsesStorageProvider in DI.
client.AsAIAgent(
model: deployment,
instructions: "You are a helpful assistant.",
name: "custom-storage-agent",
description: "Custom storage test agent (placeholder).");
static AIAgent CreateAzureSearchRagAgent(AIProjectClient client, string deployment)
{
// The fixture (AzureSearchRagHostedAgentFixture) injects AZURE_SEARCH_ENDPOINT and
// AZURE_SEARCH_INDEX_NAME into the hosted agent definition. The index is provisioned
// out of band (see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md for the
// required schema and seed content); the container only needs read access. The
// agent's managed identity must hold 'Search Index Data Reader' on the search service
// scope.
var searchEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set for IT_SCENARIO=azure-search-rag."));
var indexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set for IT_SCENARIO=azure-search-rag.");
var searchClient = new SearchClient(searchEndpoint, indexName, new DefaultAzureCredential());
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
return client.AsAIAgent(new ChatClientAgentOptions
{
Name = "azure-search-rag-agent",
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
"Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(CreateAzureSearchAdapter(searchClient), options)]
});
}
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
CreateAzureSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
{
var searchOptions = new SearchOptions { Size = top };
Response<SearchResults<SearchDocument>> response =
await client.SearchAsync<SearchDocument>(query, searchOptions, cancellationToken).ConfigureAwait(false);
var results = new List<TextSearchProvider.TextSearchResult>();
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
RawRepresentation = hit
});
}
return results;
};
// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume.
// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample.
static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) =>
client.AsAIAgent(
model: deployment,
instructions: """
You are a friendly assistant that helps users inspect and summarise
files stored in the session sandbox at $HOME.
Always answer file-related questions by calling the available tools
(GetHomeDirectory, ListFiles, ReadFile). Do not guess file paths or
contents read the file before answering.
Quote numbers and figures verbatim from the file rather than
paraphrasing them.
""",
name: "session-files-agent",
description: "Reads files from the per-session $HOME volume.",
tools: [
AIFunctionFactory.Create(GetHomeDirectory),
AIFunctionFactory.Create(ListFiles),
AIFunctionFactory.Create(ReadFile)
]);
// Memory scenario. The agent uses FoundryMemoryProvider scoped per user via the
// HostedSessionContext that the hosting layer applies from the platform isolation headers.
// In production the platform sets the headers; here we rely on the default
// PlatformHostedSessionIsolationKeyProvider that AgentFrameworkResponseHandler resolves.
static async Task<AIAgent> CreateMemoryAgentAsync(AIProjectClient client, string deployment)
{
var embedding = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
var memoryStoreName = Environment.GetEnvironmentVariable("IT_MEMORY_STORE_ID") ?? "it-memory-store";
var memoryProvider = new FoundryMemoryProvider(
client,
memoryStoreName,
stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
await memoryProvider.EnsureMemoryStoreCreatedAsync(deployment, embedding, "Memory store for hosted-memory IT scenario.").ConfigureAwait(false);
return client.AsAIAgent(new ChatClientAgentOptions
{
Name = "memory-agent",
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details."
},
AIContextProviders = [memoryProvider]
});
}
// Agent skills scenario. Uses AgentSkillsProvider with two bundled Contoso Outdoors skills
// (support-style + escalation-policy). Skills are loaded from embedded SKILL.md files on disk,
// simulating the download-from-Foundry pattern used by the Hosted-AgentSkills sample. When the
// container starts, it writes the skills to a temp directory and wires AgentSkillsProvider over it.
#pragma warning disable MEAI001 // AgentSkillsProvider is experimental
static AIAgent CreateAgentSkillsAgent(AIProjectClient client, string deployment)
{
string skillsDir = Path.Combine(Path.GetTempPath(), "it-agent-skills-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(skillsDir, "support-style"));
Directory.CreateDirectory(Path.Combine(skillsDir, "escalation-policy"));
File.WriteAllText(Path.Combine(skillsDir, "support-style", "SKILL.md"),
"""
---
name: support-style
description: Contoso Outdoors customer-support tone and formatting guidelines.
---
# Contoso Outdoors Support Style
You are speaking on behalf of Contoso Outdoors customer support.
## Voice
- Warm, concise, and confident.
- Use the customer's name when known.
- Sign every response with ` Contoso Outdoors Support`.
## Canary
To prove this skill was loaded, include the token `STYLE-CANARY-3318` on a
separate line at the bottom of every response, prefixed with `# `.
""");
File.WriteAllText(Path.Combine(skillsDir, "escalation-policy", "SKILL.md"),
"""
---
name: escalation-policy
description: When and how to escalate Contoso Outdoors customer-support tickets.
---
# Contoso Outdoors Escalation Policy
## Escalate immediately when the customer
- Reports an injury or safety incident.
- Mentions legal action, regulators, or the press.
- Requests a refund larger than $500.
## How to escalate
1. Acknowledge the issue.
2. Tell the customer you are escalating to a senior specialist.
3. Provide the escalation reference `ESC-CANARY-7742`.
""");
var skillsProvider = new AgentSkillsProvider(skillsDir, scriptRunner: null);
return client.AsAIAgent(new ChatClientAgentOptions
{
Name = "agent-skills-agent",
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = "You are a customer-support assistant for Contoso Outdoors.",
},
AIContextProviders = [skillsProvider]
})
.AsBuilder()
.UseToolApproval(new ToolApprovalAgentOptions
{
AutoApprovalRules = [AgentSkillsProvider.AllToolsAutoApprovalRule],
})
.Build();
}
#pragma warning restore MEAI001
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
[Description("Multiplies two integers and returns the product.")]
static int Multiply([Description("First operand")] int a, [Description("Second operand")] int b) => a * b;
[Description("Sends an email. Requires user approval.")]
static string SendEmail(
[Description("Recipient address")] string to,
[Description("Email subject")] string subject) =>
$"Email sent to {to} with subject '{subject}'.";
// session-files tools: resolve paths against $HOME (the per-session sandbox volume).
[Description("Get the absolute path of the session home directory ($HOME).")]
static string GetHomeDirectory() => SessionHome();
[Description("List files and directories under the given path inside the session sandbox. Pass an empty string to list $HOME.")]
static string[] ListFiles(
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
{
try
{
return Directory.EnumerateFileSystemEntries(ResolveSessionPath(path)).ToArray();
}
catch (Exception ex)
{
return [$"Error listing '{path}': {ex.Message}"];
}
}
[Description("Read the full text contents of a file inside the session sandbox.")]
static string ReadFile(
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
{
try
{
return File.ReadAllText(ResolveSessionPath(path));
}
catch (Exception ex)
{
return $"Error reading '{path}': {ex.Message}";
}
}
static string SessionHome() =>
Environment.GetEnvironmentVariable("HOME")
?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Resolve a caller-supplied path against $HOME, rejecting absolute paths and traversal segments
// so that the model cannot read or list arbitrary container files via the ReadFile/ListFiles
// tools (defense-in-depth against indirect prompt injection). Mirrors the canonicalize +
// startsWith($HOME) pattern used by FileSystemAgentFileStore.ResolveSafePath.
static string ResolveSessionPath(string path)
{
string home = SessionHome();
string homeFull = Path.GetFullPath(home);
string homePrefix = homeFull.EndsWith(Path.DirectorySeparatorChar)
? homeFull
: homeFull + Path.DirectorySeparatorChar;
if (string.IsNullOrWhiteSpace(path))
{
return homeFull;
}
if (Path.IsPathRooted(path))
{
throw new ArgumentException($"Absolute paths are not allowed: '{path}'.", nameof(path));
}
string combined = Path.Combine(homeFull, path);
string fullPath = Path.GetFullPath(combined);
if (!fullPath.Equals(homeFull, StringComparison.Ordinal) &&
!fullPath.StartsWith(homePrefix, StringComparison.Ordinal))
{
throw new ArgumentException(
$"Path '{path}' resolves outside the session sandbox.", nameof(path));
}
return fullPath;
}
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Extensions.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Integration tests that exercise the Agent Skills pattern in a hosted agent container.
/// The container uses <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> with two
/// Contoso Outdoors skills (support-style, escalation-policy) to verify the progressive
/// disclosure flow: skills are advertised in the system prompt and loaded on demand via
/// the <c>load_skill</c> tool when the model decides they are relevant.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class AgentSkillsHostedAgentTests(AgentSkillsHostedAgentFixture fixture) : IClassFixture<AgentSkillsHostedAgentFixture>
{
private readonly AgentSkillsHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task RoutineQuestion_LoadsSupportStyleSkillAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — ask a routine support question that should trigger the support-style skill
var response = await agent.RunAsync(
"Hi, I am Alex. I just want to confirm I can return my tent within 30 days.");
// Assert — response should contain the canary token proving the skill was loaded
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("STYLE-CANARY-3318", response.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task EscalationTrigger_LoadsEscalationPolicySkillAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — trigger an escalation (legal threat + refund > $500)
var response = await agent.RunAsync(
"I want a $750 refund on Order #A-1042 right now or I am calling my lawyer.");
// Assert — response should contain the escalation canary token
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("ESC-CANARY-7742", response.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task SkillsAreAdvertised_LoadSkillToolIsAvailableAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — ask the model what skills are available (triggers system prompt inspection)
var response = await agent.RunAsync(
"List the skills you have access to. Just give me their names.");
// Assert — both skills should be mentioned (they are advertised in the system prompt)
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("support-style", response.Text);
Assert.Contains("escalation-policy", response.Text);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task LoadSkill_InvokesToolAndReturnsContentAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act — ask a question that should load a specific skill
var response = await agent.RunAsync(
"I need to know the escalation policy for customer tickets. Load the escalation-policy skill and tell me the rules.");
// Assert — the response should reference the load_skill tool invocation
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.True(
response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any(fc => fc.Name == "load_skill")),
"Expected at least one load_skill FunctionCallContent in the response messages.");
}
}
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End to end RAG integration tests against a hosted agent backed by Azure AI Search.
/// The hosted agent runs the test container with <c>IT_SCENARIO=azure-search-rag</c>, which
/// wires <see cref="TextSearchProvider"/> over a real <c>SearchClient</c> against the
/// pre-seeded Contoso Outdoors index.
/// </summary>
/// <remarks>
/// Each test asks for a unique <c>*-CANARY-*</c> token that exists ONLY in the seeded
/// document. The model cannot fabricate these tokens from its training data, so a passing
/// assertion is proof the agent retrieved the seeded document via Azure AI Search rather
/// than answering from general knowledge.
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class AzureSearchRagHostedAgentTests(AzureSearchRagHostedAgentFixture fixture)
: IClassFixture<AzureSearchRagHostedAgentFixture>
{
private readonly AzureSearchRagHostedAgentFixture _fixture = fixture;
[Fact]
public async Task RagAnswer_CitesSeededReturnPolicy_WhenAskedAboutReturnsAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: ask about the canary SKU embedded in the seeded Return Policy doc. The
// canary token (TR-CANARY-7821) is unfakeable - it does not exist in any model
// training data, so its presence in the answer is proof the agent retrieved
// the seeded document via the Azure AI Search adapter.
var response = await agent.RunAsync(
"What item code do I get with my return? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("TR-CANARY-7821", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_CitesShippingGuide_WhenAskedAboutShippingAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: canary promo code (SHIP-CANARY-4493) is unique to the seeded Shipping
// Guide doc. Its presence proves the answer was grounded in retrieved content.
var response = await agent.RunAsync(
"What promo code can I use for free overnight shipping? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("SHIP-CANARY-4493", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_StaysGroundedWithoutContext_WhenAskedUnrelatedQuestionAsync()
{
// Arrange: ask something that is NOT covered by the three seeded Contoso documents.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync(
"What is the boiling point of liquid nitrogen in degrees Celsius? " +
"Just give the number with units, no other context.");
// Assert: response is non empty AND does NOT fabricate a Contoso source citation.
// The agent may either answer from its general knowledge or admit uncertainty; either
// is acceptable. The key assertion is that we do not see a fake Contoso link.
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.DoesNotContain("contoso.com", response.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests for a hosted agent whose container wires an in memory custom storage provider
/// in place of the platform default. Verifies the model still works and that multi turn
/// behavior reads from the custom store.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class CustomStorageHostedAgentTests(CustomStorageHostedAgentFixture fixture)
: IClassFixture<CustomStorageHostedAgentFixture>
{
private readonly CustomStorageHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task RoundTrip_WorksWithCustomStorageAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Reply with the word 'stored'.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task MultiTurn_PreviousResponseId_ReadsFromCustomStoreAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act
var first = await agent.RunAsync("My favorite city is Lisbon. Acknowledge briefly.", session);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
var second = await agent.RunAsync("What city did I just tell you?", session);
// Assert
Assert.Contains("Lisbon", second.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=agent-skills</c> mode.
/// The container creates two Contoso Outdoors skills (support-style, escalation-policy) on disk
/// and wires them into <see cref="Microsoft.Agents.AI.AgentSkillsProvider"/> so the model can
/// discover and load skills via the progressive disclosure pattern.
/// </summary>
public sealed class AgentSkillsHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "agent-skills";
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using AgentConformance.IntegrationTests.Support;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=azure-search-rag</c> mode.
/// Wires the container up with an Azure AI Search backed <see cref="Microsoft.Agents.AI.TextSearchProvider"/>
/// adapter that retrieves Contoso Outdoors documents from a pre-provisioned search index before each
/// model invocation.
/// </summary>
/// <remarks>
/// Prerequisites managed out of band:
/// <list type="bullet">
/// <item><description>The <c>it-azure-search-rag</c> agent's managed identity must hold
/// <c>Search Index Data Reader</c> on the search service scope. Granted manually after
/// the first <c>scripts/it-bootstrap-agents.ps1</c> run; see the IT README.</description></item>
/// <item><description>The search index referenced by <c>AZURE_SEARCH_INDEX_NAME</c> must
/// already exist with the documented schema and Contoso Outdoors content. The search
/// service is shared with <c>python-sample-validation.yml</c>; no .NET-side provisioning
/// script ships with this repository.</description></item>
/// </list>
/// </remarks>
public sealed class AzureSearchRagHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "azure-search-rag";
/// <summary>
/// Inject the AZURE_SEARCH_* env vars onto the hosted agent definition so the test container
/// scenario branch can construct its <c>SearchClient</c>. These names are NOT in the platform
/// reserved <c>FOUNDRY_*</c> / <c>AGENT_*</c> namespace so they are safe to set.
/// </summary>
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
environment[TestSettings.AzureSearchEndpoint] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchEndpoint);
environment[TestSettings.AzureSearchIndexName] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchIndexName);
}
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=custom-storage</c> mode.
/// The container substitutes the default Responses storage provider with a custom in memory
/// implementation so tests can verify that conversation history is read from and written to
/// the custom store rather than the platform default.
/// </summary>
public sealed class CustomStorageHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "custom-storage";
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=happy-path</c> mode.
/// Used by tests that exercise the basic Responses protocol round trip, multi turn behavior
/// (via <c>previous_response_id</c> and <c>conversation_id</c>), and the <c>stored=false</c> flag.
/// </summary>
public sealed class HappyPathHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "happy-path";
}
@@ -0,0 +1,300 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Base fixture for Foundry Hosted Agent integration tests.
///
/// Each derived fixture represents one scenario (happy path, tool calling, toolbox, etc.) and
/// targets a stable, scenario-keyed agent name (e.g. <c>it-happy-path</c>). The fixture creates
/// a new <see cref="ProjectsAgentVersion"/> on each <see cref="InitializeAsync"/>, polls until
/// active, patches the agent's endpoint to route 100% of traffic to that new version, then
/// exposes the wrapped <see cref="AIAgent"/> for tests via <see cref="Agent"/>.
///
/// On <see cref="DisposeAsync"/> only the version created by this fixture is removed; the agent
/// itself (and therefore its managed identity) is left in place. This is critical because the
/// agent's managed identity must hold <c>Azure AI User</c> on the project scope to serve
/// inbound inference traffic, and that role assignment is lost when the agent itself is deleted.
///
/// Prerequisite: each scenario agent (and its managed identity) must exist and have
/// <c>Azure AI User</c> pre-granted on the project scope before the tests run. See
/// <c>scripts/it-bootstrap-agents.ps1</c>.
///
/// The container image is the same for every scenario; the scenario itself is selected by
/// the <c>IT_SCENARIO</c> environment variable in <see cref="HostedAgentDefinition.EnvironmentVariables"/>,
/// configured by each derived fixture via <see cref="ScenarioName"/>.
/// </summary>
public abstract class HostedAgentFixture : IAsyncLifetime
{
private const string ScenarioEnvironmentVariable = "IT_SCENARIO";
private const string RunIdEnvironmentVariable = "IT_RUN_ID";
private const string ModelDeploymentEnvironmentVariable = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
private const string FoundryFeaturesHeader = "Foundry-Features";
private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview";
private const string EnableVnextExperienceMetadataKey = "enableVnextExperience";
private AgentAdministrationClient _adminClient = null!;
/// <summary>
/// Scenario keyword passed to the container as <c>IT_SCENARIO</c>. Derived fixtures override.
/// </summary>
protected abstract string ScenarioName { get; }
/// <summary>
/// CPU request for the hosted agent container. Override per scenario if needed.
/// </summary>
protected virtual string Cpu => "0.25";
/// <summary>
/// Memory request for the hosted agent container. Override per scenario if needed.
/// </summary>
protected virtual string Memory => "0.5Gi";
/// <summary>
/// Maximum time to wait for <see cref="AgentVersionStatus.Active"/> after creation.
/// </summary>
protected virtual TimeSpan ProvisioningTimeout => TimeSpan.FromMinutes(5);
/// <summary>
/// Container responses protocol version declared in <c>container_protocol_versions</c>. Defaults to
/// <c>2.0.0</c> (the only version this image supports). The unsupported-protocol scenario overrides
/// it to <c>1.0.0</c> to assert the container fails fast with a clear, actionable error.
/// </summary>
protected virtual string ResponsesProtocolVersion => "2.0.0";
/// <summary>
/// The wrapped agent. Available after <see cref="InitializeAsync"/>.
/// </summary>
public AIAgent Agent { get; private set; } = null!;
/// <summary>
/// The stable, scenario keyed agent name registered in Foundry (e.g. <c>it-happy-path</c>).
/// The agent itself is provisioned out of band (see <c>scripts/it-bootstrap-agents.ps1</c>);
/// each test run only adds and removes a version under it.
/// </summary>
public string AgentName { get; private set; } = null!;
/// <summary>
/// The agent version assigned by Foundry on creation.
/// </summary>
public string AgentVersion { get; private set; } = null!;
/// <summary>
/// The underlying <see cref="AIProjectClient"/>, useful for tests that need to talk
/// to the conversations or responses APIs directly (e.g. to assert chain visibility).
/// </summary>
public AIProjectClient ProjectClient { get; private set; } = null!;
/// <summary>
/// The per-agent <see cref="ProjectOpenAIClient"/> bound to this scenario's hosted agent endpoint
/// (<c>/agents/{name}/endpoint/protocols/openai</c>). Stored hosted-agent responses are only
/// readable through this per-agent client; the project-level responses client returns
/// <c>session_not_accessible</c> (403). Use this to fetch a response by id.
/// </summary>
public ProjectOpenAIClient AgentOpenAIClient { get; private set; } = null!;
/// <summary>
/// Creates a server side conversation that tests can pass via <c>ChatOptions.ConversationId</c>
/// to exercise multi turn flows backed by the Foundry conversations service.
/// </summary>
public async Task<string> CreateConversationAsync()
{
var response = await this.AgentOpenAIClient.GetProjectConversationsClient().CreateProjectConversationAsync().ConfigureAwait(false);
return response.Value.Id;
}
/// <summary>
/// Deletes a previously created conversation. Used by tests in their cleanup blocks.
/// </summary>
public async Task DeleteConversationAsync(string conversationId)
{
try
{
await this.AgentOpenAIClient.GetProjectConversationsClient().DeleteConversationAsync(conversationId).ConfigureAwait(false);
}
catch
{
// Best effort cleanup mirroring DisposeAsync.
}
}
/// <summary>
/// Counts items currently stored in a conversation. Used by tests verifying that a
/// <c>stored=false</c> request did not append to the conversation.
/// </summary>
public async Task<int> CountConversationItemsAsync(string conversationId)
{
var count = 0;
await foreach (var _ in this.AgentOpenAIClient.GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc").ConfigureAwait(false))
{
count++;
}
return count;
}
public async ValueTask InitializeAsync()
{
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
var image = TestConfiguration.GetRequiredValue(TestSettings.FoundryHostingItImage);
var credential = TestAzureCliCredentials.CreateAzureCliCredential();
var adminOptions = new AgentAdministrationClientOptions();
adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
this._adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
this.ProjectClient = new AIProjectClient(endpoint, credential);
this.AgentName = $"it-{this.ScenarioName}";
var definition = new HostedAgentDefinition(cpu: this.Cpu, memory: this.Memory)
{
Image = image,
};
definition.Versions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, this.ResponsesProtocolVersion));
definition.EnvironmentVariables[ScenarioEnvironmentVariable] = this.ScenarioName;
// Forward the test-side model deployment to the container so it targets the same model the
// tests expect. Without this the container falls back to its hard-coded default (gpt-4o),
// which fails on projects that only deploy a different model.
var modelDeployment = TestConfiguration.GetValue(TestSettings.AzureAIModelDeploymentName);
if (!string.IsNullOrWhiteSpace(modelDeployment))
{
definition.EnvironmentVariables[ModelDeploymentEnvironmentVariable] = modelDeployment;
}
// Foundry deduplicates versions by content hash, so a fixture re-using the same
// definition would just receive the bootstrap version and then delete it on dispose.
// Adding a per-run env var forces a brand new version that the dispose can safely remove
// without touching the bootstrap version (which keeps the agent alive across runs).
definition.EnvironmentVariables[RunIdEnvironmentVariable] = Guid.NewGuid().ToString("N");
// Allow derived fixtures to layer additional environment variables before submission.
this.ConfigureEnvironment(definition.EnvironmentVariables);
var creationOptions = new ProjectsAgentVersionCreationOptions(definition);
creationOptions.Metadata[EnableVnextExperienceMetadataKey] = "true";
// Adds a new version under the (stable) agent name. Auto-creates the agent on first run.
// The agent is intentionally never deleted because its managed identity must hold the
// pre-granted role assignment for inbound inference to succeed (see class docs).
var version = await this._adminClient.CreateAgentVersionAsync(this.AgentName, creationOptions).ConfigureAwait(false);
var activeVersion = await WaitForActiveAsync(this._adminClient, version.Value, this.ProvisioningTimeout).ConfigureAwait(false);
this.AgentVersion = activeVersion.Version;
// The agent endpoint must already be configured to route via @latest. The bootstrap
// script (scripts/it-bootstrap-agents.ps1) does that one-time per agent. Each new
// version we create automatically becomes the served one because @latest resolves
// to the highest version number.
//
// Build a per-agent ProjectOpenAIClient (the cached projectClient.ProjectOpenAIClient is bound
// to the project-level URL and cannot serve a hosted agent). AgentName on the options selects
// the per-agent URL suffix `/agents/{name}/endpoint/protocols/openai`. The Foundry-Features
// header is also required on the invocation pipeline (not just the admin one) for hosted agents.
var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this.AgentName };
openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
this.AgentOpenAIClient = openAIClient;
var responsesClient = openAIClient.GetProjectResponsesClient();
this.Agent = responsesClient.AsIChatClient().AsAIAgent(name: this.AgentName);
}
public async ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
if (this._adminClient is null || this.AgentName is null || this.AgentVersion is null)
{
return;
}
try
{
// Delete only the version we created. The agent itself MUST stay so that its
// managed identity (and the pre-granted Azure AI User role on it) survive across
// test runs. If we delete the agent, Foundry mints a new MI on the next create
// and inference fails with PermissionDenied until the role is regranted.
await this._adminClient.DeleteAgentVersionAsync(this.AgentName, this.AgentVersion).ConfigureAwait(false);
}
catch
{
// Best effort cleanup. Never throw from DisposeAsync because that would mask
// the real test failure. Orphan versions accumulate harmlessly; a maintenance
// script can prune them when needed.
}
}
/// <summary>
/// Hook for derived fixtures to add scenario specific environment variables.
/// Reserved names (anything matching <c>FOUNDRY_*</c> or <c>AGENT_*</c>) are forbidden by the platform.
/// </summary>
protected virtual void ConfigureEnvironment(IDictionary<string, string> environment)
{
}
private static async Task<ProjectsAgentVersion> WaitForActiveAsync(
AgentAdministrationClient adminClient,
ProjectsAgentVersion version,
TimeSpan timeout)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (version.Status != AgentVersionStatus.Active && version.Status != AgentVersionStatus.Failed)
{
if (DateTimeOffset.UtcNow > deadline)
{
throw new TimeoutException(
$"Hosted agent '{version.Name}' version '{version.Version}' did not become Active within {timeout.TotalSeconds:F0}s. Last status: {version.Status}.");
}
await Task.Delay(TimeSpan.FromMilliseconds(500), CancellationToken.None).ConfigureAwait(false);
version = (await adminClient.GetAgentVersionAsync(version.Name, version.Version).ConfigureAwait(false)).Value;
}
if (version.Status != AgentVersionStatus.Active)
{
throw new InvalidOperationException(
$"Hosted agent '{version.Name}' version '{version.Version}' failed to deploy. Status: {version.Status}.");
}
return version;
}
/// <summary>
/// Pipeline policy that adds the Foundry feature header on every request.
/// Required for hosted agent operations until the V1 preview flag is removed.
/// </summary>
private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
ProcessNext(message, pipeline, currentIndex);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
private void SetHeader(PipelineMessage message)
{
// Set rather than Add to avoid duplicate headers if the pipeline reprocesses
// the request (retries) or if multiple policies attempt to set the same key.
message.Request.Headers.Remove(FoundryFeaturesHeader);
message.Request.Headers.Add(FoundryFeaturesHeader, features);
}
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=store-config</c> mode.
/// Used by <c>HostedResponsesStoreConfigTests</c> to exercise store/session semantics: <c>store=true</c>
/// vs <c>store=false</c>, <c>previous_response_id</c> and <c>conversation_id</c> forks, and multi-turn
/// recall. The container agent is a neutral assistant with no marker instruction so it never
/// contaminates the content assertions.
/// </summary>
public sealed class HostedResponsesStoreConfigFixture : HostedAgentFixture
{
protected override string ScenarioName => "store-config";
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=mcp-toolbox</c> mode.
/// The container connects to a public MCP server (the Microsoft Learn MCP endpoint) so tests
/// can verify MCP tool discovery and invocation flowing through the Foundry hosted agent.
/// </summary>
public sealed class McpToolboxHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "mcp-toolbox";
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=memory</c> mode.
/// Used by tests that exercise <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
/// running inside the Foundry hosted agent. The memory store name is randomised per fixture
/// instance so concurrent test runs do not share state.
/// </summary>
public sealed class MemoryHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "memory";
/// <summary>
/// Memory store name passed to the test container via <c>IT_MEMORY_STORE_ID</c> so that each
/// fixture instance gets a fresh, isolated bucket of memories.
/// </summary>
public string MemoryStoreId { get; } = $"it-memory-{Guid.NewGuid():N}";
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
environment["IT_MEMORY_STORE_ID"] = this.MemoryStoreId;
}
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=session-files</c> mode.
/// The container exposes three local function tools (<c>GetHomeDirectory</c>, <c>ListFiles</c>,
/// <c>ReadFile</c>) that read from the per-session <c>$HOME</c> sandbox volume — mirroring the
/// <c>Hosted-Files</c> sample. Tests use the alpha
/// <see cref="Azure.AI.Projects.Agents.AgentSessionFiles"/> API to upload a file into the session
/// sandbox, then invoke the agent (pinned to the same <c>agent_session_id</c>) and assert that the
/// agent's tools observed the uploaded file.
/// </summary>
public sealed class SessionFilesHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "session-files";
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling-approval</c> mode.
/// The container declares an AIFunction tagged <c>RequiresApproval=true</c> so tests can exercise
/// the human in the loop approval flow (request, grant, deny).
/// </summary>
public sealed class ToolCallingApprovalHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "tool-calling-approval";
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=tool-calling</c> mode.
/// The container declares one or more deterministic AIFunctions on the server side
/// (e.g. <c>GetUtcNow</c>, <c>Multiply(int,int)</c>) so tests can verify tool invocation behavior
/// without requiring approvals.
/// </summary>
public sealed class ToolCallingHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "tool-calling";
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=toolbox-oauth-consent</c>
/// mode. The container pre-registers a Foundry toolbox (named by <c>IT_TOOLBOX_NAME</c>) whose tool
/// source is fronted by a per-user OAuth connection. The first request that needs the tool must
/// surface an <c>oauth_consent_request</c> instead of running it.
/// </summary>
/// <remarks>
/// Prerequisite (out of band, per project): a Foundry toolbox named by <see cref="ToolboxName"/> must
/// exist in the target project and reference a tool source that returns <c>CONSENT_REQUIRED</c> for an
/// unconsented user (for example a delegated GitHub or Microsoft Graph connection). Override the
/// toolbox name with the <c>IT_TOOLBOX_NAME</c> environment variable. See the project README.
/// </remarks>
public sealed class ToolboxOAuthConsentHostedAgentFixture : HostedAgentFixture
{
private const string ToolboxNameEnvironmentVariable = "IT_TOOLBOX_NAME";
private const string DefaultToolboxName = "auth-paths-oauth-toolbox";
protected override string ScenarioName => "toolbox-oauth-consent";
/// <summary>
/// The Foundry toolbox the container pre-registers. Resolved from <c>IT_TOOLBOX_NAME</c>, falling
/// back to a default that exists in the reference project.
/// </summary>
public string ToolboxName { get; } =
Environment.GetEnvironmentVariable(ToolboxNameEnvironmentVariable) ?? DefaultToolboxName;
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
// Pass the toolbox name into the container so Program.cs wires AddFoundryToolboxes(credential, name).
// IT_TOOLBOX_NAME is a non-reserved key (FOUNDRY_*/AGENT_* are forbidden by the platform).
environment[ToolboxNameEnvironmentVariable] = this.ToolboxName;
}
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a dedicated <c>it-unsupported-protocol</c> agent under the legacy responses protocol
/// <c>1.0.0</c> on purpose. The test container targets protocol <c>2.0.0</c> only, so a request served
/// as <c>1.0.0</c> (no <c>x-agent-foundry-call-id</c> header) must fail fast with a clear <c>501</c>
/// rather than an opaque 500. A dedicated agent name keeps this 1.0.0 deployment isolated from the
/// 2.0.0 scenario agents (notably <c>it-happy-path</c>), whose <c>@latest</c> must stay 2.0.0.
/// See <see cref="UnsupportedProtocolHostedAgentTests"/>.
/// </summary>
public sealed class UnsupportedProtocolHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "unsupported-protocol";
protected override string ResponsesProtocolVersion => "1.0.0";
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!--
Constrained to net10.0: Microsoft.Agents.AI.Foundry.Hosting targets net8/9/10 only
(no net472 — depends on ASP.NET Core), while AgentConformance.IntegrationTests
inherits the default tests TFM list (net10.0;net472). The intersection is net10.0.
-->
<TargetFrameworks>net10.0</TargetFrameworks>
<NoWarn>$(NoWarn);CS8793;NU1605;NU1903;AAIP001</NoWarn>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
<ItemGroup>
<!-- Linked from the Hosted-Files sample so the demo testdata file has a single source of truth. -->
<Content Include="..\..\samples\04-hosting\FoundryHostedAgents\responses\Hosted-Files\resources\contoso_q1_2026_report.txt"
Link="TestData\contoso_q1_2026_report.txt"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Basic round trip, streaming, and container-instruction behaviour for a hosted Responses agent.
/// Store and session semantics live in <see cref="HostedResponsesStoreConfigTests"/>.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class HappyPathHostedAgentTests(HappyPathHostedAgentFixture fixture) : IClassFixture<HappyPathHostedAgentFixture>
{
private readonly HappyPathHostedAgentFixture _fixture = fixture;
[Fact]
public async Task RunAsync_ReturnsNonEmptyTextAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Reply with a short greeting.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
[Fact]
public async Task RunStreamingAsync_YieldsAtLeastOneUpdateAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var collected = new System.Collections.Generic.List<string>();
await foreach (var update in agent.RunStreamingAsync("Reply with a short greeting."))
{
if (!string.IsNullOrEmpty(update.Text))
{
collected.Add(update.Text);
}
}
// Assert
Assert.NotEmpty(collected);
Assert.False(string.IsNullOrWhiteSpace(string.Concat(collected)));
}
[Fact]
public async Task Instructions_FromContainerDefinition_AreObeyedAsync()
{
// Arrange: the container-side happy-path instructions require every reply to end with the
// marker token CONTAINER-OK. See TestContainer/Program.cs.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Say something useful.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("CONTAINER-OK", response.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,206 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Store and session semantics for a hosted Responses agent: <c>store=true</c> vs <c>store=false</c>,
/// <c>previous_response_id</c> and <c>conversation_id</c> forks, and multi-turn recall. Stored
/// hosted-agent responses are read through the per-agent endpoint client (the project-level client
/// returns 403 <c>session_not_accessible</c>).
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class HostedResponsesStoreConfigTests(HostedResponsesStoreConfigFixture fixture) : IClassFixture<HostedResponsesStoreConfigFixture>
{
private readonly HostedResponsesStoreConfigFixture _fixture = fixture;
[Fact]
public async Task StoredTrue_Default_PersistsResponseInChainAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Reply with the word 'ack'.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
// Stored hosted-agent responses are readable only through the per-agent endpoint client.
var fetched = await this._fixture.AgentOpenAIClient.GetProjectResponsesClient().GetResponseAsync(response.ResponseId!);
Assert.NotNull(fetched.Value);
}
[Fact]
public async Task StoredFalse_Baseline_DoesNotPersistResponseAsync()
{
// Arrange
var agent = this._fixture.Agent;
var options = new ChatClientAgentRunOptions(new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false }
});
// Act
var response = await agent.RunAsync("Reply with the word 'pong'.", options: options);
// Assert: response returned but the response id is not retrievable from the chain.
Assert.False(string.IsNullOrWhiteSpace(response.Text));
var responseId = response.ResponseId;
Assert.False(string.IsNullOrWhiteSpace(responseId));
// Attempting to fetch the response should fail because nothing was stored. Reads go through
// the per-agent endpoint client (the project-level client returns 403 session_not_accessible).
await Assert.ThrowsAnyAsync<Exception>(() =>
this._fixture.AgentOpenAIClient.GetProjectResponsesClient().GetResponseAsync(responseId));
}
[Fact]
public async Task StoredFalse_WithPreviousResponseId_ReadsForkHistoryAndDoesNotAppendAsync()
{
// Contract: a store=true head establishes a retrievable fork that carries history. store=false
// continuations chained to it via previous_response_id read that history transparently but
// never persist their own response, so any number of store=false turns can reuse the same fork
// without appending to it.
var agent = this._fixture.Agent;
var perAgent = this._fixture.AgentOpenAIClient.GetProjectResponsesClient();
// Head turn: store=true (default). Establishes the fork with a fact and is retrievable.
var head = await agent.RunAsync("Remember the number 73. Acknowledge briefly.");
var headId = head.ResponseId;
Assert.False(string.IsNullOrWhiteSpace(headId));
var fetchedHead = await perAgent.GetResponseAsync(headId!);
Assert.NotNull(fetchedHead.Value);
ChatClientAgentRunOptions ContinuationOnFork() => new(new ChatOptions
{
RawRepresentationFactory = _ => new CreateResponseOptions
{
StoredOutputEnabled = false,
PreviousResponseId = headId,
},
});
// First store=false continuation: reads the fork history (recalls 73) but is not persisted.
var c1 = await agent.RunAsync("What number did I just tell you?", options: ContinuationOnFork());
Assert.Contains("73", c1.Text);
Assert.NotEqual(headId, c1.ResponseId);
await Assert.ThrowsAnyAsync<Exception>(() => perAgent.GetResponseAsync(c1.ResponseId!));
// Second store=false continuation on the SAME fork: still recalls, still does not append.
var c2 = await agent.RunAsync("State that number one more time.", options: ContinuationOnFork());
Assert.Contains("73", c2.Text);
await Assert.ThrowsAnyAsync<Exception>(() => perAgent.GetResponseAsync(c2.ResponseId!));
// The fork head is unchanged and still retrievable after the store=false continuations.
var fetchedHeadAgain = await perAgent.GetResponseAsync(headId!);
Assert.NotNull(fetchedHeadAgain.Value);
}
[Fact]
public async Task StoredFalse_WithConversationId_ReadsHistoryButDoesNotAppendAsync()
{
// Conversation-id analog of the previous_response_id fork contract: a store=true head populates
// the conversation with history; store=false continuations bound to the same conversation read
// that history transparently but never append to it, so the conversation can be reused by any
// number of store=false turns without growing.
var agent = this._fixture.Agent;
var conversationId = await this._fixture.CreateConversationAsync();
try
{
var stored = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
ChatClientAgentRunOptions ContinuationOnConversation() => new(new ChatOptions
{
ConversationId = conversationId,
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false },
});
// Head turn: store=true, populates the conversation with a fact.
await agent.RunAsync("Remember the number 99. Acknowledge briefly.", options: stored);
var afterHeadCount = await this._fixture.CountConversationItemsAsync(conversationId);
Assert.True(afterHeadCount > 0);
// First store=false continuation: reads the conversation history (recalls 99) but does not append.
var c1 = await agent.RunAsync("What number did I just tell you?", options: ContinuationOnConversation());
Assert.Contains("99", c1.Text);
Assert.Equal(afterHeadCount, await this._fixture.CountConversationItemsAsync(conversationId));
// Second store=false continuation on the SAME conversation: still recalls, still does not append.
var c2 = await agent.RunAsync("State that number one more time.", options: ContinuationOnConversation());
Assert.Contains("99", c2.Text);
Assert.Equal(afterHeadCount, await this._fixture.CountConversationItemsAsync(conversationId));
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
[Fact]
public async Task MultiTurn_WithPreviousResponseId_PreservesContextAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act
var first = await agent.RunAsync("My favorite number is 42. Acknowledge briefly.", session);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
var second = await agent.RunAsync("What number did I just tell you?", session);
// Assert
Assert.Contains("42", second.Text);
}
[Fact]
public async Task MultiTurn_WithPreviousResponseId_RecoversAcrossThreeTurnsAsync()
{
// Arrange: recover the conversation across three turns purely via the previous_response_id
// chain (store=true). Each turn must land on the same hosted MAF session so earlier facts hold.
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act
var t1 = await agent.RunAsync("Remember two facts: my dog is named Rex and I live in Lisbon. Acknowledge briefly.", session);
Assert.False(string.IsNullOrWhiteSpace(t1.Text));
var t2 = await agent.RunAsync("What is my dog's name?", session);
var t3 = await agent.RunAsync("Which city do I live in?", session);
// Assert: both facts survive across the chain.
Assert.Contains("Rex", t2.Text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Lisbon", t3.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task MultiTurn_WithConversationId_PreservesContextAsync()
{
// Arrange
var agent = this._fixture.Agent;
var conversationId = await this._fixture.CreateConversationAsync();
try
{
var options = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
// Act
var first = await agent.RunAsync("My favorite color is teal. Acknowledge briefly.", options: options);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
var second = await agent.RunAsync("What color did I just tell you?", options: options);
// Assert
Assert.Contains("teal", second.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Extensions.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests for an MCP backed toolbox: the hosted container connects to a public MCP server
/// (the Microsoft Learn MCP endpoint) at startup and exposes its tools to the model.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class McpToolboxHostedAgentTests(McpToolboxHostedAgentFixture fixture)
: IClassFixture<McpToolboxHostedAgentFixture>
{
private readonly McpToolboxHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task McpTool_IsInvokedSuccessfullyAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Use the Microsoft Learn MCP tool to look up 'Azure AI Foundry'. Reply with one short paragraph.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.True(response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any()),
"Expected at least one MCP tool invocation in the response messages.");
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task McpTool_WithStructuredArguments_ReturnsValidResultAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Use the MCP search tool with the query 'agent framework hosted agents'. Reply with at least one fact.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task McpTool_ProducesUsableResponseAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Tell me one thing about Microsoft Foundry that would only be in MS Learn docs.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
}
}
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
#pragma warning disable OPENAI001 // Experimental Responses API surfaces
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Validates the Hosted-MemoryAgent end-to-end against a deployed test container running the
/// <c>IT_SCENARIO=memory</c> scenario. Asserts that <see cref="Microsoft.Agents.AI.Foundry.FoundryMemoryProvider"/>
/// scoped via <see cref="Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext"/> recalls user
/// preferences across multiple turns of a conversation.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class MemoryHostedAgentTests(MemoryHostedAgentFixture fixture) : IClassFixture<MemoryHostedAgentFixture>
{
private readonly MemoryHostedAgentFixture _fixture = fixture;
[Fact]
public async Task Memory_RecallsAcrossTurnsAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act: teach the agent two pieces of information about the user.
var first = await agent.RunAsync("My name is Taylor and I am planning a hiking trip to Patagonia in November.", session);
Assert.False(string.IsNullOrWhiteSpace(first.Text));
var second = await agent.RunAsync("I am travelling with my sister and we love finding scenic viewpoints.", session);
Assert.False(string.IsNullOrWhiteSpace(second.Text));
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side ingestion
// typically completes within ~3 seconds; allow a small margin.
await Task.Delay(TimeSpan.FromSeconds(5));
var recall = await agent.RunAsync("What do you already know about my upcoming trip?", session);
// Assert
Assert.Contains("Patagonia", recall.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact(Skip = "Foundry Memory write propagation is eventually consistent and the in-container WhenUpdatesCompletedAsync flush hook is not callable from the test process; this scenario is exercised manually via the sample's smoke.ps1.")]
public async Task Memory_PersistsAcrossSessionsForSameUserAsync()
{
// Arrange: drive a session that establishes some user-private memory. Foundry Memory
// extracts memories more reliably from multi-turn conversations than from a single
// imperative utterance, so mirror the sample's two-turn teaching pattern.
var agent = this._fixture.Agent;
var teachingSession = await agent.CreateSessionAsync();
await agent.RunAsync("My preferred airline is Iberia and I always fly business class.", teachingSession);
await agent.RunAsync("I also prefer aisle seats whenever they are available.", teachingSession);
// FoundryMemoryProvider defaults to UpdateDelay=0 (immediate trigger). Server-side
// ingestion typically completes within ~3 seconds; poll a fresh-session recall a few
// times before failing so the test does not flake on cold caches.
AgentResponse recall = null!;
const int MaxAttempts = 6;
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
await Task.Delay(TimeSpan.FromSeconds(5));
var freshSession = await agent.CreateSessionAsync();
recall = await agent.RunAsync("Which airline do I prefer? Reply with just the airline name.", freshSession);
if (recall.Text.Contains("Iberia", StringComparison.OrdinalIgnoreCase))
{
break;
}
}
// Assert
Assert.Contains("Iberia", recall.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,224 @@
# Foundry.Hosting.IntegrationTests
Integration tests for `Microsoft.Agents.AI.Foundry.Hosting` against real Foundry hosted agents.
## How it works
Each test class is bound to a scenario fixture (e.g. `HappyPathHostedAgentFixture`,
`ToolCallingHostedAgentFixture`). On `InitializeAsync` the fixture:
1. Reads `AZURE_AI_PROJECT_ENDPOINT` and `IT_HOSTED_AGENT_IMAGE` from the environment.
2. Targets a stable, scenario keyed agent name (e.g. `it-happy-path`). The agent is
provisioned out of band by `scripts/it-bootstrap-agents.ps1`; tests only manage versions.
3. Calls `AgentAdministrationClient.CreateAgentVersionAsync` with a `HostedAgentDefinition`
that points at the image, sets `IT_SCENARIO=<scenario>` in the container env vars, and
adds a per-run `IT_RUN_ID` so each run gets a fresh content-addressed version (Foundry
deduplicates versions by definition hash).
4. Polls until the agent reports `AgentVersionStatus.Active` (timeout: 5 minutes).
5. Patches the agent endpoint with `AgentEndpointConfig` (Responses protocol, version
selector pointing 100% at the new version).
6. Builds a per-agent `ProjectOpenAIClient` with `AgentName` set on the options (this
selects the `/agents/{name}/endpoint/protocols/openai` URL suffix; the cached
`projectClient.ProjectOpenAIClient` cannot serve a hosted agent), wraps the
`ProjectResponsesClient` as an `AIAgent`, and exposes it via `Agent`.
On `DisposeAsync` only the version created by this fixture is deleted. The agent itself
is intentionally never deleted, because its managed identity must hold the pre-granted
`Azure AI User` role on the project scope for inbound inference to succeed.
The container image is **the same for every scenario**. The `IT_SCENARIO` env var, set on
the agent definition by each fixture, drives a `switch` in the test container's
`Program.cs` to wire up the scenario specific behavior (tools, toolbox, custom storage,
etc.).
## Required environment variables
| Variable | Source | Purpose |
| --- | --- | --- |
| `AZURE_AI_PROJECT_ENDPOINT` | Foundry project | Where to provision the agent. Must be in a region that has the Hosted Agents preview enabled (e.g. East US 2). |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Foundry project | Model the agent uses. Defaults to `gpt-4o` inside the container. |
| `IT_HOSTED_AGENT_IMAGE` | `scripts/it-build-image.ps1` | ACR image reference the agent points at. |
| `AZURE_SEARCH_ENDPOINT` | Pre-provisioned Azure AI Search service | Endpoint for the `azure-search-rag` scenario. The index it points at must already exist with the schema and content described under **Azure AI Search index prerequisite** below. |
| `AZURE_SEARCH_INDEX_NAME` | Pre-provisioned Azure AI Search service | Name of the pre-seeded index for the `azure-search-rag` scenario. |
## One-time bootstrap (per Foundry project)
Hosted agent invocation requires the agent's own managed identity to hold the
`Azure AI User` role on the project scope. Because each agent's MI is created when the
agent is first provisioned (and recycled on agent delete), the bootstrap creates the
eleven stable scenario agents once and grants the role to each MI. The fixture then only
manages versions under those existing agents, so the role grants survive across runs.
```powershell
./scripts/it-bootstrap-agents.ps1 `
-ProjectEndpoint "https://<account>.services.ai.azure.com/api/projects/<project>" `
-Image "<acr>.azurecr.io/foundry-hosting-it:<tag>"
```
The script is idempotent. It requires Owner or User Access Administrator on the project
scope (RBAC writes). Wait ~3 minutes after first-time grants for AAD propagation before
running the tests.
### Per-scenario data-plane RBAC (manual, one time per agent)
The bootstrap script grants only `Azure AI User` on the Foundry project scope, which is what
every hosted agent needs to receive inbound inference traffic. Scenarios that read from
external data services need an additional grant on that service to the agent's managed
identity. Today only the `azure-search-rag` scenario falls into this category.
For `it-azure-search-rag`, after the first bootstrap run, grant `Search Index Data Reader`
on the Azure AI Search service to the agent's managed identity:
```powershell
# 1. Get the agent MI principal id
$tok = az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv
$agent = Invoke-RestMethod `
-Headers @{Authorization="Bearer $tok"; "Foundry-Features"="HostedAgents=V1Preview"} `
-Uri "<project-endpoint>/agents/it-azure-search-rag?api-version=v1"
$mi = $agent.versions.latest.instance_identity.principal_id
# 2. Grant Search Index Data Reader on the search service
az role assignment create `
--assignee-object-id $mi `
--assignee-principal-type ServicePrincipal `
--role "Search Index Data Reader" `
--scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Search/searchServices/<search-service>"
```
Wait ~3 minutes after the grant for RBAC propagation before running the tests.
If the search service has `authOptions = apiKeyOnly` (default for older deployments), Entra
auth will return 403 regardless of role assignments. Flip it to `aadOrApiKey` first:
```powershell
az search service update -g <rg> -n <search-service> --auth-options aadOrApiKey --aad-auth-failure-mode http403
```
### Azure AI Search index prerequisite (one time, out of band)
The `azure-search-rag` scenario assumes the index pointed at by `AZURE_SEARCH_INDEX_NAME` already
exists with the schema and Contoso Outdoors content the test asserts against. See
`dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md` for
the schema and copy-pasteable provisioning snippet. Provisioning the index from your user
identity needs `Search Index Data Contributor` on the search service scope. The search service
itself is treated as pre-existing infrastructure shared with `python-sample-validation.yml`;
no automated provisioning script ships in this repository.
### Required user/SP roles for delegating data-plane grants
To self-serve the `Search Index Data Reader` grant above, you need `User Access Administrator`
(or `Owner`) on the search service scope. To create/seed the index from your own identity, you
need `Search Index Data Contributor`. These are typically granted once per onboarded engineer
and reused for every new IT scenario that needs Search.
### OAuth consent toolbox prerequisite (one time, out of band)
The `toolbox-oauth-consent` scenario assumes a Foundry **toolbox** named by `IT_TOOLBOX_NAME`
(default `auth-paths-oauth-toolbox`) already exists in the target project and references a tool
source fronted by a **per-user OAuth connection** that returns `CONSENT_REQUIRED` for an
unconsented caller (for example a delegated GitHub or Microsoft Graph connection). The test does
not consent on the caller's behalf; it asserts only that the first invocation surfaces an
`oauth_consent_request` consent link to the consumer and that the container stays routable. See
`dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md`
(auth path #4) for how the toolbox/connection is set up. No automated provisioning script ships
for the toolbox; it is treated as pre-existing project configuration.
## Building and pushing the test container image
The test container source lives at `dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer`.
Build and push it with:
```powershell
$env:IT_REGISTRY = "<your-acr>.azurecr.io"
$env:IT_HOSTED_AGENT_IMAGE = (./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Select-String IT_HOSTED_AGENT_IMAGE).Line.Split('=', 2)[1]
```
The script tags the image by content hash of the test container source. If you didn't
change anything since the last build, the push is a no op.
The Foundry project's account MI and project MI both need `AcrPull` on the registry.
## Running the tests locally
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o"
# IT_HOSTED_AGENT_IMAGE was set above.
dotnet test dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj
```
> **Note:** some scenarios are validated and active (for example `happy-path`,
> `store-config`, `tool-calling`, `azure-search-rag`, `session-files`); the remaining
> scenarios stay tagged `[Fact(Skip = ...)]` until they have been exercised end to end
> against a live Foundry deployment. Once a scenario has been exercised and its assertions
> stabilized, remove the Skip annotation on its tests.
All test classes carry `[Trait("Category", "FoundryHostedAgents")]` so the CI workflow can
route them to a separate Foundry project than the rest of the integration tests (see
`.github/workflows/dotnet-build-and-test.yml`).
## CI wiring
The main "Run Integration Tests" step excludes this category. Two extra steps run only on
`ubuntu-latest` for this category, gated on `paths-filter.outputs.foundryHostingChanges`
so they execute only when the project under test, its dependency chain, the test
container, the test fixture, or their tooling changed:
1. **Build and push Foundry Hosted Agents test container** invokes
`scripts/it-build-image.ps1` against `vars.IT_HOSTED_AGENT_REGISTRY`. The image is
rebuilt every IT run; its tag is content-hashed across the test container source AND
its referenced framework projects (`Microsoft.Agents.AI.Foundry.Hosting`,
`Microsoft.Agents.AI.Foundry`, `Microsoft.Agents.AI`, `Microsoft.Agents.AI.Abstractions`),
so unchanged content is a `docker push` no-op while any framework code change forces
a fresh image. The script pipes its `IT_HOSTED_AGENT_IMAGE=<tag>` line into
`$GITHUB_ENV` for the next step.
2. **Run Foundry Hosted Agents Integration Tests** executes only `--filter-trait
"Category=FoundryHostedAgents"` with the env vars below mapped onto the names the
fixture reads. `IT_HOSTED_AGENT_IMAGE` is the value just exported by step 1.
| GitHub env var | Mapped to |
| --- | --- |
| `IT_HOSTED_AGENT_PROJECT_ENDPOINT` | `AZURE_AI_PROJECT_ENDPOINT` |
| `IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME` | `AZURE_AI_MODEL_DEPLOYMENT_NAME` |
| `IT_HOSTED_AGENT_REGISTRY` | (consumed by `it-build-image.ps1`; not passed to tests) |
| `secrets.AZURE_SEARCH_ENDPOINT` | `AZURE_SEARCH_ENDPOINT` (shared with `python-sample-validation.yml`) |
| `secrets.AZURE_SEARCH_INDEX_NAME` | `AZURE_SEARCH_INDEX_NAME` (shared with `python-sample-validation.yml`) |
Like all integration tests in this workflow, the steps run only on `push` and merge-queue
events, never on plain `pull_request`. The path-filter list lives in the `paths-filter`
job in `.github/workflows/dotnet-build-and-test.yml` under `filters.foundryHosting` and
must stay in sync with `$hashedDirs` in `scripts/it-build-image.ps1`.
The CI service principal that backs `secrets.AZURE_CLIENT_ID` needs:
- `Azure AI User` on the hosted-agents Foundry project (to add/delete agent versions).
- `AcrPush` on the registry referenced by `IT_HOSTED_AGENT_REGISTRY` (to push the image).
The Azure AI Search index referenced by `secrets.AZURE_SEARCH_ENDPOINT` and
`secrets.AZURE_SEARCH_INDEX_NAME` is provisioned out of band (shared with
`python-sample-validation.yml`); CI does not need write access to the search service.
The bootstrap script (and one-time `AcrPull` grants for the Foundry project's MIs) is a
human-only operation; CI only adds and deletes versions under existing agents.
## Scenarios
| Fixture | `IT_SCENARIO` | Agent name | What it tests |
| --- | --- | --- | --- |
| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, and container-instruction behaviour. |
| `HostedResponsesStoreConfigFixture` | `store-config` | `it-store-config` | Store/session semantics: `store=true` vs `store=false`, `previous_response_id` and `conversation_id` forks (read history without appending), multi-turn recall. |
| `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. |
| `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. |
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
| `ToolboxOAuthConsentHostedAgentFixture` | `toolbox-oauth-consent` | `it-toolbox-oauth-consent` | Per-user OAuth toolbox consent: pre-registers a consent-gated Foundry toolbox (`IT_TOOLBOX_NAME`), invokes the agent, asserts the consumer captures an `oauth_consent_request` consent link (and the container stays routable, no 424). Requires a consent-gated toolbox in the project (see prerequisite below). |
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
| `MemoryHostedAgentFixture` | `memory` | `it-memory` | `FoundryMemoryProvider` (scoped via `HostedSessionContext`) running inside the hosted agent recalls user preferences across multiple turns; the memory store name is randomised per fixture (`IT_MEMORY_STORE_ID`). |
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
| `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. |
The scenarios marked (placeholder) are already wired into the test container `Program.cs`,
but their assertions stay skipped pending live validation and stabilization of the relevant
`Microsoft.Agents.AI.Foundry.Hosting` API surfaces.
@@ -0,0 +1,241 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable AAIP001 // AgentSessionFiles is experimental
#pragma warning disable OPENAI001 // CreateResponseOptions is experimental
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End-to-end integration test for the Hosted-Files style scenario: a file uploaded by the client
/// via the alpha <see cref="AgentSessionFiles"/> SDK is read by the deployed hosted agent's
/// container-side <c>ReadFile</c> tool and surfaces in <see cref="AIAgent.RunAsync(string, AgentSession, AgentRunOptions, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// <para>
/// Routing both invocations to the same per-session container requires two clients on the same
/// agent-scoped <see cref="ProjectOpenAIClient"/>: a <see cref="ProjectConversationsClient"/> to
/// pre-create a conversation bound to the agent endpoint, and a <see cref="ProjectResponsesClient"/>
/// for invocation. The session id resolved by the platform on the first call is captured from the
/// <c>x-agent-session-id</c> response header and used to target the
/// <see cref="AgentSessionFiles"/> upload at the same session's <c>$HOME</c>. The second call
/// carries the same conversation_id so it lands in the same container and the agent's
/// <c>ReadFile</c> tool sees the upload.
/// </para>
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class SessionFilesHostedAgentTests(SessionFilesHostedAgentFixture fixture) : IClassFixture<SessionFilesHostedAgentFixture>
{
private const string FoundryFeaturesHeader = "Foundry-Features";
private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview,AgentEndpoints=V1Preview";
private const string SessionIdHeader = "x-agent-session-id";
private const string TestDataFileName = "contoso_q1_2026_report.txt";
/// <summary>Token that appears verbatim in the test data file. Proof the agent read what we uploaded.</summary>
private const string ExpectedTokenInFile = "1,482.6";
private readonly SessionFilesHostedAgentFixture _fixture = fixture;
[Fact]
public async Task UploadedFile_IsReadByHostedAgentAsync()
{
// Arrange
string localPath = Path.Combine(AppContext.BaseDirectory, "TestData", TestDataFileName);
Assert.True(
File.Exists(localPath),
$"Test data file not found at '{localPath}'. Confirm the linked Content entry in the csproj.");
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
var credential = TestAzureCliCredentials.CreateAzureCliCredential();
// Admin client + AgentSessionFiles for upload/list/delete (alpha SDK).
var adminOptions = new AgentAdministrationClientOptions();
adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
var adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
// Build the per-agent OpenAI client. The conversation is created on this client so it is
// bound to the agent endpoint URL (`/agents/{name}/endpoint/protocols/openai/conversations`).
// A header-capture policy reads the `x-agent-session-id` the platform stamps on every reply.
var headerCapture = new ResponseHeaderCapturePolicy(SessionIdHeader);
var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this._fixture.AgentName };
openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
openAIOptions.AddPolicy(headerCapture, PipelinePosition.PerCall);
var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
var conversations = openAIClient.GetProjectConversationsClient();
var responses = openAIClient.GetProjectResponsesClient();
// Step 1 — create a conversation bound to the agent endpoint. Subsequent /responses calls
// tagged with this conversation_id route to the same per-session container.
var conversation = await conversations.CreateProjectConversationAsync();
string conversationId = conversation.Value.Id;
try
{
// Step 2 — warm-up call. Provisions the per-session container under the conversation and
// lets us read back the resolved agent_session_id from the response header.
var agent = responses.AsIChatClient().AsAIAgent(name: this._fixture.AgentName);
var convOptions = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
var warmup = await agent.RunAsync(
"Reply with the single word 'ready' and nothing else.",
options: convOptions);
Assert.False(string.IsNullOrWhiteSpace(warmup.Text));
string agentSessionId = headerCapture.LastValue
?? throw new InvalidOperationException(
$"Expected '{SessionIdHeader}' response header on warm-up but got none.");
// AgentSessionFiles is scoped to the (agent, session) pair at creation time.
var sessionFiles = adminClient.GetAgentSessionFiles(this._fixture.AgentName, agentSessionId);
try
{
// Step 3 — upload the file via the alpha AgentSessionFiles SDK to that exact session's $HOME.
SessionFileWriteResponse writeResponse = await sessionFiles.UploadAsync(
sessionStoragePath: TestDataFileName,
localPath: localPath);
long expectedBytes = new FileInfo(localPath).Length;
Assert.Equal(expectedBytes, writeResponse.BytesWritten);
bool foundEntry = false;
await foreach (SessionDirectoryEntry entry in sessionFiles.GetAllAsync(
sessionStoragePath: "."))
{
if (entry.Name == TestDataFileName && !entry.IsDirectory && entry.SizeInBytes == expectedBytes)
{
foundEntry = true;
break;
}
}
Assert.True(
foundEntry,
$"Expected session directory listing to contain '{TestDataFileName}' ({expectedBytes} bytes) as a file.");
// Step 4 — invoke the agent again on the SAME conversation. The platform routes back to
// the same agent_session_id container, so the agent's ReadFile tool sees the upload.
// The platform mutates session/conversation revision when AgentSessionFiles uploads land,
// so an immediate /responses follow-up races and 400's with "modified concurrently. Please
// retry." — the response message literally tells us to retry. Bounded retry handles it.
var readOptions = new CreateResponseOptions { AgentConversationId = conversationId };
readOptions.InputItems.Add(ResponseItem.CreateUserMessageItem(
$"Read {TestDataFileName} from $HOME and quote the headline total revenue figure verbatim, no commentary."));
ClientResult<ResponseResult> rawResponse = null!;
const int MaxAttempts = 5;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
rawResponse = await responses.CreateResponseAsync(readOptions);
break;
}
catch (ClientResultException ex) when (
ex.Status == 400 &&
ex.Message.Contains("modified concurrently", StringComparison.OrdinalIgnoreCase) &&
attempt < MaxAttempts)
{
await Task.Delay(TimeSpan.FromSeconds(2 * attempt));
}
}
string responseText = rawResponse.Value.GetOutputText() ?? string.Empty;
Assert.Equal(agentSessionId, headerCapture.LastValue);
// Assert: the response contains the deterministic token from the file.
Assert.False(string.IsNullOrWhiteSpace(responseText));
Assert.Contains(ExpectedTokenInFile, responseText);
}
finally
{
// Best-effort cleanup of the uploaded file. The session itself is left for TTL expiry —
// the platform owns its lifecycle (no isolation key in our hands).
try
{
await sessionFiles.DeleteAsync(TestDataFileName);
}
catch
{
// Ignore.
}
}
}
finally
{
await this._fixture.DeleteConversationAsync(conversationId);
}
}
/// <summary>
/// Captures a response header value on every pipeline call. Latest value is read after the
/// response completes. Used to grab the platform's <c>x-agent-session-id</c> stamp.
/// </summary>
private sealed class ResponseHeaderCapturePolicy(string headerName) : PipelinePolicy
{
private readonly string _headerName = headerName;
private string? _lastValue;
public string? LastValue => Volatile.Read(ref this._lastValue);
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
ProcessNext(message, pipeline, currentIndex);
this.Capture(message);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
this.Capture(message);
}
private void Capture(PipelineMessage message)
{
if (message.Response is not null &&
message.Response.Headers.TryGetValue(this._headerName, out var value) &&
!string.IsNullOrEmpty(value))
{
Volatile.Write(ref this._lastValue, value);
}
}
}
private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
ProcessNext(message, pipeline, currentIndex);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
this.SetHeader(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
private void SetHeader(PipelineMessage message)
{
message.Request.Headers.Remove(FoundryFeaturesHeader);
message.Request.Headers.Add(FoundryFeaturesHeader, features);
}
}
}
@@ -0,0 +1,86 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Extensions.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests for the human in the loop tool approval flow: the container declares an AIFunction
/// flagged as requiring approval, and the model raises a <see cref="ToolApprovalRequestContent"/>
/// before the tool executes.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class ToolCallingApprovalHostedAgentTests(ToolCallingApprovalHostedAgentFixture fixture)
: IClassFixture<ToolCallingApprovalHostedAgentFixture>
{
private readonly ToolCallingApprovalHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ApprovalRequiredTool_RaisesApprovalRequestAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Run the SendEmail tool with subject='hi' to test@example.com.");
// Assert
var approvalRequest = response.Messages
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
.FirstOrDefault();
Assert.NotNull(approvalRequest);
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ApprovalGranted_ToolRunsAndResponseReflectsResultAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
var first = await agent.RunAsync("Run the SendEmail tool with subject='ok' to test@example.com.", session);
var approvalRequest = first.Messages
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
.First();
var approvalResponse = approvalRequest.CreateResponse(approved: true);
var followUp = new ChatMessage(ChatRole.User, [approvalResponse]);
// Act
var second = await agent.RunAsync([followUp], session);
// Assert: model received the tool result and produced a final response.
Assert.False(string.IsNullOrWhiteSpace(second.Text));
var hasFurtherApprovalRequest = second.Messages
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
.Any();
Assert.False(hasFurtherApprovalRequest, "Did not expect another approval request after granting.");
}
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
public async Task ApprovalDenied_ToolDoesNotRunAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
var first = await agent.RunAsync("Run the SendEmail tool with subject='no' to test@example.com.", session);
var approvalRequest = first.Messages
.SelectMany(m => m.Contents.OfType<ToolApprovalRequestContent>())
.First();
var approvalResponse = approvalRequest.CreateResponse(approved: false);
var followUp = new ChatMessage(ChatRole.User, [approvalResponse]);
// Act
var second = await agent.RunAsync([followUp], session);
// Assert: no FunctionResultContent for SendEmail in the response.
Assert.False(string.IsNullOrWhiteSpace(second.Text));
var sendEmailResults = second.Messages
.SelectMany(m => m.Contents.OfType<FunctionResultContent>())
.Where(r => r.CallId == approvalRequest.ToolCall?.CallId);
Assert.Empty(sendEmailResults);
}
}
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Extensions.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Tests that exercise server side tool invocation by a hosted agent. The container
/// declares deterministic AIFunctions (e.g. <c>GetUtcNow</c>, <c>Multiply</c>) and the
/// model decides whether to call them based on the prompt.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class ToolCallingHostedAgentTests(ToolCallingHostedAgentFixture fixture) : IClassFixture<ToolCallingHostedAgentFixture>
{
private readonly ToolCallingHostedAgentFixture _fixture = fixture;
[Fact]
public async Task ServerSideTool_IsInvokedWhenPromptedAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("What is the current UTC date and time? Use the GetUtcNow tool.");
// Assert: response references a timestamp (very loose check; deterministic-ish).
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.True(response.Messages.Any(m => m.Contents.OfType<FunctionCallContent>().Any()),
"Expected at least one FunctionCallContent in the response messages.");
}
[Fact]
public async Task ServerSideTool_NotInvokedWhenNotNeededAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Say hello in one word.");
// Assert: no tool call expected for a simple greeting.
Assert.False(string.IsNullOrWhiteSpace(response.Text));
var toolCallCount = response.Messages.SelectMany(m => m.Contents.OfType<FunctionCallContent>()).Count();
Assert.Equal(0, toolCallCount);
}
[Fact]
public async Task ServerSideTool_MultiTurn_RemembersPriorToolResultAsync()
{
// Arrange
var agent = this._fixture.Agent;
var session = await agent.CreateSessionAsync();
// Act
var first = await agent.RunAsync("Multiply 6 by 7 using the Multiply tool. Reply with the result.", session);
Assert.Contains("42", first.Text);
var second = await agent.RunAsync("What was the result of the last multiplication?", session);
// Assert
Assert.Contains("42", second.Text);
}
[Fact]
public async Task ServerSideTool_WithArguments_ReturnsExpectedResultAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync("Use the Multiply tool with a=12 and b=11. Reply with just the numeric result.");
// Assert
Assert.Contains("132", response.Text);
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End-to-end test for the per-user OAuth toolbox consent flow. The hosted container pre-registers a
/// Foundry toolbox whose tool source needs per-user OAuth consent; invoking the agent must surface an
/// <c>oauth_consent_request</c> (carrying a consent link) to the consumer instead of silently running
/// without the tool, and the container must stay routable (no 424) despite the consent-gated toolbox.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class ToolboxOAuthConsentHostedAgentTests(ToolboxOAuthConsentHostedAgentFixture fixture)
: IClassFixture<ToolboxOAuthConsentHostedAgentFixture>
{
private readonly ToolboxOAuthConsentHostedAgentFixture _fixture = fixture;
[Fact(Skip = "Pending TestContainer build, a consent-gated toolbox in the IT project, and end to end smoke (step 5).")]
public async Task ToolRequiringConsent_SurfacesOAuthConsentRequestToConsumerAsync()
{
// Arrange: the agent is backed by a pre-registered toolbox whose tool source requires
// per-user OAuth consent (the fixture provisioned it, the container stayed routable).
var agent = this._fixture.Agent;
// Act: ask for something that needs the OAuth-protected tool. The toolbox proxy returns
// CONSENT_REQUIRED for the (unconsented) caller, which the hosted agent surfaces as an
// oauth_consent_request output item and marks the response incomplete.
var response = await agent.RunAsync(
"Use the OAuth-protected tool to act on my behalf. List my pull requests.");
// Assert: the consumer captured an oauth_consent_request carrying a usable https consent link.
// The high-level client exposes the (non-OpenAI) consent item as an AIContent whose
// RawRepresentation serializes to the oauth_consent_request wire shape, mirroring how the
// Hosted-Toolbox-AuthPaths REPL client detects it.
var consentLink = response.Messages
.SelectMany(m => m.Contents)
.Select(c => TryGetConsentLink(c.RawRepresentation))
.FirstOrDefault(link => link is not null);
Assert.False(string.IsNullOrWhiteSpace(consentLink),
"Expected the response to surface an oauth_consent_request with a consent link.");
Assert.StartsWith("https://", consentLink, StringComparison.OrdinalIgnoreCase);
}
private static string? TryGetConsentLink(object? raw)
{
if (raw is null)
{
return null;
}
try
{
BinaryData json = ModelReaderWriter.Write(raw, new ModelReaderWriterOptions("J"));
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("type", out JsonElement typeProp)
&& typeProp.GetString() == "oauth_consent_request"
&& root.TryGetProperty("consent_link", out JsonElement linkProp)
&& linkProp.GetString() is string link
&& !string.IsNullOrWhiteSpace(link))
{
return link;
}
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or NotSupportedException or FormatException)
{
// Not a persistable model, or no consent link present — treat as no consent.
}
return null;
}
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// Provokes the protocol-compatibility failure on purpose: the test container (which targets responses
/// protocol <c>2.0.0</c> only) is deployed under responses protocol <c>1.0.0</c>. Invoking it must
/// surface the clear <c>501</c> error the container emits (see <c>HostedProtocolCompatibility</c>),
/// rather than an opaque 500.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class UnsupportedProtocolHostedAgentTests(UnsupportedProtocolHostedAgentFixture fixture) : IClassFixture<UnsupportedProtocolHostedAgentFixture>
{
private readonly UnsupportedProtocolHostedAgentFixture _fixture = fixture;
[Fact]
public async Task RunAsync_OnProtocol1_0_0_FailsWith501UnsupportedProtocolAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: a 1.0.0 deployment sends no x-agent-foundry-call-id header, so the container detects the
// unsupported protocol and fails fast. The OpenAI responses client surfaces the container's HTTP
// error as a ClientResultException.
var ex = await Assert.ThrowsAnyAsync<Exception>(() => agent.RunAsync("Reply with a short greeting."));
// Assert: the failure is the deliberate 501, not an opaque 500, and the body names the required
// protocol so the operator knows the fix.
var clientError = FindClientResultException(ex);
Assert.NotNull(clientError);
Assert.Equal(501, clientError!.Status);
Assert.Contains("2.0.0", clientError.Message, StringComparison.OrdinalIgnoreCase);
}
private static ClientResultException? FindClientResultException(Exception? ex)
{
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is ClientResultException clientResultException)
{
return clientResultException;
}
}
return null;
}
}
@@ -0,0 +1,175 @@
#requires -Version 7.0
<#
.SYNOPSIS
One-time bootstrap of stable hosted agents for the Foundry.Hosting.IntegrationTests suite.
.DESCRIPTION
The IT fixture targets stable, scenario-keyed agent names (e.g. it-happy-path) and only
manages versions on each test run. The agent itself must already exist AND its managed
identity must hold the Foundry User role on the project scope, otherwise inbound
inference calls fail with HTTP 500 PermissionDenied.
This script idempotently creates each scenario agent (with a placeholder version) and
grants Foundry User on the project to its managed identity. Re-run it safely; existing
agents and role assignments are left in place.
.PARAMETER ProjectEndpoint
Foundry project endpoint, e.g. https://<account>.services.ai.azure.com/api/projects/<project>
.PARAMETER Image
Container image reference for the placeholder version (e.g. <acr>.azurecr.io/foundry-hosting-it:<tag>).
Use the value emitted by scripts/it-build-image.ps1.
.NOTES
Per-scenario data-plane RBAC (e.g. `Search Index Data Reader` on the Azure AI Search service
for the `azure-search-rag` scenario) is intentionally NOT performed by this script. Search,
Cosmos, and other backing services are treated as pre-existing infrastructure. Grant the
scenario-specific data role to the agent's managed identity manually after the first run
(see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md).
.EXAMPLE
./it-bootstrap-agents.ps1 `
-ProjectEndpoint "https://my-acct.services.ai.azure.com/api/projects/my-proj" `
-Image "myacr.azurecr.io/foundry-hosting-it:abc123"
#>
param(
[Parameter(Mandatory)] [string] $ProjectEndpoint,
[Parameter(Mandatory)] [string] $Image
)
$ErrorActionPreference = 'Stop'
$Scenarios = @(
'happy-path',
'store-config',
'tool-calling',
'tool-calling-approval',
'mcp-toolbox',
'toolbox-oauth-consent',
'custom-storage',
'memory',
'azure-search-rag',
'session-files',
'agent-skills',
'unsupported-protocol'
)
# Resolve project ARM scope from the endpoint.
$endpointUri = [Uri]$ProjectEndpoint
$accountName = $endpointUri.Host.Split('.')[0]
$projectName = ($endpointUri.AbsolutePath.TrimEnd('/') -split '/')[-1]
$accountInfo = az cognitiveservices account list --query "[?name=='$accountName'].{name:name, rg:resourceGroup, sub:id}" | ConvertFrom-Json
if (-not $accountInfo) { throw "Could not find Cognitive Services account '$accountName'." }
$rg = $accountInfo[0].rg
$sub = ($accountInfo[0].sub -split '/')[2]
$projectScope = "/subscriptions/$sub/resourceGroups/$rg/providers/Microsoft.CognitiveServices/accounts/$accountName/projects/$projectName"
Write-Host "Project scope: $projectScope"
$tok = az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv
$headers = @{
Authorization = "Bearer $tok"
'Foundry-Features' = 'HostedAgents=V1Preview'
'Content-Type' = 'application/json'
}
foreach ($scenario in $Scenarios) {
$agentName = "it-$scenario"
Write-Host ""
Write-Host "=== $agentName ==="
# 1. Ensure the agent exists. Create a placeholder version if it doesn't.
$agent = $null
try {
$agent = Invoke-RestMethod -Method GET -Headers $headers `
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1"
Write-Host " agent exists"
} catch {
if ($_.Exception.Response.StatusCode -ne 404) { throw }
}
if (-not $agent) {
Write-Host " creating placeholder version..."
$body = @{
definition = @{
kind = 'hosted'
container_protocol_versions = @(@{ protocol = 'responses'; version = '2.0.0' })
cpu = '0.25'
memory = '0.5Gi'
environment_variables = @{ IT_SCENARIO = $scenario }
image = $Image
}
metadata = @{ enableVnextExperience = 'true' }
} | ConvertTo-Json -Depth 10
Invoke-RestMethod -Method POST -Headers $headers `
-Uri "$ProjectEndpoint/agents/$agentName/versions`?api-version=v1" `
-Body $body | Out-Null
Start-Sleep 5
$agent = Invoke-RestMethod -Method GET -Headers $headers `
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1"
}
$principalId = $agent.versions.latest.instance_identity.principal_id
Write-Host " agent MI: $principalId"
# 2. PATCH the agent endpoint to route via @latest if not already configured.
# Using @latest means each new version added by the IT fixture automatically becomes the
# served version, no per-run PATCH needed (which is good because the strongly-typed
# PATCH wrapper is alpha-only on Azure.AI.Projects right now).
$hasLatestSelector = $agent.agent_endpoint -and `
($agent.agent_endpoint.version_selector.version_selection_rules | Where-Object { $_.agent_version -eq '@latest' })
if ($hasLatestSelector) {
Write-Host " endpoint already routes via @latest"
} else {
Write-Host " patching endpoint to route via @latest..."
$patchBody = @{
agent_endpoint = @{
version_selector = @{
version_selection_rules = @(@{
type = 'FixedRatio'
agent_version = '@latest'
traffic_percentage = 100
})
}
protocols = @('responses')
}
} | ConvertTo-Json -Depth 10
Invoke-RestMethod -Method PATCH -Headers $headers `
-Uri "$ProjectEndpoint/agents/$agentName`?api-version=v1" `
-Body $patchBody | Out-Null
}
# 3. Grant Foundry User on the project scope to the agent MI (idempotent).
$existing = az role assignment list --assignee $principalId --scope $projectScope `
--query "[?roleDefinitionName=='Foundry User']" 2>$null | ConvertFrom-Json
if ($existing) {
Write-Host " role already assigned"
} else {
Write-Host " granting Foundry User..."
$maxAttempts = 12
$granted = $false
for ($i = 1; $i -le $maxAttempts; $i++) {
$output = az role assignment create `
--assignee-object-id $principalId `
--assignee-principal-type ServicePrincipal `
--role 'Foundry User' `
--scope $projectScope 2>&1
if ($LASTEXITCODE -eq 0) {
$granted = $true
break
}
if ($output -match 'Cannot find user or service principal in graph') {
Write-Host " attempt $i/$maxAttempts : MI not yet in AAD graph, retrying in 15s..."
Start-Sleep 15
continue
}
throw "az role assignment failed: $output"
}
if (-not $granted) {
throw "MI '$principalId' did not appear in AAD graph after $maxAttempts attempts."
}
Write-Host " granted (RBAC propagation may take 1-3 minutes)"
}
}
Write-Host ""
Write-Host "Done. Wait ~3 minutes after first-time grants before running the tests."
@@ -0,0 +1,159 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Builds and pushes the Foundry.Hosting.IntegrationTests.TestContainer image to a container registry.
.DESCRIPTION
The integration tests in dotnet/tests/Foundry.Hosting.IntegrationTests provision real
Foundry hosted agents that point at a container image. This script builds and pushes that
image, then emits the IT_HOSTED_AGENT_IMAGE=... line that the tests read from the
environment.
.PARAMETER Registry
The container registry login server, e.g. mycompany.azurecr.io. Required. There is no
default because every team and every dev may use a different registry.
.PARAMETER Repository
Image repository name within the registry. Defaults to foundry-hosting-it.
.PARAMETER TestContainerProject
Path to the test container csproj. Defaults to the in repo location.
.EXAMPLE
PS> ./scripts/it-build-image.ps1 -Registry mycompany.azurecr.io
IT_HOSTED_AGENT_IMAGE=mycompany.azurecr.io/foundry-hosting-it:abc123def456
.EXAMPLE
Local dev, set the env var directly:
PS> $env:IT_REGISTRY = "mycompany.azurecr.io"
PS> $env:IT_HOSTED_AGENT_IMAGE = (./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Select-String IT_HOSTED_AGENT_IMAGE).Line.Split('=', 2)[1]
.EXAMPLE
CI workflow, assumes IT_REGISTRY is set in the environment:
- name: Build IT image
run: pwsh ./scripts/it-build-image.ps1 -Registry $env:IT_REGISTRY | Tee-Object -FilePath $env:GITHUB_ENV
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Registry,
[string] $Repository = "foundry-hosting-it",
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
)
$ErrorActionPreference = "Stop"
# Resolve to the repo root regardless of the caller's PWD so all relative paths used below
# (TestContainerProject, the framework src dirs hashed for the image tag) resolve correctly.
# This script lives at <repoRoot>/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/.
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../../../..")).Path
Push-Location $RepoRoot
try {
if (-not (Test-Path $TestContainerProject)) {
throw "Test container project not found at '$TestContainerProject' (repo root '$RepoRoot')."
}
# Strip any scheme/trailing slash from the registry, then derive the ACR short name.
$Registry = $Registry -replace '^https?://', '' -replace '/+$', ''
$registryHost = $Registry.Split('.')[0]
if ([string]::IsNullOrWhiteSpace($registryHost)) {
throw "Could not derive ACR short name from -Registry '$Registry'."
}
# Hash the test container source content AND the source of all referenced framework projects
# so any edit (in TestContainer OR in dotnet/src/Microsoft.Agents.AI.Foundry*/) produces a new
# tag. The TestContainer image embeds compiled output of those projects, so a framework code
# change must invalidate the tag for `docker push` to publish a new layer; a TestContainer-only
# hash silently reused stale images on framework edits.
#
# Keep this list in sync with the `foundryHosting` paths-filter in
# .github/workflows/dotnet-build-and-test.yml so CI gating and image tagging cover the same set.
$hashedDirs = @(
$TestContainerProject,
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting",
"dotnet/src/Microsoft.Agents.AI.Foundry",
"dotnet/src/Microsoft.Agents.AI",
"dotnet/src/Microsoft.Agents.AI.Abstractions",
"dotnet/src/Microsoft.Agents.AI.Workflows"
)
$sourceFiles = @()
foreach ($dir in $hashedDirs) {
if (Test-Path $dir) {
$sourceFiles += @(git -c core.quotepath=false ls-files -- $dir)
}
}
if ($sourceFiles.Count -eq 0) {
throw "No tracked files found under any of: $($hashedDirs -join ', ')"
}
$fileHashes = git hash-object -- $sourceFiles
$shaInput = ($fileHashes -join "`n" | git hash-object --stdin).Trim()
$tag = $shaInput.Substring(0, 12)
$image = "$Registry/$Repository`:$tag"
Write-Host "Publishing $TestContainerProject ..." -ForegroundColor Cyan
$out = Join-Path $TestContainerProject "out"
if (Test-Path $out) {
Remove-Item -Recurse -Force $out
}
# Always tell publish to skip ProjectReference rebuilds via --no-dependencies. Publish
# resolves TestContainer's framework lib references (Foundry, Foundry.Hosting and their
# transitive deps) by reading the prebuilt DLLs at src/<lib>/bin/Release/net10.0/*.dll.
# This:
# 1) Structurally avoids the MSB3026 "file is being used by another process" race that
# occurs when publish overwrites the same DLL paths a prior `dotnet build` produced
# while VBCSCompiler from that build still holds file handles.
# 2) Avoids needlessly rebuilding identical managed (RID-agnostic) library DLLs.
# Callers MUST run `dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release`
# (or equivalent) first so those prebuilt DLLs exist. The CI workflow does this in the
# preceding "Build Foundry hosted IT (and its deps)" step.
$prebuildProbes = @(
"dotnet/src/Microsoft.Agents.AI.Foundry/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.dll",
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.Hosting.dll"
)
$missingPrebuilds = @($prebuildProbes | Where-Object { -not (Test-Path $_) })
if ($missingPrebuilds.Count -gt 0) {
$msg = @(
"Required prebuilt outputs not found:"
($missingPrebuilds | ForEach-Object { " - $_" })
""
"Publish runs with --no-dependencies and consumes prebuilt DLLs in place. Build the"
"test project first so its ProjectReference closure populates src/<lib>/bin/Release/net10.0/:"
" dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release"
) -join "`n"
throw $msg
}
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false --no-dependencies -o $out --tl:off | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
Write-Host "Building $image ..." -ForegroundColor Cyan
docker build -t $image -f (Join-Path $TestContainerProject "Dockerfile") $TestContainerProject | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "docker build failed with exit code $LASTEXITCODE."
}
Write-Host "Pushing $image ..." -ForegroundColor Cyan
az acr login -n $registryHost | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "az acr login failed with exit code $LASTEXITCODE."
}
docker push $image | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "docker push failed with exit code $LASTEXITCODE."
}
# Emit the env var line for shells / CI consumption.
"IT_HOSTED_AGENT_IMAGE=$image"
}
finally {
Pop-Location
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);CS8793</NoWarn>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
</Project>
@@ -0,0 +1,229 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using OpenAI.Files;
using OpenAI.Responses;
using OpenAI.VectorStores;
using Shared.IntegrationTests;
namespace Foundry.IntegrationTests;
/// <summary>
/// Integration tests for the file and vector-store forwarder extensions on
/// <see cref="FoundryAgent"/> declared in <see cref="FoundryAgentExtensions"/>. End-to-end
/// counterparts of the unit tests in
/// <c>FoundryAgentExtensionsTests</c> that exercise the live Foundry project pipeline.
/// </summary>
/// <remarks>
/// Mirrors <see cref="FoundryVersionedAgentCreateTests.CreateAgent_CreatesAgentWithVectorStoresAsync(string)"/>
/// in shape (file upload → vector store creation → FileSearchTool answer → cleanup), but routes
/// every helper call through the new <see cref="FoundryAgent"/> extensions instead of the raw
/// <c>projectOpenAIClient.GetProjectFilesClient()</c> / <c>GetProjectVectorStoresClient()</c>
/// path. Skipped by default for the same reasons as the existing vector-store IT (cost and
/// runtime); flip Skip to run manually after seeding the right Foundry project.
/// </remarks>
public class FoundryAgentExtensionsTests
{
private readonly AIProjectClient _client = new(
new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)),
TestAzureCliCredentials.CreateAzureCliCredential());
[Fact(Skip = "For manual testing only")]
public async Task UploadFileAsync_ViaAgentExtension_UploadsToProjectAsync()
{
// Arrange — non-versioned Responses Agent (Mode 1) so we do not have to provision a server-side agent.
var agent = this._client.AsAIAgent(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: "Be helpful.");
var foundryAgent = this.WrapAsFoundryAgent(agent);
var filePath = Path.GetTempFileName() + ".txt";
File.WriteAllText(filePath, "agent-extensions integration test payload");
OpenAIFile? uploaded = null;
try
{
// Act.
uploaded = await foundryAgent.UploadFileAsync(filePath, FileUploadPurpose.Assistants);
// Assert.
Assert.NotNull(uploaded);
Assert.False(string.IsNullOrEmpty(uploaded.Id));
Assert.Equal(Path.GetFileName(filePath), uploaded.Filename);
}
finally
{
if (uploaded is not null)
{
await foundryAgent.DeleteFileAsync(uploaded.Id);
}
File.Delete(filePath);
}
}
[Fact(Skip = "For manual testing only")]
public async Task DeleteFileAsync_ViaAgentExtension_RemovesUploadedFileAsync()
{
var agent = this._client.AsAIAgent(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: "Be helpful.");
var foundryAgent = this.WrapAsFoundryAgent(agent);
var filePath = Path.GetTempFileName() + ".txt";
File.WriteAllText(filePath, "delete-me payload");
try
{
var uploaded = await foundryAgent.UploadFileAsync(filePath, FileUploadPurpose.Assistants);
// Act.
var result = await foundryAgent.DeleteFileAsync(uploaded.Id);
// Assert.
Assert.NotNull(result);
Assert.Equal(uploaded.Id, result.FileId);
Assert.True(result.Deleted);
}
finally
{
File.Delete(filePath);
}
}
[Fact(Skip = "For manual testing only")]
public async Task CreateVectorStoreAsync_ViaAgentExtension_BuildsStoreAndAnswersFileSearchQuestionAsync()
{
// Mirrors CreateAgent_CreatesAgentWithVectorStoresAsync but the upload-then-create-store
// sequence routes through the FoundryAgent.CreateVectorStoreAsync extension (single call
// that uploads, creates the store, and polls until ready). The resulting vector store id
// is then wired to a versioned agent's FileSearch tool and queried for a known value.
string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("VectorStoreExtAgent");
const string AgentInstructions = """
You are a helpful agent that can help fetch data from files you know about.
Use the File Search Tool to look up codes for words.
Do not answer a question unless you can find the answer using the File Search Tool.
""";
// Non-versioned helper agent that owns the upload pipeline.
var helperAgent = this._client.AsAIAgent(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: "Be helpful.");
var helperFoundryAgent = this.WrapAsFoundryAgent(helperAgent);
var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt";
File.WriteAllText(searchFilePath, "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457.");
VectorStore? vectorStore = null;
FoundryAgent? versionedAgent = null;
try
{
// Act — single agent-level helper call uploads, creates, and waits until ready.
vectorStore = await helperFoundryAgent.CreateVectorStoreAsync(
"WordCodeLookup_ExtensionVectorStore",
new[] { searchFilePath });
Assert.NotNull(vectorStore);
Assert.False(string.IsNullOrEmpty(vectorStore.Id));
Assert.NotEqual(VectorStoreStatus.InProgress, vectorStore.Status);
// Wire the store id into a versioned agent's FileSearch tool to prove it is actually usable.
var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = AgentInstructions,
Tools = { ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStore.Id]) },
};
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
AgentName,
new ProjectsAgentVersionCreationOptions(definition));
versionedAgent = this._client.AsAIAgent(agentVersion);
// Assert.
var result = await versionedAgent.RunAsync("Can you give me the documented code for 'banana'?");
Assert.Contains("673457", result.ToString());
}
finally
{
if (versionedAgent is not null)
{
await this._client.AgentAdministrationClient.DeleteAgentAsync(versionedAgent.Name);
}
// Cleanup the vector store via the new extension too.
if (vectorStore is not null)
{
await helperFoundryAgent.DeleteVectorStoreAsync(vectorStore.Id);
}
File.Delete(searchFilePath);
}
}
[Fact(Skip = "For manual testing only")]
public async Task DeleteVectorStoreAsync_ViaAgentExtension_RemovesStoreAsync()
{
var agent = this._client.AsAIAgent(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: "Be helpful.");
var foundryAgent = this.WrapAsFoundryAgent(agent);
var filePath = Path.GetTempFileName() + ".txt";
File.WriteAllText(filePath, "delete-store payload");
VectorStore? vectorStore = null;
try
{
vectorStore = await foundryAgent.CreateVectorStoreAsync(
"DeleteVectorStore_ExtensionTest",
new[] { filePath });
// Act.
var result = await foundryAgent.DeleteVectorStoreAsync(vectorStore.Id);
// Assert.
Assert.NotNull(result);
Assert.Equal(vectorStore.Id, result.VectorStoreId);
Assert.True(result.Deleted);
vectorStore = null;
}
finally
{
if (vectorStore is not null)
{
await foundryAgent.DeleteVectorStoreAsync(vectorStore.Id);
}
File.Delete(filePath);
}
}
/// <summary>
/// Resolves the underlying <see cref="FoundryAgent"/> from an <see cref="AIAgent"/> handle
/// returned by <c>AIProjectClient.AsAIAgent(model, instructions)</c>. The Mode 1 overload
/// returns a <see cref="ChatClientAgent"/>; the extension forwarders we test live on
/// <see cref="FoundryAgent"/>, so callers wanting them through this entry point need to
/// reach for the FoundryAgent constructor instead. This helper makes the test setup
/// consistent across the four IT scenarios.
/// </summary>
private FoundryAgent WrapAsFoundryAgent(AIAgent agent)
{
// The Mode 1 AsAIAgent overload returns ChatClientAgent rather than FoundryAgent; use
// the FoundryAgent projectEndpoint+model+instructions ctor to get the same underlying
// FoundryChatClient surfaced through a FoundryAgent typed handle.
_ = agent;
return new FoundryAgent(
projectEndpoint: new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)),
credential: TestAzureCliCredentials.CreateAzureCliCredential(),
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: "Be helpful.");
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace Foundry.IntegrationTests;
public class FoundryVersionedAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests<FoundryVersionedAgentFixture>(() => new())
{
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace Foundry.IntegrationTests;
public class FoundryVersionedAgentChatClientRunTests() : ChatClientAgentRunTests<FoundryVersionedAgentFixture>(() => new())
{
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
}
}
@@ -0,0 +1,348 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Extensions.AI;
using OpenAI.Files;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.IntegrationTests;
/// <summary>
/// Integration tests for versioned <see cref="FoundryAgent"/> creation via
/// <c>AIProjectClient.AgentAdministrationClient.CreateAgentVersionAsync</c> and <c>AIProjectClient.AsAIAgent(ProjectsAgentVersion)</c>.
/// </summary>
public class FoundryVersionedAgentCreateTests
{
private readonly AIProjectClient _client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
[Fact]
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync()
{
// Arrange.
string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("IntegrationTestAgent");
const string AgentDescription = "An agent created during integration tests";
const string AgentInstructions = "You are an integration test agent";
// Act.
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
AgentName,
new ProjectsAgentVersionCreationOptions(
new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = AgentInstructions
})
{
Description = AgentDescription
});
var agent = this._client.AsAIAgent(agentVersion);
try
{
// Assert.
Assert.NotNull(agent);
Assert.Equal(AgentName, agent.Name);
Assert.Equal(AgentDescription, agent.Description);
Assert.Equal(AgentInstructions, agent.GetService<ChatClientAgent>()!.Instructions);
var agentRecord = await this._client.AgentAdministrationClient.GetAgentAsync(agent.Name);
Assert.NotNull(agentRecord);
Assert.Equal(AgentName, agentRecord.Value.Name);
var definition = Assert.IsType<DeclarativeAgentDefinition>(agentRecord.Value.GetLatestVersion().Definition);
Assert.Equal(AgentDescription, agentRecord.Value.GetLatestVersion().Description);
Assert.Equal(AgentInstructions, definition.Instructions);
}
finally
{
// Cleanup.
await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
}
}
[Theory(Skip = "For manual testing only")]
[InlineData("FileSearchTool")]
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string _)
{
// Arrange.
string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("VectorStoreAgent");
const string AgentInstructions = """
You are a helpful agent that can help fetch data from files you know about.
Use the File Search Tool to look up codes for words.
Do not answer a question unless you can find the answer using the File Search Tool.
""";
// Get the project OpenAI client.
var projectOpenAIClient = this._client.GetProjectOpenAIClient();
// Create a vector store.
var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt";
File.WriteAllText(
path: searchFilePath,
contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457."
);
OpenAIFile uploadedAgentFile = projectOpenAIClient.GetProjectFilesClient().UploadFile(
filePath: searchFilePath,
purpose: FileUploadPurpose.Assistants
);
var vectorStoreMetadata = await projectOpenAIClient.GetProjectVectorStoresClient().CreateVectorStoreAsync(options: new() { FileIds = { uploadedAgentFile.Id }, Name = "WordCodeLookup_VectorStore" });
// Act — create agent version with FileSearch tool via native SDK, then wrap with AsAIAgent.
var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = AgentInstructions,
Tools = { ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]) }
};
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
AgentName,
new ProjectsAgentVersionCreationOptions(definition));
var agent = this._client.AsAIAgent(agentVersion);
try
{
// Assert.
// Verify that the agent can use the vector store to answer a question.
var result = await agent.RunAsync("Can you give me the documented code for 'banana'?");
Assert.Contains("673457", result.ToString());
}
finally
{
// Cleanup.
await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
await projectOpenAIClient.GetProjectVectorStoresClient().DeleteVectorStoreAsync(vectorStoreMetadata.Value.Id);
await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedAgentFile.Id);
File.Delete(searchFilePath);
}
}
[Fact]
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync()
{
// Arrange.
string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("CodeInterpreterAgent");
const string AgentInstructions = """
You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file
and report the SECRET_NUMBER value it prints. Respond only with the number.
""";
// Get the project OpenAI client.
var projectOpenAIClient = this._client.GetProjectOpenAIClient();
// Create a python file that prints a known value.
var codeFilePath = Path.GetTempFileName() + "secret_number.py";
File.WriteAllText(
path: codeFilePath,
contents: "print(\"SECRET_NUMBER=24601\")" // Deterministic output we will look for.
);
OpenAIFile uploadedCodeFile = projectOpenAIClient.GetProjectFilesClient().UploadFile(
filePath: codeFilePath,
purpose: FileUploadPurpose.Assistants
);
// Act — create agent version with CodeInterpreter tool via native SDK, then wrap with AsAIAgent.
var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = AgentInstructions,
Tools = { ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))) }
};
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
AgentName,
new ProjectsAgentVersionCreationOptions(definition));
var agent = this._client.AsAIAgent(agentVersion);
try
{
// Assert.
var result = await agent.RunAsync("What is the SECRET_NUMBER?");
// We expect the model to run the code and surface the number.
Assert.Contains("24601", result.ToString());
}
finally
{
// Cleanup.
await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedCodeFile.Id);
File.Delete(codeFilePath);
}
}
/// <summary>
/// Validates that an agent version created with an OpenAPI tool definition via the native
/// Azure.AI.Projects SDK and then wrapped with <c>AsAIAgent(agentVersion)</c> correctly
/// invokes the server-side OpenAPI function through <c>RunAsync</c>.
/// Regression test for https://github.com/microsoft/agent-framework/issues/4883.
/// </summary>
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync()
{
// Arrange — create agent version with OpenAPI tool using native Azure.AI.Projects SDK types.
string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("OpenAPITestAgent");
const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code.";
const string CountriesOpenApiSpec = """
{
"openapi": "3.1.0",
"info": {
"title": "REST Countries API",
"description": "Retrieve information about countries by currency code",
"version": "v3.1"
},
"servers": [
{
"url": "https://restcountries.com/v3.1"
}
],
"paths": {
"/currency/{currency}": {
"get": {
"description": "Get countries that use a specific currency code (e.g., USD, EUR, GBP)",
"operationId": "GetCountriesByCurrency",
"parameters": [
{
"name": "currency",
"in": "path",
"description": "Currency code (e.g., USD, EUR, GBP)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response with list of countries",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
},
"404": {
"description": "No countries found for the currency"
}
}
}
}
}
}
""";
// Step 1: Create the OpenAPI function definition and agent version using native SDK types.
var openApiFunction = new OpenApiFunctionDefinition(
"get_countries",
BinaryData.FromString(CountriesOpenApiSpec),
new OpenAPIAnonymousAuthenticationDetails())
{
Description = "Retrieve information about countries by currency code"
};
var definition = new DeclarativeAgentDefinition(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = AgentInstructions,
Tools = { (ResponseTool)ProjectsAgentTool.CreateOpenApiTool(openApiFunction) }
};
ProjectsAgentVersionCreationOptions creationOptions = new(definition);
ProjectsAgentVersion agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(AgentName, creationOptions);
try
{
// Step 2: Wrap the agent version using AsAIAgent extension.
FoundryAgent agent = this._client.AsAIAgent(agentVersion);
// Assert the agent was created correctly and retains version metadata.
Assert.NotNull(agent);
Assert.Equal(AgentName, agent.Name);
var retrievedVersion = agent.GetService<ProjectsAgentVersion>();
Assert.NotNull(retrievedVersion);
// Step 3: Call RunAsync to trigger the server-side OpenAPI function.
var result = await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them.");
// Step 4: Validate the OpenAPI tool was invoked server-side.
// Note: Server-side OpenAPI tools (executed within the Responses API via AgentReference)
// do not surface as FunctionCallContent in the MEAI abstraction — the API handles the full
// tool loop internally. We validate tool invocation by asserting the response contains
// multiple specific country names that the model would need API data to enumerate accurately.
var text = result.ToString();
Assert.NotEmpty(text);
// The response must mention multiple well-known Eurozone countries — requiring several
// correct entries makes it highly unlikely the model answered purely from parametric knowledge.
int matchCount = 0;
foreach (var country in new[] { "Germany", "France", "Italy", "Spain", "Portugal", "Netherlands", "Belgium", "Austria", "Ireland", "Finland" })
{
if (text.Contains(country, StringComparison.OrdinalIgnoreCase))
{
matchCount++;
}
}
Assert.True(
matchCount >= 3,
$"Expected response to list at least 3 Eurozone countries from the OpenAPI tool, but found {matchCount}. Response: {text}");
}
finally
{
// Cleanup.
await this._client.AgentAdministrationClient.DeleteAgentAsync(AgentName);
}
}
[Fact]
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync()
{
// Arrange.
string AgentName = FoundryVersionedAgentFixture.GenerateUniqueAgentName("WeatherAgent");
const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather.";
static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C.";
var weatherFunction = AIFunctionFactory.Create(GetWeather);
// Create agent version with the function tool registered in the server-side definition,
// then wrap with AsAIAgent passing the local AIFunction implementation.
var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = AgentInstructions,
};
definition.Tools.Add(weatherFunction.AsOpenAIResponseTool());
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
AgentName,
new ProjectsAgentVersionCreationOptions(definition));
FoundryAgent agent = this._client.AsAIAgent(agentVersion, tools: [weatherFunction]);
try
{
// Act.
var response = await agent.RunAsync("What is the weather like in Amsterdam?");
// Assert - ensure function was invoked and its output surfaced.
var text = response.Text;
Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
}
}
}
@@ -0,0 +1,242 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.IntegrationTests;
/// <summary>
/// Integration test fixture that creates versioned Foundry agents via
/// <c>AIProjectClient.AgentAdministrationClient.CreateAgentVersionAsync</c> and wraps them
/// with <c>AIProjectClient.AsAIAgent(ProjectsAgentVersion)</c>.
/// </summary>
public class FoundryVersionedAgentFixture : IChatClientAgentFixture
{
private FoundryAgent _agent = null!;
private AIProjectClient _client = null!;
public IChatClient ChatClient => this._agent.GetService<ChatClientAgent>()!.ChatClient;
public AIAgent Agent => this._agent;
public async Task<string> CreateConversationAsync()
{
var response = await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync();
return response.Value.Id;
}
public async Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session)
{
var chatClientSession = (ChatClientAgentSession)session;
if (chatClientSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true)
{
return await this.GetChatHistoryFromConversationAsync(chatClientSession.ConversationId);
}
if (chatClientSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true)
{
return await this.GetChatHistoryFromResponsesChainAsync(chatClientSession.ConversationId);
}
var chatHistoryProvider = agent.GetService<ChatHistoryProvider>();
if (chatHistoryProvider is null)
{
return [];
}
return (await chatHistoryProvider.InvokingAsync(new(agent, session, []))).ToList();
}
private async Task<List<ChatMessage>> GetChatHistoryFromResponsesChainAsync(string conversationId)
{
var openAIResponseClient = this._client.GetProjectOpenAIClient().GetProjectResponsesClient();
var inputItems = await openAIResponseClient.GetResponseInputItemsAsync(conversationId).ToListAsync();
var response = await openAIResponseClient.GetResponseAsync(conversationId);
var responseItem = response.Value.OutputItems.FirstOrDefault()!;
// Take the messages that were the chat history leading up to the current response
// remove the instruction messages, and reverse the order so that the most recent message is last.
var previousMessages = inputItems
.Select(ConvertToChatMessage)
.Where(x => x.Text != "You are a helpful assistant.")
.Reverse();
// Convert the response item to a chat message.
var responseMessage = ConvertToChatMessage(responseItem);
// Concatenate the previous messages with the response message to get a full chat history
// that includes the current response.
return [.. previousMessages, responseMessage];
}
private static ChatMessage ConvertToChatMessage(ResponseItem item)
{
if (item is MessageResponseItem messageResponseItem)
{
var role = messageResponseItem.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant;
return new ChatMessage(role, messageResponseItem.Content.FirstOrDefault()?.Text);
}
throw new NotSupportedException("This test currently only supports text messages");
}
private async Task<List<ChatMessage>> GetChatHistoryFromConversationAsync(string conversationId)
{
List<ChatMessage> messages = [];
await foreach (AgentResponseItem item in this._client.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc"))
{
var openAIItem = item.AsResponseResultItem();
if (openAIItem is MessageResponseItem messageItem)
{
messages.Add(new ChatMessage
{
Role = new ChatRole(messageItem.Role.ToString()),
Contents = messageItem.Content
.Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText)
.Select(c => new TextContent(c.Text))
.ToList<AIContent>()
});
}
}
return messages;
}
public async Task<ChatClientAgent> CreateChatClientAgentAsync(
string name = "HelpfulAssistant",
string instructions = "You are a helpful assistant.",
IList<AITool>? aiTools = null)
{
var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = instructions
};
// Register AIFunction tool definitions in the server-side agent definition so the model
// can invoke them. The local AIFunction implementations are matched by name via AsAIAgent.
if (aiTools is not null)
{
foreach (var tool in aiTools)
{
if (tool.AsOpenAIResponseTool() is ResponseTool responseTool)
{
definition.Tools.Add(responseTool);
}
}
}
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
GenerateUniqueAgentName(name),
new ProjectsAgentVersionCreationOptions(definition));
return this._client.AsAIAgent(agentVersion, tools: aiTools).GetService<ChatClientAgent>()!;
}
public async Task<ChatClientAgent> CreateChatClientAgentAsync(ChatClientAgentOptions options)
{
options.Name ??= GenerateUniqueAgentName("HelpfulAssistant");
var definition = new DeclarativeAgentDefinition(
options.ChatOptions?.ModelId ?? TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = options.ChatOptions?.Instructions
};
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
options.Name,
new ProjectsAgentVersionCreationOptions(definition) { Description = options.Description });
var agent = this._client.AsAIAgent(agentVersion, tools: options.ChatOptions?.Tools);
return agent.GetService<ChatClientAgent>()!;
}
public static string GenerateUniqueAgentName(string baseName) =>
$"{baseName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
public Task DeleteAgentAsync(ChatClientAgent agent) =>
this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
public async Task DeleteSessionAsync(AgentSession session)
{
var typedSession = (ChatClientAgentSession)session;
if (typedSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true)
{
await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(typedSession.ConversationId);
}
else if (typedSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true)
{
await this.DeleteResponseChainAsync(typedSession.ConversationId!);
}
}
private async Task DeleteResponseChainAsync(string lastResponseId)
{
var response = await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().GetResponseAsync(lastResponseId);
await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().DeleteResponseAsync(lastResponseId);
if (response.Value.PreviousResponseId is not null)
{
await this.DeleteResponseChainAsync(response.Value.PreviousResponseId);
}
}
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
if (this._client is not null && this._agent is not null)
{
return new ValueTask(this._client.AgentAdministrationClient.DeleteAgentAsync(this._agent.Name));
}
return default;
}
public virtual async ValueTask InitializeAsync()
{
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
GenerateUniqueAgentName("HelpfulAssistant"),
new ProjectsAgentVersionCreationOptions(
new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = "You are a helpful assistant."
}));
this._agent = this._client.AsAIAgent(agentVersion);
}
public async Task InitializeAsync(ChatClientAgentOptions options)
{
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
options.Name ??= GenerateUniqueAgentName("HelpfulAssistant");
var definition = new DeclarativeAgentDefinition(
options.ChatOptions?.ModelId ?? TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = options.ChatOptions?.Instructions
};
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
options.Name,
new ProjectsAgentVersionCreationOptions(definition) { Description = options.Description });
this._agent = this._client.AsAIAgent(agentVersion, tools: options.ChatOptions?.Tools);
}
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using Microsoft.Agents.AI;
namespace Foundry.IntegrationTests;
public class FoundryVersionedAgentRunStreamingPreviousResponseTests() : RunStreamingTests<FoundryVersionedAgentFixture>(() => new())
{
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
}
}
public class FoundryVersionedAgentRunStreamingConversationTests() : RunStreamingTests<FoundryVersionedAgentFixture>(() => new())
{
public override Func<Task<AgentRunOptions?>> AgentRunOptionsFactory => async () =>
{
var conversationId = await this.Fixture.CreateConversationAsync();
return new ChatClientAgentRunOptions(new() { ConversationId = conversationId });
};
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
}
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using Microsoft.Agents.AI;
namespace Foundry.IntegrationTests;
public class FoundryVersionedAgentRunPreviousResponseTests() : RunTests<FoundryVersionedAgentFixture>(() => new())
{
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
}
}
public class FoundryVersionedAgentRunConversationTests() : RunTests<FoundryVersionedAgentFixture>(() => new())
{
public override Func<Task<AgentRunOptions?>> AgentRunOptionsFactory => async () =>
{
var conversationId = await this.Fixture.CreateConversationAsync();
return new ChatClientAgentRunOptions(new() { ConversationId = conversationId });
};
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
}
}
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Foundry.IntegrationTests;
public class FoundryVersionedAgentStructuredOutputRunTests() : StructuredOutputRunTests<FoundryVersionedAgentStructuredOutputFixture<CityInfo>>(() => new FoundryVersionedAgentStructuredOutputFixture<CityInfo>())
{
private const string NotSupported = "Versioned Foundry agents do not support specifying structured output type at invocation time.";
private const string ResponseFormatNotSupported = "FoundryChatClient clears ResponseFormat for versioned agents; structured output must be defined in the server-side agent definition.";
/// <summary>
/// Verifies that response format provided at agent initialization is used when invoking RunAsync.
/// </summary>
/// <returns></returns>
[RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)]
public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo));
Assert.Equal("Paris", cityInfo.Name);
}
/// <summary>
/// Verifies that generic RunAsync works with versioned Foundry agents when structured output is configured at agent initialization.
/// </summary>
/// <remarks>
/// Versioned Foundry agents do not support specifying the structured output type at invocation time yet.
/// The type T provided to RunAsync&lt;T&gt; is ignored by FoundryChatClient and is only used
/// for deserializing the agent response by AgentResponse&lt;T&gt;.Result.
/// </remarks>
[RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)]
public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
{
// Arrange
var agent = this.Fixture.Agent;
var session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>(
new ChatMessage(ChatRole.User, "Provide information about the capital of France."),
session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.NotNull(response.Result);
Assert.Equal("Paris", response.Result.Name);
}
public override Task RunWithGenericTypeReturnsExpectedResultAsync()
{
Assert.Skip(NotSupported);
return base.RunWithGenericTypeReturnsExpectedResultAsync();
}
public override Task RunWithResponseFormatReturnsExpectedResultAsync()
{
Assert.Skip(NotSupported);
return base.RunWithResponseFormatReturnsExpectedResultAsync();
}
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
{
Assert.Skip(NotSupported);
return base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
}
}
/// <summary>
/// Represents a fixture for testing versioned Foundry agents with structured output of type <typeparamref name="T"/> provided at agent initialization.
/// </summary>
public class FoundryVersionedAgentStructuredOutputFixture<T> : FoundryVersionedAgentFixture
{
public override async ValueTask InitializeAsync()
{
var agentOptions = new ChatClientAgentOptions
{
ChatOptions = new ChatOptions()
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<T>(AgentAbstractionsJsonUtilities.DefaultOptions)
},
};
await this.InitializeAsync(agentOptions);
}
}
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.AI.Projects.Memory;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.IntegrationTests.Memory;
/// <summary>
/// Integration tests for <see cref="FoundryMemoryProvider"/> against a configured Azure AI Foundry Memory service.
/// </summary>
/// <remarks>
/// These integration tests are skipped by default and require a live Azure AI Foundry Memory service.
/// The tests need to be updated to use the new AIAgent-based API pattern.
/// </remarks>
public sealed class FoundryMemoryProviderTests : IDisposable
{
private const string SkipReason = "Requires an Azure AI Foundry Memory service configured"; // Set to null to enable.
private readonly AIProjectClient? _client;
private readonly string? _memoryStoreName;
private readonly string? _deploymentName;
private readonly string? _embeddingDeploymentName;
private bool _disposed;
public FoundryMemoryProviderTests()
{
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables()
.AddUserSecrets<FoundryMemoryProviderTests>(optional: true)
.Build();
var endpoint = configuration[TestSettings.AzureAIProjectEndpoint];
var memoryStoreName = configuration[TestSettings.AzureAIMemoryStoreId];
var deploymentName = configuration[TestSettings.AzureAIModelDeploymentName];
var embeddingDeploymentName = configuration[TestSettings.AzureAIEmbeddingDeploymentName];
if (!string.IsNullOrWhiteSpace(endpoint) &&
!string.IsNullOrWhiteSpace(memoryStoreName))
{
this._client = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
this._memoryStoreName = memoryStoreName;
this._deploymentName = deploymentName ?? "gpt-4.1-mini";
this._embeddingDeploymentName = embeddingDeploymentName ?? "text-embedding-ada-002";
}
}
[Fact(Skip = SkipReason)]
public async Task CanAddAndRetrieveUserMemoriesAsync()
{
// Arrange
FoundryMemoryProvider memoryProvider = new(
this._client!,
this._memoryStoreName!,
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-user-1")));
await memoryProvider.EnsureMemoryStoreCreatedAsync(this._deploymentName!, this._embeddingDeploymentName!);
AIAgent agent = this._client!.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
ModelId = this._deploymentName!,
Instructions = "You are a helpful assistant. Use known memories about the user when responding, and do not invent details."
},
AIContextProviders = [memoryProvider]
});
AgentSession session = await agent.CreateSessionAsync();
await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
// Act
AgentResponse resultBefore = await agent.RunAsync("What is my name?", session);
Assert.DoesNotContain("Caoimhe", resultBefore.Text);
await agent.RunAsync("Hello, my name is Caoimhe.", session);
await memoryProvider.WhenUpdatesCompletedAsync();
await Task.Delay(2000);
// Assert - verify memories were actually created in the store before querying via agent
var searchResult = await this._client!.MemoryStores.SearchMemoriesAsync(
this._memoryStoreName!,
new MemorySearchOptions("it-user-1")
{
Items = { ResponseItem.CreateUserMessageItem("Caoimhe") }
});
Assert.NotEmpty(searchResult.Value.Memories);
AgentResponse resultAfter = await agent.RunAsync("What is my name?", session);
// Cleanup
await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
// Assert
Assert.Contains("Caoimhe", resultAfter.Text);
}
[Fact(Skip = SkipReason)]
public async Task DoesNotLeakMemoriesAcrossScopesAsync()
{
// Arrange
FoundryMemoryProvider memoryProvider1 = new(
this._client!,
this._memoryStoreName!,
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-a")));
FoundryMemoryProvider memoryProvider2 = new(
this._client!,
this._memoryStoreName!,
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-b")));
await memoryProvider1.EnsureMemoryStoreCreatedAsync(this._deploymentName!, this._embeddingDeploymentName!);
AIAgent agent1 = this._client!.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
ModelId = this._deploymentName!,
Instructions = "You are a helpful assistant. Use known memories about the user when responding, and do not invent details."
},
AIContextProviders = [memoryProvider1]
});
AIAgent agent2 = this._client!.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
ModelId = this._deploymentName!,
Instructions = "You are a helpful assistant. Use known memories about the user when responding, and do not invent details."
},
AIContextProviders = [memoryProvider2]
});
AgentSession session1 = await agent1.CreateSessionAsync();
AgentSession session2 = await agent2.CreateSessionAsync();
await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1);
await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2);
// Act - add memory only to scope A
await agent1.RunAsync("Hello, I'm an AI tutor and my name is Caoimhe.", session1);
await memoryProvider1.WhenUpdatesCompletedAsync();
await Task.Delay(2000);
// Assert - verify memories were created in scope A but not in scope B
var searchResultA = await this._client!.MemoryStores.SearchMemoriesAsync(
this._memoryStoreName!,
new MemorySearchOptions("it-scope-a")
{
Items = { ResponseItem.CreateUserMessageItem("Caoimhe") }
});
Assert.NotEmpty(searchResultA.Value.Memories);
var searchResultB = await this._client.MemoryStores.SearchMemoriesAsync(
this._memoryStoreName!,
new MemorySearchOptions("it-scope-b")
{
Items = { ResponseItem.CreateUserMessageItem("Caoimhe") }
});
Assert.Empty(searchResultB.Value.Memories);
AgentResponse result1 = await agent1.RunAsync("What is my name?", session1);
AgentResponse result2 = await agent2.RunAsync("What is my name?", session2);
// Assert
Assert.Contains("Caoimhe", result1.Text);
Assert.DoesNotContain("Caoimhe", result2.Text);
// Cleanup
await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1);
await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2);
}
public void Dispose()
{
if (!this._disposed)
{
this._disposed = true;
}
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace Foundry.IntegrationTests;
public class ResponsesAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests<ResponsesAgentFixture>(() => new())
{
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace Foundry.IntegrationTests;
public class ResponsesAgentChatClientRunTests() : ChatClientAgentRunTests<ResponsesAgentFixture>(() => new())
{
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
}
}
@@ -0,0 +1,138 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace Foundry.IntegrationTests;
/// <summary>
/// Integration tests for non-versioned <see cref="ChatClientAgent"/> creation via <see cref="AIProjectClient"/> extension methods.
/// </summary>
public class ResponsesAgentExtensionCreateTests
{
private static Uri Endpoint => new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
private static string Model => TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName);
private readonly AIProjectClient _client = new(Endpoint, TestAzureCliCredentials.CreateAzureCliCredential());
[Fact]
public async Task AsAIAgent_WithModelAndInstructions_CreatesChatClientAgentAndRunsAsync()
{
// Arrange
const string AgentName = "ResponsesAgentExtensionSimple";
const string AgentDescription = "Integration test agent created from AIProjectClient.AsAIAgent(model, instructions).";
const string VerificationToken = "integration-extension-ok";
ChatClientAgent agent = this._client.AsAIAgent(
model: Model,
instructions: $"You are a helpful assistant. When asked for verification, reply with exactly '{VerificationToken}'.",
name: AgentName,
description: AgentDescription);
AgentSession? session = null;
try
{
var conversation = await CreateConversationAsync(this._client);
session = await agent.CreateSessionAsync(conversation.Id);
// Act
AgentResponse response = await agent.RunAsync("Return the verification token.", session);
// Assert
Assert.NotNull(agent);
Assert.Equal(AgentName, agent.Name);
Assert.Equal(AgentDescription, agent.Description);
Assert.NotNull(agent.GetService<IChatClient>());
Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await DeleteSessionAsync(this._client, session);
}
}
[Fact]
public async Task AsAIAgent_WithOptions_CreatesChatClientAgentAndRunsAsync()
{
// Arrange
const string VerificationToken = "integration-options-ok";
ChatClientAgentOptions options = new()
{
Name = "ResponsesAgentExtensionOptions",
Description = "Integration test agent created from AIProjectClient.AsAIAgent(options).",
ChatOptions = new ChatOptions
{
ModelId = Model,
Instructions = $"You are a helpful assistant. When asked for verification, reply with exactly '{VerificationToken}'.",
},
};
ChatClientAgent agent = this._client.AsAIAgent(options);
ChatClientAgentSession? session = null;
try
{
var conversation = await CreateConversationAsync(this._client);
session = ((await agent.CreateSessionAsync(conversation.Id)) as ChatClientAgentSession)!;
// Act
AgentResponse response = await agent.RunAsync("Return the verification token.", session);
// Assert
Assert.StartsWith("conv_", session!.ConversationId, StringComparison.OrdinalIgnoreCase);
Assert.Equal(options.Name, agent.Name);
Assert.Equal(options.Description, agent.Description);
Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await DeleteSessionAsync(this._client, session);
}
}
private static async Task DeleteSessionAsync(AIProjectClient client, AgentSession? session)
{
if (session is null)
{
return;
}
ChatClientAgentSession typedSession = (ChatClientAgentSession)session;
if (typedSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true)
{
await client.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(typedSession.ConversationId);
}
else if (typedSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true)
{
await DeleteResponseChainAsync(client, typedSession.ConversationId);
}
}
private static async Task DeleteResponseChainAsync(AIProjectClient client, string lastResponseId)
{
var responsesClient = client.GetProjectOpenAIClient().GetProjectResponsesClient();
var response = await responsesClient.GetResponseAsync(lastResponseId);
await responsesClient.DeleteResponseAsync(lastResponseId);
if (response.Value.PreviousResponseId is not null)
{
await DeleteResponseChainAsync(client, response.Value.PreviousResponseId);
}
}
private static async Task<ProjectConversation> CreateConversationAsync(AIProjectClient client)
{
ProjectConversationsClient conversationsClient = client.GetProjectOpenAIClient().GetProjectConversationsClient();
return (await conversationsClient.CreateProjectConversationAsync()).Value!;
}
}
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace Foundry.IntegrationTests;
/// <summary>
/// Integration test fixture that creates non-versioned Responses agents via the direct <c>AIProjectClient.AsAIAgent(...)</c> path.
/// </summary>
public class ResponsesAgentFixture : IChatClientAgentFixture
{
private ChatClientAgent _agent = null!;
private AIProjectClient _client = null!;
public IChatClient ChatClient => this._agent.GetService<ChatClientAgent>()!.ChatClient;
public AIAgent Agent => this._agent;
public async Task<string> CreateConversationAsync()
{
var response = await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().CreateProjectConversationAsync();
return response.Value.Id;
}
public async Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session)
{
ChatClientAgentSession chatClientSession = (ChatClientAgentSession)session;
if (chatClientSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true)
{
return await this.GetChatHistoryFromConversationAsync(chatClientSession.ConversationId);
}
if (chatClientSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true)
{
return await this.GetChatHistoryFromResponsesChainAsync(chatClientSession.ConversationId);
}
ChatHistoryProvider? chatHistoryProvider = agent.GetService<ChatHistoryProvider>();
if (chatHistoryProvider is null)
{
return [];
}
return (await chatHistoryProvider.InvokingAsync(new(agent, session, []))).ToList();
}
private async Task<List<ChatMessage>> GetChatHistoryFromResponsesChainAsync(string conversationId)
{
var openAIResponseClient = this._client.GetProjectOpenAIClient().GetProjectResponsesClient();
var inputItems = await openAIResponseClient.GetResponseInputItemsAsync(conversationId).ToListAsync();
var response = await openAIResponseClient.GetResponseAsync(conversationId);
ResponseItem responseItem = response.Value.OutputItems.FirstOrDefault()!;
var previousMessages = inputItems
.Select(ConvertToChatMessage)
.Where(x => x.Text != "You are a helpful assistant.")
.Reverse();
ChatMessage responseMessage = ConvertToChatMessage(responseItem);
return [.. previousMessages, responseMessage];
}
private static ChatMessage ConvertToChatMessage(ResponseItem item)
{
if (item is MessageResponseItem messageResponseItem)
{
ChatRole role = messageResponseItem.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant;
return new ChatMessage(role, messageResponseItem.Content.FirstOrDefault()?.Text);
}
throw new NotSupportedException("This test currently only supports text messages");
}
private async Task<List<ChatMessage>> GetChatHistoryFromConversationAsync(string conversationId)
{
List<ChatMessage> messages = [];
await foreach (AgentResponseItem item in this._client.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc"))
{
var openAIItem = item.AsResponseResultItem();
if (openAIItem is MessageResponseItem messageItem)
{
messages.Add(new ChatMessage
{
Role = new ChatRole(messageItem.Role.ToString()),
Contents = messageItem.Content
.Where(c => c.Kind is ResponseContentPartKind.OutputText or ResponseContentPartKind.InputText)
.Select(c => new TextContent(c.Text))
.ToList<AIContent>()
});
}
}
return messages;
}
public Task<ChatClientAgent> CreateChatClientAgentAsync(
string name = "HelpfulAssistant",
string instructions = "You are a helpful assistant.",
IList<AITool>? aiTools = null)
{
return Task.FromResult(this._client.AsAIAgent(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: instructions,
name: name,
tools: aiTools).GetService<ChatClientAgent>()!);
}
public Task<ChatClientAgent> CreateChatClientAgentAsync(ChatClientAgentOptions options)
{
return Task.FromResult(this._client.AsAIAgent(options).GetService<ChatClientAgent>()!);
}
// Non-versioned Responses agents have no server-side agent to delete.
public Task DeleteAgentAsync(ChatClientAgent agent) => Task.CompletedTask;
public async Task DeleteSessionAsync(AgentSession session)
{
ChatClientAgentSession typedSession = (ChatClientAgentSession)session;
if (typedSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true)
{
await this._client.GetProjectOpenAIClient().GetProjectConversationsClient().DeleteConversationAsync(typedSession.ConversationId);
}
else if (typedSession.ConversationId?.StartsWith("resp_", StringComparison.OrdinalIgnoreCase) == true)
{
await this.DeleteResponseChainAsync(typedSession.ConversationId!);
}
}
private async Task DeleteResponseChainAsync(string lastResponseId)
{
var response = await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().GetResponseAsync(lastResponseId);
await this._client.GetProjectOpenAIClient().GetProjectResponsesClient().DeleteResponseAsync(lastResponseId);
if (response.Value.PreviousResponseId is not null)
{
await this.DeleteResponseChainAsync(response.Value.PreviousResponseId);
}
}
// Non-versioned Responses agents have no server-side agent to clean up on dispose.
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
return default;
}
public virtual ValueTask InitializeAsync()
{
this._client = new AIProjectClient(
new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)),
TestAzureCliCredentials.CreateAzureCliCredential());
this._agent = this._client.AsAIAgent(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
instructions: "You are a helpful assistant.",
name: "HelpfulAssistant");
return default;
}
public ValueTask InitializeAsync(ChatClientAgentOptions options)
{
this._client = new AIProjectClient(
new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)),
TestAzureCliCredentials.CreateAzureCliCredential());
this._agent = this._client.AsAIAgent(options);
return default;
}
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using Microsoft.Agents.AI;
namespace Foundry.IntegrationTests;
public class ResponsesAgentRunStreamingPreviousResponseTests() : RunStreamingTests<ResponsesAgentFixture>(() => new())
{
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
}
}
public class ResponsesAgentRunStreamingConversationTests() : RunStreamingTests<ResponsesAgentFixture>(() => new())
{
public override Func<Task<AgentRunOptions?>> AgentRunOptionsFactory => async () =>
{
var conversationId = await this.Fixture.CreateConversationAsync();
return new ChatClientAgentRunOptions(new() { ConversationId = conversationId });
};
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
}
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using Microsoft.Agents.AI;
namespace Foundry.IntegrationTests;
public class ResponsesAgentRunPreviousResponseTests() : RunTests<ResponsesAgentFixture>(() => new())
{
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
}
}
public class ResponsesAgentRunConversationTests() : RunTests<ResponsesAgentFixture>(() => new())
{
public override Func<Task<AgentRunOptions?>> AgentRunOptionsFactory => async () =>
{
var conversationId = await this.Fixture.CreateConversationAsync();
return new ChatClientAgentRunOptions(new() { ConversationId = conversationId });
};
public override Task RunWithNoMessageDoesNotFailAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithNoMessageDoesNotFailAsync();
}
}
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Azure.AI.Projects;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace Foundry.IntegrationTests;
/// <summary>
/// Integration tests validating that the <c>x-ms-served-model</c> response header
/// returned by the Azure OpenAI Responses API is surfaced on <see cref="ChatResponse.ModelId"/>.
/// </summary>
public class ResponsesAgentServedModelTests
{
// Matches a dated served-model snapshot, e.g. "gpt-5-nano-2025-08-07".
private static readonly Regex s_snapshotRegex = new(@"-\d{4}-\d{2}-\d{2}$", RegexOptions.Compiled);
private static Uri Endpoint => new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
private static string DeploymentName => TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName);
private readonly AIProjectClient _client = new(Endpoint, TestAzureCliCredentials.CreateAzureCliCredential());
[Fact]
public async Task GetResponseAsync_ReturnsServedModelSnapshotOnModelIdAsync()
{
// Arrange
ChatClientAgent agent = this._client.AsAIAgent(
model: DeploymentName,
instructions: "You are a helpful assistant. Reply with a single short word.",
name: "ServedModelTest");
IChatClient chatClient = agent.ChatClient;
// Act
ChatResponse response = await chatClient.GetResponseAsync(
[new ChatMessage(ChatRole.User, "Say hi.")],
new ChatOptions { ModelId = DeploymentName });
// Assert
AssertServedModel(response.ModelId);
}
[Fact]
public async Task RunAsync_AgentResponseRawRepresentationCarriesServedModelAsync()
{
// Arrange
ChatClientAgent agent = this._client.AsAIAgent(
model: DeploymentName,
instructions: "You are a helpful assistant. Reply with a single short word.",
name: "ServedModelTestRun");
// Act
AgentResponse agentResponse = await agent.RunAsync("Say hi.");
// Assert
ChatResponse? chatResponse = agentResponse.RawRepresentation as ChatResponse;
Assert.NotNull(chatResponse);
AssertServedModel(chatResponse!.ModelId);
}
private static void AssertServedModel(string? modelId)
{
Assert.False(string.IsNullOrWhiteSpace(modelId), "ChatResponse.ModelId must be populated.");
// Primary invariant: the served-model value must look like a dated snapshot
// (e.g. "gpt-5-nano-2025-08-07"). This is what the x-ms-served-model header carries.
// Only when the configured deployment name itself already matches the snapshot pattern
// do we fall back to permitting equality with the deployment alias.
bool aliasIsSnapshot = s_snapshotRegex.IsMatch(DeploymentName);
if (aliasIsSnapshot)
{
return;
}
Assert.Matches(s_snapshotRegex, modelId!);
Assert.NotEqual(DeploymentName, modelId);
}
}
@@ -0,0 +1,100 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace Foundry.IntegrationTests;
public class ResponsesAgentStructuredOutputRunTests() : StructuredOutputRunTests<ResponsesAgentStructuredOutputFixture<CityInfo>>(() => new())
{
private const string NotSupported = "The direct Responses AsAIAgent path does not support specifying structured output type at invocation time.";
/// <summary>
/// Verifies that response format provided at agent initialization is used when invoking RunAsync.
/// </summary>
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
{
// Arrange
AIAgent agent = this.Fixture.Agent;
AgentSession session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
AgentResponse response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo));
Assert.Equal("Paris", cityInfo.Name);
}
/// <summary>
/// Verifies that generic RunAsync works when structured output is configured at agent initialization.
/// </summary>
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
{
// Arrange
AIAgent agent = this.Fixture.Agent;
AgentSession session = await agent.CreateSessionAsync();
await using var cleanup = new SessionCleanup(session, this.Fixture);
// Act
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>(
new ChatMessage(ChatRole.User, "Provide information about the capital of France."),
session);
// Assert
Assert.NotNull(response);
Assert.Single(response.Messages);
Assert.Contains("Paris", response.Text);
Assert.NotNull(response.Result);
Assert.Equal("Paris", response.Result.Name);
}
public override Task RunWithGenericTypeReturnsExpectedResultAsync()
{
Assert.Skip(NotSupported);
return base.RunWithGenericTypeReturnsExpectedResultAsync();
}
public override Task RunWithResponseFormatReturnsExpectedResultAsync()
{
Assert.Skip(NotSupported);
return base.RunWithResponseFormatReturnsExpectedResultAsync();
}
public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync()
{
Assert.Skip(NotSupported);
return base.RunWithPrimitiveTypeReturnsExpectedResultAsync();
}
}
/// <summary>
/// Fixture for testing the direct Responses <see cref="ChatClientAgent"/> path with structured output of type <typeparamref name="T"/> provided at agent initialization.
/// </summary>
public class ResponsesAgentStructuredOutputFixture<T> : ResponsesAgentFixture
{
public override ValueTask InitializeAsync()
{
ChatClientAgentOptions agentOptions = new()
{
ChatOptions = new ChatOptions()
{
ModelId = TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
ResponseFormat = ChatResponseFormat.ForJsonSchema<T>(AgentAbstractionsJsonUtilities.DefaultOptions)
},
};
return this.InitializeAsync(agentOptions);
}
}
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AAgentSession"/> class.
/// </summary>
public sealed class A2AAgentSessionTests
{
[Fact]
public void Constructor_RoundTrip_SerializationPreservesState()
{
// Arrange
const string ContextId = "context-rt-001";
const string TaskId = "task-rt-002";
A2AAgentSession originalSession = new() { ContextId = ContextId, TaskId = TaskId };
// Act
JsonElement serialized = originalSession.Serialize();
A2AAgentSession deserializedSession = A2AAgentSession.Deserialize(serialized);
// Assert
Assert.Equal(originalSession.ContextId, deserializedSession.ContextId);
Assert.Equal(originalSession.TaskId, deserializedSession.TaskId);
}
[Fact]
public void Constructor_RoundTrip_SerializationPreservesStateBag()
{
// Arrange
A2AAgentSession originalSession = new() { ContextId = "ctx-1", TaskId = "task-1" };
originalSession.StateBag.SetValue("testKey", "testValue");
// Act
JsonElement serialized = originalSession.Serialize();
A2AAgentSession deserializedSession = A2AAgentSession.Deserialize(serialized);
// Assert
Assert.Equal("ctx-1", deserializedSession.ContextId);
Assert.Equal("task-1", deserializedSession.TaskId);
Assert.True(deserializedSession.StateBag.TryGetValue<string>("testKey", out var value));
Assert.Equal("testValue", value);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AContinuationToken"/> class.
/// </summary>
public sealed class A2AContinuationTokenTests
{
[Fact]
public void Constructor_WithValidTaskId_InitializesTaskIdProperty()
{
// Arrange
const string TaskId = "task-123";
// Act
var token = new A2AContinuationToken(TaskId);
// Assert
Assert.Equal(TaskId, token.TaskId);
}
[Fact]
public void ToBytes_WithValidToken_SerializesToJsonBytes()
{
// Arrange
const string TaskId = "task-456";
var token = new A2AContinuationToken(TaskId);
// Act
var bytes = token.ToBytes();
// Assert
Assert.NotEqual(0, bytes.Length);
var jsonString = System.Text.Encoding.UTF8.GetString(bytes.ToArray());
using var jsonDoc = JsonDocument.Parse(jsonString);
var root = jsonDoc.RootElement;
Assert.True(root.TryGetProperty("taskId", out var taskIdElement));
Assert.Equal(TaskId, taskIdElement.GetString());
}
[Fact]
public void FromToken_WithA2AContinuationToken_ReturnsSameInstance()
{
// Arrange
const string TaskId = "task-direct";
var originalToken = new A2AContinuationToken(TaskId);
// Act
var resultToken = A2AContinuationToken.FromToken(originalToken);
// Assert
Assert.Same(originalToken, resultToken);
Assert.Equal(TaskId, resultToken.TaskId);
}
[Fact]
public void FromToken_WithSerializedToken_DeserializesCorrectly()
{
// Arrange
const string TaskId = "task-deserialized";
var originalToken = new A2AContinuationToken(TaskId);
var serialized = originalToken.ToBytes();
// Create a mock token wrapper to pass to FromToken
var mockToken = new MockResponseContinuationToken(serialized);
// Act
var resultToken = A2AContinuationToken.FromToken(mockToken);
// Assert
Assert.Equal(TaskId, resultToken.TaskId);
Assert.IsType<A2AContinuationToken>(resultToken);
}
[Fact]
public void FromToken_RoundTrip_PreservesTaskId()
{
// Arrange
const string TaskId = "task-roundtrip-123";
var originalToken = new A2AContinuationToken(TaskId);
var serialized = originalToken.ToBytes();
var mockToken = new MockResponseContinuationToken(serialized);
// Act
var deserializedToken = A2AContinuationToken.FromToken(mockToken);
var reserialized = deserializedToken.ToBytes();
var mockToken2 = new MockResponseContinuationToken(reserialized);
var deserializedAgain = A2AContinuationToken.FromToken(mockToken2);
// Assert
Assert.Equal(TaskId, deserializedAgain.TaskId);
}
[Fact]
public void FromToken_WithEmptyData_ThrowsArgumentException()
{
// Arrange
var emptyToken = new MockResponseContinuationToken(ReadOnlyMemory<byte>.Empty);
// Act & Assert
Assert.Throws<ArgumentException>(() => A2AContinuationToken.FromToken(emptyToken));
}
[Fact]
public void FromToken_WithNullTaskIdValue_ThrowsJsonException()
{
// Arrange
var jsonWithNullTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"taskId\": null }").AsMemory();
var mockToken = new MockResponseContinuationToken(jsonWithNullTaskId);
// Act & Assert
var ex = Assert.Throws<JsonException>(() => A2AContinuationToken.FromToken(mockToken));
Assert.Contains("taskId", ex.Message);
}
[Fact]
public void FromToken_WithMissingTaskIdProperty_ThrowsException()
{
// Arrange
var jsonWithoutTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"someOtherProperty\": \"value\" }").AsMemory();
var mockToken = new MockResponseContinuationToken(jsonWithoutTaskId);
// Act & Assert
Assert.Throws<JsonException>(() => A2AContinuationToken.FromToken(mockToken));
}
[Fact]
public void FromToken_WithValidTaskId_ParsesTaskIdCorrectly()
{
// Arrange
const string TaskId = "task-multi-prop";
var json = System.Text.Encoding.UTF8.GetBytes($"{{ \"taskId\": \"{TaskId}\" }}").AsMemory();
var mockToken = new MockResponseContinuationToken(json);
// Act
var resultToken = A2AContinuationToken.FromToken(mockToken);
// Assert
Assert.Equal(TaskId, resultToken.TaskId);
}
/// <summary>
/// Mock implementation of ResponseContinuationToken for testing.
/// </summary>
private sealed class MockResponseContinuationToken : ResponseContinuationToken
{
private readonly ReadOnlyMemory<byte> _data;
public MockResponseContinuationToken(ReadOnlyMemory<byte> data)
{
this._data = data;
}
public override ReadOnlyMemory<byte> ToBytes()
{
return this._data;
}
}
}
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using A2A;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AAIContentExtensions"/> class.
/// </summary>
public sealed class A2AAIContentExtensionsTests
{
[Fact]
public void ToA2AParts_WithEmptyCollection_ReturnsNull()
{
// Arrange
var emptyContents = new List<AIContent>();
// Act
var result = emptyContents.ToParts();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AParts_WithMultipleContents_ReturnsListWithAllParts()
{
// Arrange
var contents = new List<AIContent>
{
new TextContent("First text"),
new UriContent("https://example.com/file1.txt", "file/txt"),
new TextContent("Second text"),
};
// Act
var result = contents.ToParts();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.Equal(PartContentCase.Text, result[0].ContentCase);
Assert.Equal("First text", result[0].Text);
Assert.Equal(PartContentCase.Url, result[1].ContentCase);
Assert.Equal("https://example.com/file1.txt", result[1].Url);
Assert.Equal(PartContentCase.Text, result[2].ContentCase);
Assert.Equal("Second text", result[2].Text);
}
[Fact]
public void ToA2AParts_WithMixedSupportedAndUnsupportedContent_IgnoresUnsupportedContent()
{
// Arrange
var contents = new List<AIContent>
{
new TextContent("First text"),
new MockAIContent(), // Unsupported - should be ignored
new UriContent("https://example.com/file.txt", "file/txt"),
new MockAIContent(), // Unsupported - should be ignored
new TextContent("Second text")
};
// Act
var result = contents.ToParts();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.Equal(PartContentCase.Text, result[0].ContentCase);
Assert.Equal("First text", result[0].Text);
Assert.Equal(PartContentCase.Url, result[1].ContentCase);
Assert.Equal("https://example.com/file.txt", result[1].Url);
Assert.Equal(PartContentCase.Text, result[2].ContentCase);
Assert.Equal("Second text", result[2].Text);
}
// Mock class for testing unsupported scenarios
private sealed class MockAIContent : AIContent;
}
@@ -0,0 +1,287 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
namespace Microsoft.Agents.AI.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AAgentCardExtensions"/> class.
/// </summary>
public sealed class A2AAgentCardExtensionsTests
{
private readonly AgentCard _agentCard;
public A2AAgentCardExtensionsTests()
{
this._agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for unit testing",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
}
[Fact]
public void AsAIAgent_ReturnsAIAgent()
{
// Act
var agent = this._agentCard.AsAIAgent();
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("Test Agent", agent.Name);
Assert.Equal("A test agent for unit testing", agent.Description);
}
[Fact]
public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync()
{
// Arrange
using var handler = new HttpMessageHandlerStub();
using var httpClient = new HttpClient(handler, false);
handler.ResponsesToReturn.Enqueue(new Message
{
Role = Role.Agent,
Parts = [Part.FromText("Response")],
});
var agent = this._agentCard.AsAIAgent(httpClient: httpClient);
// Act
await agent.RunAsync("Test input");
// Assert
Assert.Single(handler.CapturedUris);
Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]);
}
[Fact]
public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync()
{
// Arrange
var card = new AgentCard
{
Name = "Multi-Interface Agent",
Description = "An agent with multiple interfaces",
SupportedInterfaces =
[
new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
]
};
using var handler = new HttpMessageHandlerStub();
using var httpClient = new HttpClient(handler, false);
handler.ResponsesToReturn.Enqueue(new Message
{
Role = Role.Agent,
Parts = [Part.FromText("Response")],
});
var options = new A2AClientOptions
{
PreferredBindings = [ProtocolBindingNames.JsonRpc]
};
var agent = card.AsAIAgent(httpClient, options: options);
// Act
await agent.RunAsync("Test input");
// Assert
Assert.Single(handler.CapturedUris);
Assert.Equal(new Uri("http://second/agent"), handler.CapturedUris[0]);
}
[Fact]
public void AsAIAgent_WithNullOptions_UsesDefaultBindingPreference()
{
// Arrange
var card = new AgentCard
{
Name = "Default Options Agent",
Description = "Tests default A2AClientOptions behavior",
SupportedInterfaces =
[
new AgentInterface { Url = "http://default/agent" },
]
};
// Act - null options should use defaults (HTTP+JSON first, JSON-RPC as fallback)
var agent = card.AsAIAgent(options: null);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("Default Options Agent", agent.Name);
}
[Fact]
public void AsAIAgent_WithNoMatchingBinding_ThrowsException()
{
// Arrange
var card = new AgentCard
{
Name = "Unmatched Binding Agent",
Description = "Agent with unsupported binding only",
SupportedInterfaces =
[
new AgentInterface { Url = "http://grpc/agent", ProtocolBinding = "GRPC" },
]
};
var options = new A2AClientOptions
{
PreferredBindings = [ProtocolBindingNames.JsonRpc]
};
// Act & Assert - factory should throw when no matching binding exists
Assert.ThrowsAny<Exception>(() => card.AsAIAgent(options: options));
}
[Fact]
public void AsAIAgent_WithNoSupportedInterfaces_ThrowsException()
{
// Arrange
var card = new AgentCard
{
Name = "No Interfaces Agent",
Description = "Agent with no supported interfaces",
};
// Act & Assert
Assert.ThrowsAny<Exception>(() => card.AsAIAgent());
}
[Fact]
public void AsAIAgent_WithAgentOptions_OverridesCardValues()
{
// Arrange
var card = new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
var agentOptions = new A2AAgentOptions
{
Id = "custom-id",
Name = "Custom Agent",
Description = "Custom description"
};
// Act
var agent = card.AsAIAgent(agentOptions);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Custom Agent", agent.Name);
Assert.Equal("Custom description", agent.Description);
}
[Fact]
public void AsAIAgent_WithAgentOptions_FallsBackToCardValues()
{
// Arrange
var card = new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
var agentOptions = new A2AAgentOptions
{
Id = "custom-id"
};
// Act
var agent = card.AsAIAgent(agentOptions);
// Assert
Assert.NotNull(agent);
Assert.Equal("custom-id", agent.Id);
Assert.Equal("Card Agent", agent.Name);
Assert.Equal("Card description", agent.Description);
}
[Fact]
public void AsAIAgent_WithEmptyAgentOptions_UsesCardValues()
{
// Arrange
var card = new AgentCard
{
Name = "Card Agent",
Description = "Card description",
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
// Act
var agent = card.AsAIAgent(new A2AAgentOptions());
// Assert
Assert.NotNull(agent);
Assert.Equal("Card Agent", agent.Name);
Assert.Equal("Card description", agent.Description);
}
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
{
public Queue ResponsesToReturn { get; } = new();
public List<Uri> CapturedUris { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.CapturedUris.Add(request.RequestUri!);
var response = this.ResponsesToReturn.Dequeue();
if (response is AgentCard agentCard)
{
var json = JsonSerializer.Serialize(agentCard);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
else if (response is Message message)
{
var sendMessageResponse = new SendMessageResponse { Message = message };
var jsonRpcResponse = new JsonRpcResponse
{
Id = "response-id",
Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions)
};
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json")
};
}
// Return empty agent card if none specified
var emptyCard = new AgentCard();
var emptyJson = JsonSerializer.Serialize(emptyCard);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(emptyJson, Encoding.UTF8, "application/json")
};
}
}
}

Some files were not shown because too many files have changed in this diff Show More