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
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:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
launchSettings.json
|
||||
+23
@@ -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.");
|
||||
}
|
||||
+21
@@ -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>
|
||||
+13
@@ -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));
|
||||
+17
@@ -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));
|
||||
+124
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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));
|
||||
+17
@@ -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));
|
||||
+85
@@ -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
|
||||
}
|
||||
+567
@@ -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
|
||||
}
|
||||
+167
@@ -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
|
||||
}
|
||||
+19
@@ -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>
|
||||
+613
@@ -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);
|
||||
}
|
||||
+10
@@ -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())
|
||||
{
|
||||
}
|
||||
+10
@@ -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())
|
||||
{
|
||||
}
|
||||
+19
@@ -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>
|
||||
+285
@@ -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");
|
||||
}
|
||||
}
|
||||
+104
@@ -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();
|
||||
}
|
||||
}
|
||||
+10
@@ -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())
|
||||
{
|
||||
}
|
||||
+10
@@ -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())
|
||||
{
|
||||
}
|
||||
+30
@@ -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();
|
||||
}
|
||||
}
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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";
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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";
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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";
|
||||
}
|
||||
+13
@@ -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";
|
||||
}
|
||||
+14
@@ -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";
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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.");
|
||||
}
|
||||
}
|
||||
+15
@@ -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();
|
||||
}
|
||||
}
|
||||
+105
@@ -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<T> is ignored by FoundryChatClient and is only used
|
||||
/// for deserializing the agent response by AgentResponse<T>.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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;
|
||||
}
|
||||
+287
@@ -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
Reference in New Issue
Block a user