Merge branch 'main' into features/3768-devui-aspire-integration

This commit is contained in:
Tommaso Stocchi
2026-04-03 20:29:53 +02:00
committed by GitHub
Unverified
837 changed files with 22534 additions and 32556 deletions
@@ -1,18 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AzureAI.IntegrationTests;
#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture
[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")]
public class AIProjectClientChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<AIProjectClientFixture>(() => new())
{
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
}
}
@@ -1,18 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AzureAI.IntegrationTests;
#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture
[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")]
public class AIProjectClientChatClientAgentRunTests() : ChatClientAgentRunTests<AIProjectClientFixture>(() => new())
{
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
{
Assert.Skip("No messages is not supported");
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
}
}
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\AgentConformance.IntegrationTests\AgentConformance.IntegrationTests.csproj" />
</ItemGroup>
@@ -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();
}
}
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods
using System;
using System.IO;
using System.Threading.Tasks;
@@ -9,45 +7,43 @@ using AgentConformance.IntegrationTests.Support;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AzureAI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Extensions.AI;
using OpenAI.Files;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
[Obsolete("Use FoundryVersionedAgentCreateTests instead. These tests exercise obsolete AIProjectClient extension methods.")]
public class AIProjectClientCreateTests
/// <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());
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithFoundryOptionsAsync")]
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism)
[Fact]
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync()
{
// Arrange.
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("IntegrationTestAgent");
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 agent = createMechanism switch
{
"CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
options: new ChatClientAgentOptions()
var agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(
AgentName,
new ProjectsAgentVersionCreationOptions(
new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Name = AgentName,
Description = AgentDescription,
ChatOptions = new() { Instructions = AgentInstructions }
}),
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
name: AgentName,
creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) { Instructions = AgentInstructions }) { Description = AgentDescription }),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
Instructions = AgentInstructions
})
{
Description = AgentDescription
});
var agent = this._client.AsAIAgent(agentVersion);
try
{
@@ -57,27 +53,26 @@ public class AIProjectClientCreateTests
Assert.Equal(AgentDescription, agent.Description);
Assert.Equal(AgentInstructions, agent.GetService<ChatClientAgent>()!.Instructions);
var agentRecord = await this._client.Agents.GetAgentAsync(agent.Name);
var agentRecord = await this._client.AgentAdministrationClient.GetAgentAsync(agent.Name);
Assert.NotNull(agentRecord);
Assert.Equal(AgentName, agentRecord.Value.Name);
var definition = Assert.IsType<PromptAgentDefinition>(agentRecord.Value.GetLatestVersion().Definition);
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.Agents.DeleteAgentAsync(agent.Name);
await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
}
}
[Theory(Skip = "For manual testing only")]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithFoundryOptionsAsync")]
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism)
[InlineData("FileSearchTool")]
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string _)
{
// Arrange.
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("VectorStoreAgent");
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.
@@ -99,22 +94,19 @@ public class AIProjectClientCreateTests
);
var vectorStoreMetadata = await projectOpenAIClient.GetProjectVectorStoresClient().CreateVectorStoreAsync(options: new() { FileIds = { uploadedAgentFile.Id }, Name = "WordCodeLookup_VectorStore" });
// Act.
var agent = createMechanism switch
// Act — create agent version with FileSearch tool via native SDK, then wrap with AsAIAgent.
var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
"CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
name: AgentName,
instructions: AgentInstructions,
tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]),
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
name: AgentName,
instructions: AgentInstructions,
tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
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.
@@ -125,20 +117,18 @@ public class AIProjectClientCreateTests
finally
{
// Cleanup.
await this._client.Agents.DeleteAgentAsync(agent.Name);
await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
await projectOpenAIClient.GetProjectVectorStoresClient().DeleteVectorStoreAsync(vectorStoreMetadata.Value.Id);
await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedAgentFile.Id);
File.Delete(searchFilePath);
}
}
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithFoundryOptionsAsync")]
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
[Fact]
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync()
{
// Arrange.
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("CodeInterpreterAgent");
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.
@@ -158,24 +148,19 @@ public class AIProjectClientCreateTests
purpose: FileUploadPurpose.Assistants
);
// Act.
var agent = createMechanism switch
// Act — create agent version with CodeInterpreter tool via native SDK, then wrap with AsAIAgent.
var definition = new DeclarativeAgentDefinition(TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
// Hosted tool path (tools supplied via ChatClientAgentOptions)
"CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
name: AgentName,
instructions: AgentInstructions,
tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]),
// Foundry (definitions + resources provided directly)
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
name: AgentName,
instructions: AgentInstructions,
tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
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.
@@ -186,7 +171,7 @@ public class AIProjectClientCreateTests
finally
{
// Cleanup.
await this._client.Agents.DeleteAgentAsync(agent.Name);
await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
await projectOpenAIClient.GetProjectFilesClient().DeleteFileAsync(uploadedCodeFile.Id);
File.Delete(codeFilePath);
}
@@ -202,7 +187,7 @@ public class AIProjectClientCreateTests
public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync()
{
// Arrange — create agent version with OpenAPI tool using native Azure.AI.Projects SDK types.
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("OpenAPITestAgent");
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 = """
@@ -267,14 +252,14 @@ public class AIProjectClientCreateTests
Description = "Retrieve information about countries by currency code"
};
var definition = new PromptAgentDefinition(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
var definition = new DeclarativeAgentDefinition(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName))
{
Instructions = AgentInstructions,
Tools = { (ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction) }
Tools = { (ResponseTool)ProjectsAgentTool.CreateOpenApiTool(openApiFunction) }
};
AgentVersionCreationOptions creationOptions = new(definition);
AgentVersion agentVersion = await this._client.Agents.CreateAgentVersionAsync(AgentName, creationOptions);
ProjectsAgentVersionCreationOptions creationOptions = new(definition);
ProjectsAgentVersion agentVersion = await this._client.AgentAdministrationClient.CreateAgentVersionAsync(AgentName, creationOptions);
try
{
@@ -284,7 +269,7 @@ public class AIProjectClientCreateTests
// Assert the agent was created correctly and retains version metadata.
Assert.NotNull(agent);
Assert.Equal(AgentName, agent.Name);
var retrievedVersion = agent.GetService<AgentVersion>();
var retrievedVersion = agent.GetService<ProjectsAgentVersion>();
Assert.NotNull(retrievedVersion);
// Step 3: Call RunAsync to trigger the server-side OpenAPI function.
@@ -316,32 +301,33 @@ public class AIProjectClientCreateTests
finally
{
// Cleanup.
await this._client.Agents.DeleteAgentAsync(AgentName);
await this._client.AgentAdministrationClient.DeleteAgentAsync(AgentName);
}
}
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism)
[Fact]
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync()
{
// Arrange.
string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("WeatherAgent");
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);
FoundryAgent agent = createMechanism switch
// 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))
{
"CreateWithChatClientAgentOptionsAsync" => await this._client.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName),
options: new ChatClientAgentOptions()
{
Name = AgentName,
ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] }
}),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
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
{
@@ -356,7 +342,7 @@ public class AIProjectClientCreateTests
}
finally
{
await this._client.Agents.DeleteAgentAsync(agent.Name);
await this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
}
}
}
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods
using System;
using System.Collections.Generic;
using System.Linq;
@@ -10,16 +8,21 @@ 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.AzureAI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
[Obsolete("Use FoundryVersionedAgentFixture instead. These tests exercise obsolete AIProjectClient extension methods.")]
public class AIProjectClientFixture : IChatClientAgentFixture
/// <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!;
@@ -40,7 +43,6 @@ public class AIProjectClientFixture : IChatClientAgentFixture
if (chatClientSession.ConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) == true)
{
// Conversation sessions do not persist message history.
return await this.GetChatHistoryFromConversationAsync(chatClientSession.ConversationId);
}
@@ -119,21 +121,55 @@ public class AIProjectClientFixture : IChatClientAgentFixture
string instructions = "You are a helpful assistant.",
IList<AITool>? aiTools = null)
{
return (await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: instructions, tools: aiTools)).GetService<ChatClientAgent>()!;
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");
return (await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options)).GetService<ChatClientAgent>()!;
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.Agents.DeleteAgentAsync(agent.Name);
this._client.AgentAdministrationClient.DeleteAgentAsync(agent.Name);
public async Task DeleteSessionAsync(AgentSession session)
{
@@ -165,7 +201,7 @@ public class AIProjectClientFixture : IChatClientAgentFixture
if (this._client is not null && this._agent is not null)
{
return new ValueTask(this._client.Agents.DeleteAgentAsync(this._agent.Name));
return new ValueTask(this._client.AgentAdministrationClient.DeleteAgentAsync(this._agent.Name));
}
return default;
@@ -174,13 +210,33 @@ public class AIProjectClientFixture : IChatClientAgentFixture
public virtual async ValueTask InitializeAsync()
{
this._client = new(new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)), TestAzureCliCredentials.CreateAzureCliCredential());
this._agent = await this._client.CreateAIAgentAsync(GenerateUniqueAgentName("HelpfulAssistant"), model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), instructions: "You are a helpful assistant.");
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");
this._agent = await this._client.CreateAIAgentAsync(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName), options);
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);
}
}
@@ -5,11 +5,9 @@ using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using Microsoft.Agents.AI;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture
[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")]
public class AIProjectClientAgentRunPreviousResponseTests() : RunTests<AIProjectClientFixture>(() => new())
public class FoundryVersionedAgentRunStreamingPreviousResponseTests() : RunStreamingTests<FoundryVersionedAgentFixture>(() => new())
{
public override Task RunWithNoMessageDoesNotFailAsync()
{
@@ -18,8 +16,7 @@ public class AIProjectClientAgentRunPreviousResponseTests() : RunTests<AIProject
}
}
[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")]
public class AIProjectClientAgentRunConversationTests() : RunTests<AIProjectClientFixture>(() => new())
public class FoundryVersionedAgentRunStreamingConversationTests() : RunStreamingTests<FoundryVersionedAgentFixture>(() => new())
{
public override Func<Task<AgentRunOptions?>> AgentRunOptionsFactory => async () =>
{
@@ -5,11 +5,9 @@ using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using Microsoft.Agents.AI;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture
[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")]
public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStreamingTests<AIProjectClientFixture>(() => new())
public class FoundryVersionedAgentRunPreviousResponseTests() : RunTests<FoundryVersionedAgentFixture>(() => new())
{
public override Task RunWithNoMessageDoesNotFailAsync()
{
@@ -18,8 +16,7 @@ public class AIProjectClientAgentRunStreamingPreviousResponseTests() : RunStream
}
}
[Obsolete("Use FoundryVersionedAgentRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")]
public class AIProjectClientAgentRunStreamingConversationTests() : RunStreamingTests<AIProjectClientFixture>(() => new())
public class FoundryVersionedAgentRunConversationTests() : RunTests<FoundryVersionedAgentFixture>(() => new())
{
public override Func<Task<AgentRunOptions?>> AgentRunOptionsFactory => async () =>
{
@@ -1,25 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
#pragma warning disable CS0618 // Tests intentionally exercise obsolete AIProjectClientFixture
[Obsolete("Use FoundryVersionedAgentStructuredOutputRunTests instead. These tests exercise obsolete AIProjectClient extension methods.")]
public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRunTests<AIProjectClientStructuredOutputFixture<CityInfo>>(() => new AIProjectClientStructuredOutputFixture<CityInfo>())
public class FoundryVersionedAgentStructuredOutputRunTests() : StructuredOutputRunTests<FoundryVersionedAgentStructuredOutputFixture<CityInfo>>(() => new FoundryVersionedAgentStructuredOutputFixture<CityInfo>())
{
private const string NotSupported = "AIProjectClient does not support specifying structured output type at invocation time.";
private const string NotSupported = "Versioned Foundry agents do not support specifying structured output type at invocation time.";
private const string ResponseFormatNotSupported = "AzureAIProjectChatClient 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)]
[RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)]
public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
{
// Arrange
@@ -39,14 +37,14 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu
}
/// <summary>
/// Verifies that generic RunAsync works with AIProjectClient when structured output is configured at agent initialization.
/// Verifies that generic RunAsync works with versioned Foundry agents when structured output is configured at agent initialization.
/// </summary>
/// <remarks>
/// AIProjectClient does not support specifying the structured output type at invocation time yet.
/// Versioned Foundry agents do not support specifying the structured output type at invocation time yet.
/// The type T provided to RunAsync&lt;T&gt; is ignored by AzureAIProjectChatClient and is only used
/// for deserializing the agent response by AgentResponse&lt;T&gt;.Result.
/// </remarks>
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
[RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = ResponseFormatNotSupported)]
public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync()
{
// Arrange
@@ -88,10 +86,9 @@ public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRu
}
/// <summary>
/// Represents a fixture for testing AIProjectClient with structured output of type <typeparamref name="T"/> provided at agent initialization.
/// Represents a fixture for testing versioned Foundry agents with structured output of type <typeparamref name="T"/> provided at agent initialization.
/// </summary>
[Obsolete("Use FoundryVersionedAgentStructuredOutputFixture instead.")]
public class AIProjectClientStructuredOutputFixture<T> : AIProjectClientFixture
public class FoundryVersionedAgentStructuredOutputFixture<T> : FoundryVersionedAgentFixture
{
public override async ValueTask InitializeAsync()
{
@@ -1,14 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Tests intentionally exercise obsolete extension methods
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 Microsoft.Agents.AI.FoundryMemory.IntegrationTests;
namespace Foundry.IntegrationTests.Memory;
/// <summary>
/// Integration tests for <see cref="FoundryMemoryProvider"/> against a configured Azure AI Foundry Memory service.
@@ -16,7 +20,6 @@ namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests;
/// <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.
/// Set <see cref="SkipReason"/> to null to enable them after configuring the service.
/// </remarks>
public sealed class FoundryMemoryProviderTests : IDisposable
{
@@ -25,6 +28,7 @@ public sealed class FoundryMemoryProviderTests : IDisposable
private readonly AIProjectClient? _client;
private readonly string? _memoryStoreName;
private readonly string? _deploymentName;
private readonly string? _embeddingDeploymentName;
private bool _disposed;
public FoundryMemoryProviderTests()
@@ -38,13 +42,15 @@ public sealed class FoundryMemoryProviderTests : IDisposable
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), TestAzureCliCredentials.CreateAzureCliCredential());
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";
}
}
@@ -57,8 +63,17 @@ public sealed class FoundryMemoryProviderTests : IDisposable
this._memoryStoreName!,
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-user-1")));
AIAgent agent = await this._client!.CreateAIAgentAsync(this._deploymentName!,
options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider] });
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();
@@ -72,6 +87,15 @@ public sealed class FoundryMemoryProviderTests : IDisposable
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
@@ -95,10 +119,27 @@ public sealed class FoundryMemoryProviderTests : IDisposable
this._memoryStoreName!,
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-b")));
AIAgent agent1 = await this._client!.CreateAIAgentAsync(this._deploymentName!,
options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider1] });
AIAgent agent2 = await this._client!.CreateAIAgentAsync(this._deploymentName!,
options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider2] });
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();
@@ -111,8 +152,25 @@ public sealed class FoundryMemoryProviderTests : IDisposable
await memoryProvider1.WhenUpdatesCompletedAsync();
await Task.Delay(2000);
AgentResponse result1 = await agent1.RunAsync("What is your name?", session1);
AgentResponse result2 = await agent2.RunAsync("What is your name?", session2);
// 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);
@@ -3,7 +3,7 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
public class ResponsesAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests<ResponsesAgentFixture>(() => new())
{
@@ -3,7 +3,7 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
public class ResponsesAgentChatClientRunTests() : ChatClientAgentRunTests<ResponsesAgentFixture>(() => new())
{
@@ -3,13 +3,13 @@
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.Agents.AI.AzureAI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
/// <summary>
/// Integration tests for non-versioned <see cref="ChatClientAgent"/> creation via <see cref="AIProjectClient"/> extension methods.
@@ -30,16 +30,19 @@ public class ResponsesAgentExtensionCreateTests
const string AgentDescription = "Integration test agent created from AIProjectClient.AsAIAgent(model, instructions).";
const string VerificationToken = "integration-extension-ok";
FoundryAgent agent = this._client.AsAIAgent(
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 = await agent.CreateSessionAsync();
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);
@@ -47,7 +50,6 @@ public class ResponsesAgentExtensionCreateTests
Assert.NotNull(agent);
Assert.Equal(AgentName, agent.Name);
Assert.Equal(AgentDescription, agent.Description);
Assert.Same(this._client, agent.GetService<AIProjectClient>());
Assert.NotNull(agent.GetService<IChatClient>());
Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase);
}
@@ -73,19 +75,22 @@ public class ResponsesAgentExtensionCreateTests
},
};
FoundryAgent agent = this._client.AsAIAgent(options);
ChatClientAgentSession session = await agent.CreateConversationSessionAsync();
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.StartsWith("conv_", session!.ConversationId, StringComparison.OrdinalIgnoreCase);
Assert.Equal(options.Name, agent.Name);
Assert.Equal(options.Description, agent.Description);
Assert.Same(this._client, agent.GetService<AIProjectClient>());
Assert.Contains(VerificationToken, response.Text, StringComparison.OrdinalIgnoreCase);
}
finally
@@ -94,8 +99,13 @@ public class ResponsesAgentExtensionCreateTests
}
}
private static async Task DeleteSessionAsync(AIProjectClient client, AgentSession 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)
@@ -119,4 +129,10 @@ public class ResponsesAgentExtensionCreateTests
await DeleteResponseChainAsync(client, response.Value.PreviousResponseId);
}
}
private static async Task<ProjectConversation> CreateConversationAsync(AIProjectClient client)
{
ProjectConversationsClient conversationsClient = client.GetProjectOpenAIClient().GetProjectConversationsClient();
return (await conversationsClient.CreateProjectConversationAsync()).Value!;
}
}
@@ -9,19 +9,18 @@ using AgentConformance.IntegrationTests.Support;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AzureAI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace AzureAI.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 FoundryAgent _agent = null!;
private ChatClientAgent _agent = null!;
private AIProjectClient _client = null!;
public IChatClient ChatClient => this._agent.GetService<ChatClientAgent>()!.ChatClient;
@@ -5,7 +5,7 @@ using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using Microsoft.Agents.AI;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
public class ResponsesAgentRunStreamingPreviousResponseTests() : RunStreamingTests<ResponsesAgentFixture>(() => new())
{
@@ -5,7 +5,7 @@ using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
using Microsoft.Agents.AI;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
public class ResponsesAgentRunPreviousResponseTests() : RunTests<ResponsesAgentFixture>(() => new())
{
@@ -7,7 +7,7 @@ using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;
namespace AzureAI.IntegrationTests;
namespace Foundry.IntegrationTests;
public class ResponsesAgentStructuredOutputRunTests() : StructuredOutputRunTests<ResponsesAgentStructuredOutputFixture<CityInfo>>(() => new())
{
@@ -6,33 +6,35 @@ using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
#pragma warning disable CS0618
[Obsolete("Uses obsolete AIProjectClient.GetAIAgentAsync compatibility extensions while validating chat-client behavior.")]
public class AzureAIProjectChatClientTests
{
/// <summary>
/// Verify that when the ChatOptions has a "conv_" prefixed conversation ID, the chat client uses conversation in the http requests via the chat client
/// Verify that after the first RunAsync, the session's ConversationId is set from the
/// response, and subsequent requests include that conversation ID automatically.
/// </summary>
[Fact]
public async Task ChatClient_UsesDefaultConversationIdAsync()
{
// Arrange
var requestTriggered = false;
var responsesRequestCount = 0;
using var httpHandler = new HttpHandlerAssert(async (request) =>
{
if (request.Method == HttpMethod.Post && request.RequestUri!.PathAndQuery.Contains("/responses"))
{
requestTriggered = true;
responsesRequestCount++;
// Assert
if (request.Content is not null)
// Assert: On the second Responses API call, verify the conversation ID
// from the first response is automatically included in the request body.
if (responsesRequestCount == 2 && request.Content is not null)
{
var requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.Contains("conv_12345", requestBody);
Assert.Contains("resp_0888a", requestBody);
}
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json") };
@@ -50,20 +52,17 @@ public class AzureAIProjectChatClientTests
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
var agent = await projectClient.GetAIAgentAsync(
new ChatClientAgentOptions
{
Name = "test-agent",
ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_12345" }
});
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
// Act
var session = await agent.CreateSessionAsync();
await agent.RunAsync("Hello", session);
await agent.RunAsync("Follow up", session);
Assert.True(requestTriggered);
// Assert
Assert.Equal(2, responsesRequestCount);
var chatClientSession = Assert.IsType<ChatClientAgentSession>(session);
Assert.Equal("conv_12345", chatClientSession.ConversationId);
Assert.Equal("resp_0888a46cbf2b1ff3006914596e05d08195a77c3f5187b769a7", chatClientSession.ConversationId);
}
/// <summary>
@@ -102,12 +101,7 @@ public class AzureAIProjectChatClientTests
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
var agent = await projectClient.GetAIAgentAsync(
new ChatClientAgentOptions
{
Name = "test-agent",
ChatOptions = new() { Instructions = "Test instructions" },
});
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
// Act
var session = await agent.CreateSessionAsync();
@@ -154,12 +148,7 @@ public class AzureAIProjectChatClientTests
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
var agent = await projectClient.GetAIAgentAsync(
new ChatClientAgentOptions
{
Name = "test-agent",
ChatOptions = new() { Instructions = "Test instructions", ConversationId = "conv_should_not_use_default" }
});
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
// Act
var session = await agent.CreateSessionAsync();
@@ -206,12 +195,7 @@ public class AzureAIProjectChatClientTests
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions() { Transport = new HttpClientPipelineTransport(httpClient) });
var agent = await projectClient.GetAIAgentAsync(
new ChatClientAgentOptions
{
Name = "test-agent",
ChatOptions = new() { Instructions = "Test instructions" },
});
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
// Act
var session = await agent.CreateSessionAsync();
@@ -7,7 +7,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider
{
@@ -9,7 +9,7 @@ using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for the <see cref="FoundryAgent"/> class.
@@ -5,7 +5,7 @@ using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
internal sealed class HttpHandlerAssert : HttpClientHandler
{
@@ -2,7 +2,7 @@
using System;
namespace Microsoft.Agents.AI.FoundryMemory.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory;
/// <summary>
/// Tests for <see cref="FoundryMemoryProvider"/> constructor validation.
@@ -11,7 +11,7 @@ using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.Core;
namespace Microsoft.Agents.AI.FoundryMemory.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory;
/// <summary>
/// Creates a testable AIProjectClient with a mock HTTP handler.
@@ -1,7 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
<ItemGroup>
@@ -6,7 +6,7 @@ using Azure.AI.Extensions.OpenAI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ProjectResponsesClientExtensions"/> class.
@@ -4,7 +4,7 @@ using System.ClientModel.Primitives;
using System.IO;
using Azure.AI.Projects.Agents;
namespace Microsoft.Agents.AI.AzureAI.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Utility class for loading and processing test data files.
@@ -29,7 +29,7 @@ internal static class TestDataUtil
/// <summary>
/// Gets the agent response JSON with optional placeholder replacements applied.
/// </summary>
public static string GetAgentResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
public static string GetAgentResponseJson(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentResponseJson;
json = ApplyAgentName(json, agentName);
@@ -42,7 +42,7 @@ internal static class TestDataUtil
/// <summary>
/// Gets the agent version response JSON with optional placeholder replacements applied.
/// </summary>
public static string GetAgentVersionResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
public static string GetAgentVersionResponseJson(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentVersionResponseJson;
json = ApplyAgentName(json, agentName);
@@ -55,7 +55,7 @@ internal static class TestDataUtil
/// <summary>
/// Gets the agent version response JSON with empty version and ID fields for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentVersionResponseJsonWithEmptyVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
public static string GetAgentVersionResponseJsonWithEmptyVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentVersionResponseJson;
json = ApplyAgentName(json, agentName);
@@ -71,7 +71,7 @@ internal static class TestDataUtil
/// <summary>
/// Gets the agent response JSON with empty version and ID fields in the latest version for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentResponseJsonWithEmptyVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
public static string GetAgentResponseJsonWithEmptyVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentResponseJson;
json = ApplyAgentName(json, agentName);
@@ -87,7 +87,7 @@ internal static class TestDataUtil
/// <summary>
/// Gets the agent version response JSON with whitespace-only version and ID fields for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentVersionResponseJsonWithWhitespaceVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
public static string GetAgentVersionResponseJsonWithWhitespaceVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentVersionResponseJson;
json = ApplyAgentName(json, agentName);
@@ -103,7 +103,7 @@ internal static class TestDataUtil
/// <summary>
/// Gets the agent response JSON with whitespace-only version and ID fields in the latest version for testing hosted agents like MCP agents.
/// </summary>
public static string GetAgentResponseJsonWithWhitespaceVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
public static string GetAgentResponseJsonWithWhitespaceVersion(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_agentResponseJson;
json = ApplyAgentName(json, agentName);
@@ -119,7 +119,7 @@ internal static class TestDataUtil
/// <summary>
/// Gets the OpenAI default response JSON with optional placeholder replacements applied.
/// </summary>
public static string GetOpenAIDefaultResponseJson(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
public static string GetOpenAIDefaultResponseJson(string? agentName = null, ProjectsAgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
{
var json = s_openAIDefaultResponseJson;
json = ApplyAgentName(json, agentName);
@@ -138,7 +138,7 @@ internal static class TestDataUtil
return json;
}
private static string ApplyAgentDefinition(string json, AgentDefinition? definition)
private static string ApplyAgentDefinition(string json, ProjectsAgentDefinition? definition)
{
return (definition is not null)
? json.Replace(AgentDefinitionPlaceholder, ModelReaderWriter.Write(definition).ToString())
@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
<InjectSharedIntegrationTestAzureCredentialsCode>True</InjectSharedIntegrationTestAzureCredentialsCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
</ItemGroup>
</Project>
@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
</Project>
@@ -0,0 +1,382 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentClassSkill"/> and <see cref="AgentInMemorySkillsSource"/>.
/// </summary>
public sealed class AgentClassSkillTests
{
[Fact]
public void Resources_DefaultsToNull_WhenNotOverridden()
{
// Arrange
var skill = new MinimalClassSkill();
// Act & Assert
Assert.Null(skill.Resources);
}
[Fact]
public void Scripts_DefaultsToNull_WhenNotOverridden()
{
// Arrange
var skill = new MinimalClassSkill();
// Act & Assert
Assert.Null(skill.Scripts);
}
[Fact]
public void Resources_ReturnsOverriddenList_WhenOverridden()
{
// Arrange
var skill = new FullClassSkill();
// Act
var resources = skill.Resources;
// Assert
Assert.Single(resources!);
Assert.Equal("test-resource", resources![0].Name);
}
[Fact]
public void Scripts_ReturnsOverriddenList_WhenOverridden()
{
// Arrange
var skill = new FullClassSkill();
// Act
var scripts = skill.Scripts;
// Assert
Assert.Single(scripts!);
Assert.Equal("TestScript", scripts![0].Name);
}
[Fact]
public void ResourcesAndScripts_CanBeLazyLoaded_AndCached()
{
// Arrange
var skill = new LazyLoadedSkill();
// Act & Assert
Assert.Equal(0, skill.ResourceCreationCount);
Assert.Equal(0, skill.ScriptCreationCount);
var firstResources = skill.Resources;
var firstScripts = skill.Scripts;
var secondResources = skill.Resources;
var secondScripts = skill.Scripts;
Assert.Single(firstResources!);
Assert.Single(firstScripts!);
Assert.Same(firstResources, secondResources);
Assert.Same(firstScripts, secondScripts);
Assert.Equal(1, skill.ResourceCreationCount);
Assert.Equal(1, skill.ScriptCreationCount);
}
[Fact]
public void Name_Content_ReturnClassDefinedValues()
{
// Arrange
var skill = new MinimalClassSkill();
// Act & Assert
Assert.Equal("minimal", skill.Frontmatter.Name);
Assert.Contains("<instructions>", skill.Content);
Assert.Contains("Minimal skill body.", skill.Content);
Assert.Contains("</instructions>", skill.Content);
}
[Fact]
public void Content_ReturnsSynthesizedXmlDocument()
{
// Arrange
var skill = new MinimalClassSkill();
// Act & Assert
Assert.Contains("<name>minimal</name>", skill.Content);
Assert.Contains("<description>A minimal skill.</description>", skill.Content);
Assert.Contains("<instructions>", skill.Content);
Assert.Contains("Minimal skill body.", skill.Content);
}
[Fact]
public async Task AgentInMemorySkillsSource_ReturnsAllSkillsAsync()
{
// Arrange
var skills = new AgentClassSkill[] { new MinimalClassSkill(), new FullClassSkill() };
var source = new AgentInMemorySkillsSource(skills);
// Act
var result = await source.GetSkillsAsync(CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("minimal", result[0].Frontmatter.Name);
Assert.Equal("full", result[1].Frontmatter.Name);
}
[Fact]
public void AgentClassSkill_InvalidFrontmatter_ThrowsArgumentException()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter("INVALID-NAME", "An invalid skill."));
}
[Fact]
public void SkillWithOnlyResources_HasNullScripts()
{
// Arrange
var skill = new ResourceOnlySkill();
// Act & Assert
Assert.Single(skill.Resources!);
Assert.Null(skill.Scripts);
}
[Fact]
public void SkillWithOnlyScripts_HasNullResources()
{
// Arrange
var skill = new ScriptOnlySkill();
// Act & Assert
Assert.Null(skill.Resources);
Assert.Single(skill.Scripts!);
}
[Fact]
public void Content_ReturnsCachedInstance_OnRepeatedAccess()
{
// Arrange
var skill = new FullClassSkill();
// Act
var first = skill.Content;
var second = skill.Content;
// Assert
Assert.Same(first, second);
}
[Fact]
public void Content_IncludesParametersSchema_WhenScriptsHaveParameters()
{
// Arrange
var skill = new FullClassSkill();
// Act
var content = skill.Content;
// Assert — scripts with typed parameters should have their schema included
Assert.Contains("parameters_schema", content);
Assert.Contains("value", content);
}
[Fact]
public void Content_IncludesDerivedResources_WhenResourcesUseBaseTypeOverrides()
{
// Arrange
var skill = new DerivedResourceSkill();
// Act
var content = skill.Content;
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("custom-resource", content);
Assert.Contains("Custom resource description.", content);
}
[Fact]
public void Content_IncludesDerivedScripts_WhenScriptsUseBaseTypeOverrides()
{
// Arrange
var skill = new DerivedScriptSkill();
// Act
var content = skill.Content;
// Assert
Assert.Contains("<scripts>", content);
Assert.Contains("custom-script", content);
Assert.Contains("Custom script description.", content);
}
[Fact]
public void Content_OmitsParametersSchema_WhenDerivedScriptDoesNotProvideOne()
{
// Arrange
var skill = new DerivedScriptSkill();
// Act
var content = skill.Content;
// Assert
Assert.DoesNotContain("parameters_schema", content);
}
#region Test skill classes
private sealed class MinimalClassSkill : AgentClassSkill
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill.");
protected override string Instructions => "Minimal skill body.";
public override IReadOnlyList<AgentSkillResource>? Resources => null;
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
}
private sealed class FullClassSkill : AgentClassSkill
{
private IReadOnlyList<AgentSkillResource>? _resources;
private IReadOnlyList<AgentSkillScript>? _scripts;
public override AgentSkillFrontmatter Frontmatter { get; } = new("full", "A full skill with resources and scripts.");
protected override string Instructions => "Full skill body.";
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
[
CreateResource("test-resource", "resource content"),
];
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
[
CreateScript("TestScript", TestScript),
];
private static string TestScript(double value) =>
JsonSerializer.Serialize(new { result = value * 2 });
}
private sealed class ResourceOnlySkill : AgentClassSkill
{
private IReadOnlyList<AgentSkillResource>? _resources;
public override AgentSkillFrontmatter Frontmatter { get; } = new("resource-only", "Skill with resources only.");
protected override string Instructions => "Body.";
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
[
CreateResource("data", "some data"),
];
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
}
private sealed class ScriptOnlySkill : AgentClassSkill
{
private IReadOnlyList<AgentSkillScript>? _scripts;
public override AgentSkillFrontmatter Frontmatter { get; } = new("script-only", "Skill with scripts only.");
protected override string Instructions => "Body.";
public override IReadOnlyList<AgentSkillResource>? Resources => null;
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
[
CreateScript("ToUpper", (string input) => input.ToUpperInvariant()),
];
}
private sealed class DerivedResourceSkill : AgentClassSkill
{
private IReadOnlyList<AgentSkillResource>? _resources;
public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-resource", "Skill with a derived resource type.");
protected override string Instructions => "Body.";
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
[
new CustomResource("custom-resource", "Custom resource description."),
];
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
}
private sealed class DerivedScriptSkill : AgentClassSkill
{
private IReadOnlyList<AgentSkillScript>? _scripts;
public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-script", "Skill with a derived script type.");
protected override string Instructions => "Body.";
public override IReadOnlyList<AgentSkillResource>? Resources => null;
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
[
new CustomScript("custom-script", "Custom script description."),
];
}
private sealed class LazyLoadedSkill : AgentClassSkill
{
private IReadOnlyList<AgentSkillResource>? _resources;
private IReadOnlyList<AgentSkillScript>? _scripts;
public override AgentSkillFrontmatter Frontmatter { get; } = new("lazy-loaded", "Skill with lazily created resources and scripts.");
protected override string Instructions => "Body.";
public int ResourceCreationCount { get; private set; }
public int ScriptCreationCount { get; private set; }
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??= this.CreateResources();
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??= this.CreateScripts();
private IReadOnlyList<AgentSkillResource> CreateResources()
{
this.ResourceCreationCount++;
return [CreateResource("lazy-resource", "resource content")];
}
private IReadOnlyList<AgentSkillScript> CreateScripts()
{
this.ScriptCreationCount++;
return [CreateScript("LazyScript", () => "done")];
}
}
private sealed class CustomResource : AgentSkillResource
{
public CustomResource(string name, string? description = null)
: base(name, description)
{
}
public override Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
=> Task.FromResult<object?>("resource-value");
}
private sealed class CustomScript : AgentSkillScript
{
public CustomScript(string name, string? description = null)
: base(name, description)
{
}
public override Task<object?> RunAsync(AgentSkill skill, Extensions.AI.AIFunctionArguments arguments, CancellationToken cancellationToken = default)
=> Task.FromResult<object?>("script-result");
}
#endregion
}
@@ -851,6 +851,61 @@ public sealed class AgentSkillsProviderTests : IDisposable
Assert.Contains("First instructions.", content!.ToString()!);
}
[Fact]
public async Task Constructor_ClassSkillsParams_ProvidesSkillsAsync()
{
// Arrange
var skill = new TestClassSkill("class-a", "Class A", "Class instructions.");
var provider = new AgentSkillsProvider(skill);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(result.Instructions);
Assert.Contains("class-a", result.Instructions);
}
[Fact]
public async Task Constructor_ClassSkillsEnumerable_ProvidesSkillsAsync()
{
// Arrange
var skills = new List<AgentClassSkill>
{
new TestClassSkill("enum-class-a", "Class A", "Instructions A."),
new TestClassSkill("enum-class-b", "Class B", "Instructions B."),
};
var provider = new AgentSkillsProvider(skills);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(result.Instructions);
Assert.Contains("enum-class-a", result.Instructions);
Assert.Contains("enum-class-b", result.Instructions);
}
[Fact]
public async Task Constructor_ClassSkills_DeduplicatesAsync()
{
// Arrange — two class skills with the same name
var skill1 = new TestClassSkill("dup-class", "First", "First instructions.");
var skill2 = new TestClassSkill("dup-class", "Second", "Second instructions.");
var provider = new AgentSkillsProvider(skill1, skill2);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "dup-class" }));
// Assert — only first occurrence survives
Assert.Contains("First instructions.", content!.ToString()!);
}
/// <summary>
/// A test skill source that counts how many times <see cref="GetSkillsAsync"/> is called.
/// </summary>
@@ -872,4 +927,23 @@ public sealed class AgentSkillsProviderTests : IDisposable
return Task.FromResult(this._skills);
}
}
private sealed class TestClassSkill : AgentClassSkill
{
private readonly string _instructions;
public TestClassSkill(string name, string description, string instructions)
{
this.Frontmatter = new AgentSkillFrontmatter(name, description);
this._instructions = instructions;
}
public override AgentSkillFrontmatter Frontmatter { get; }
protected override string Instructions => this._instructions;
public override IReadOnlyList<AgentSkillResource>? Resources => null;
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
}
}
@@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
/// end-to-end behavior with <see cref="ServiceStoredSimulatingChatClient"/> and
/// end-to-end behavior with <see cref="PerServiceCallChatHistoryPersistingChatClient"/> and
/// <see cref="FunctionInvokingChatClient"/>.
/// </summary>
internal static class ChatClientAgentTestHelper
@@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests that verify the end-to-end approval flow behavior of the
/// <see cref="ChatClientAgent"/> class with <see cref="ServiceStoredSimulatingChatClient"/>,
/// <see cref="ChatClientAgent"/> class with <see cref="PerServiceCallChatHistoryPersistingChatClient"/>,
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
/// </summary>
public class ChatClientAgent_ApprovalsTests
@@ -48,7 +48,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -260,7 +260,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -520,7 +520,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 1,
expectedHistory:
@@ -554,7 +554,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 2,
expectedHistory:
@@ -13,15 +13,15 @@ using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the <see cref="ServiceStoredSimulatingChatClient"/> decorator,
/// Contains unit tests for the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator,
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
/// individual service call by default, or marks messages for end-of-run persistence when the
/// <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> option is enabled.
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> option is enabled.
/// </summary>
public class ServiceStoredSimulatingChatClientTests
public class PerServiceCallChatHistoryPersistingChatClientTests
{
/// <summary>
/// Verifies that by default (SimulateServiceStoredChatHistory is false),
/// Verifies that by default (RequirePerServiceCallChatHistoryPersistence is false),
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
/// </summary>
[Fact]
@@ -50,7 +50,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -97,7 +97,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -145,7 +145,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -163,7 +163,7 @@ public class ServiceStoredSimulatingChatClientTests
}
/// <summary>
/// Verifies that the decorator is NOT injected by default (SimulateServiceStoredChatHistory is false).
/// Verifies that the decorator is NOT injected by default (RequirePerServiceCallChatHistoryPersistence is false).
/// </summary>
[Fact]
public void ChatClient_DoesNotContainDecorator_ByDefault()
@@ -175,15 +175,15 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new());
// Assert
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the decorator is injected when SimulateServiceStoredChatHistory is true.
/// Verifies that the decorator is injected when RequirePerServiceCallChatHistoryPersistence is true.
/// </summary>
[Fact]
public void ChatClient_ContainsDecorator_WhenSimulateServiceStoredChatHistory()
public void ChatClient_ContainsDecorator_WhenRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -191,11 +191,11 @@ public class ServiceStoredSimulatingChatClientTests
// Act
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Assert
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.NotNull(decorator);
}
@@ -215,27 +215,27 @@ public class ServiceStoredSimulatingChatClientTests
});
// Assert
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the SimulateServiceStoredChatHistory option is included in Clone().
/// Verifies that the RequirePerServiceCallChatHistoryPersistence option is included in Clone().
/// </summary>
[Fact]
public void ChatClientAgentOptions_Clone_IncludesSimulateServiceStoredChatHistory()
public void ChatClientAgentOptions_Clone_IncludesRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
var options = new ChatClientAgentOptions
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
};
// Act
var cloned = options.Clone();
// Assert
Assert.True(cloned.SimulateServiceStoredChatHistory);
Assert.True(cloned.RequirePerServiceCallChatHistoryPersistence);
}
/// <summary>
@@ -289,7 +289,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -358,7 +358,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -407,7 +407,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -454,7 +454,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -513,7 +513,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object],
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -587,7 +587,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -652,7 +652,7 @@ public class ServiceStoredSimulatingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -720,8 +720,8 @@ public class ServiceStoredSimulatingChatClientTests
/// <summary>
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
/// <see cref="ChatClientAgent"/> sets the <see cref="ServiceStoredSimulatingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="ServiceStoredSimulatingChatClient"/> strips it before
/// <see cref="ChatClientAgent"/> sets the <see cref="PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="PerServiceCallChatHistoryPersistingChatClient"/> strips it before
/// forwarding to the inner client.
/// </summary>
[Fact]
@@ -741,7 +741,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -773,7 +773,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -808,7 +808,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Create a session with a real conversation ID.
@@ -842,7 +842,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -862,7 +862,7 @@ public class ServiceStoredSimulatingChatClientTests
/// skip provider resolution in the agent (the decorator handles it).
/// </summary>
[Fact]
public async Task RunAsync_SetsSentinelOnSession_WhenSimulateServiceStoredChatHistoryActiveAsync()
public async Task RunAsync_SetsSentinelOnSession_WhenRequirePerServiceCallChatHistoryPersistenceActiveAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -875,7 +875,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -883,7 +883,7 @@ public class ServiceStoredSimulatingChatClientTests
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — session should have the sentinel conversation ID
Assert.Equal(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
Assert.Equal(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
@@ -924,7 +924,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act & Assert — conflict detection should throw
@@ -969,7 +969,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1025,7 +1025,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1077,7 +1077,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1137,7 +1137,7 @@ public class ServiceStoredSimulatingChatClientTests
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1192,7 +1192,7 @@ public class ServiceStoredSimulatingChatClientTests
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
@@ -1253,7 +1253,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -1270,7 +1270,7 @@ public class ServiceStoredSimulatingChatClientTests
Assert.Equal("test", messageList[0].Text);
// Assert — session should NOT have the sentinel (agent handles ConversationId at end-of-run)
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
@@ -1291,7 +1291,7 @@ public class ServiceStoredSimulatingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
SimulateServiceStoredChatHistory = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -1309,6 +1309,6 @@ public class ServiceStoredSimulatingChatClientTests
Assert.NotEmpty(updates);
// Assert — session should NOT have the sentinel
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
}
@@ -35,13 +35,13 @@ internal abstract class AgentProvider(IConfiguration configuration)
{
Uri foundryEndpoint = new(this.GetSetting(TestSettings.AzureAIProjectEndpoint));
await foreach (AgentVersion agent in this.CreateAgentsAsync(foundryEndpoint))
await foreach (ProjectsAgentVersion agent in this.CreateAgentsAsync(foundryEndpoint))
{
Console.WriteLine($"Created agent: {agent.Name}:{agent.Version}");
}
}
protected abstract IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint);
protected abstract IAsyncEnumerable<ProjectsAgentVersion> CreateAgentsAsync(Uri foundryEndpoint);
protected string GetSetting(string settingName) =>
configuration[settingName] ??
@@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class FunctionToolAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
protected override async IAsyncEnumerable<ProjectsAgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
MenuPlugin menuPlugin = new();
AIFunction[] functions =
@@ -33,9 +33,9 @@ internal sealed class FunctionToolAgentProvider(IConfiguration configuration) :
agentDescription: "Provides information about the restaurant menu");
}
private PromptAgentDefinition DefineMenuAgent(AIFunction[] functions)
private DeclarativeAgentDefinition DefineMenuAgent(AIFunction[] functions)
{
PromptAgentDefinition agentDefinition =
DeclarativeAgentDefinition agentDefinition =
new(this.GetSetting(TestSettings.AzureAIModelDeploymentName))
{
Instructions =
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class MarketingAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
protected override async IAsyncEnumerable<ProjectsAgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential());
@@ -35,7 +35,7 @@ internal sealed class MarketingAgentProvider(IConfiguration configuration) : Age
agentDescription: "Editor agent for Marketing workflow");
}
private PromptAgentDefinition DefineAnalystAgent() =>
private DeclarativeAgentDefinition DefineAnalystAgent() =>
new(this.GetSetting(TestSettings.AzureAIModelDeploymentName))
{
Instructions =
@@ -47,13 +47,13 @@ internal sealed class MarketingAgentProvider(IConfiguration configuration) : Age
""",
Tools =
{
//AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available
//ProjectsAgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available
// new BingGroundingSearchToolParameters(
// [new BingGroundingSearchConfiguration(this.GetSetting(Settings.FoundryGroundingTool))]))
}
};
private PromptAgentDefinition DefineWriterAgent() =>
private DeclarativeAgentDefinition DefineWriterAgent() =>
new(this.GetSetting(TestSettings.AzureAIModelDeploymentName))
{
Instructions =
@@ -64,7 +64,7 @@ internal sealed class MarketingAgentProvider(IConfiguration configuration) : Age
"""
};
private PromptAgentDefinition DefineEditorAgent() =>
private DeclarativeAgentDefinition DefineEditorAgent() =>
new(this.GetSetting(TestSettings.AzureAIModelDeploymentName))
{
Instructions =
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class MathChatAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
protected override async IAsyncEnumerable<ProjectsAgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential());
@@ -29,7 +29,7 @@ internal sealed class MathChatAgentProvider(IConfiguration configuration) : Agen
agentDescription: "Teacher agent for MathChat workflow");
}
private PromptAgentDefinition DefineStudentAgent() =>
private DeclarativeAgentDefinition DefineStudentAgent() =>
new(this.GetSetting(TestSettings.AzureAIModelDeploymentName))
{
Instructions =
@@ -41,7 +41,7 @@ internal sealed class MathChatAgentProvider(IConfiguration configuration) : Agen
"""
};
private PromptAgentDefinition DefineTeacherAgent() =>
private DeclarativeAgentDefinition DefineTeacherAgent() =>
new(this.GetSetting(TestSettings.AzureAIModelDeploymentName))
{
Instructions =
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class PoemAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
protected override async IAsyncEnumerable<ProjectsAgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential());
@@ -23,7 +23,7 @@ internal sealed class PoemAgentProvider(IConfiguration configuration) : AgentPro
agentDescription: "Authors original poems");
}
private PromptAgentDefinition DefinePoemAgent() =>
private DeclarativeAgentDefinition DefinePoemAgent() =>
new(this.GetSetting(TestSettings.AzureAIModelDeploymentName))
{
Instructions =
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class TestAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
protected override async IAsyncEnumerable<ProjectsAgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential());
@@ -23,6 +23,6 @@ internal sealed class TestAgentProvider(IConfiguration configuration) : AgentPro
agentDescription: "Basic agent");
}
private PromptAgentDefinition DefineMenuAgent() =>
private DeclarativeAgentDefinition DefineMenuAgent() =>
new(this.GetSetting(TestSettings.AzureAIModelDeploymentName));
}
@@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class VisionAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
protected override async IAsyncEnumerable<ProjectsAgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, TestAzureCliCredentials.CreateAzureCliCredential());
@@ -23,7 +23,7 @@ internal sealed class VisionAgentProvider(IConfiguration configuration) : AgentP
agentDescription: "Use computer vision to describe an image or document.");
}
private PromptAgentDefinition DefineVisionAgent() =>
private DeclarativeAgentDefinition DefineVisionAgent() =>
new(this.GetSetting(TestSettings.AzureAIModelDeploymentName))
{
Instructions =
@@ -29,11 +29,11 @@ public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowT
[InlineData("MathChat.yaml", "MathChat.json", true)]
[InlineData("DeepResearch.yaml", "DeepResearch.json", Skip = "Long running")]
public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName, externalConveration);
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "declarative-agents", "workflow-samples", workflowFileName), testcaseFileName, externalConveration);
[Fact(Skip = "Needs template support")]
public Task ValidateMultiTurnAsync() =>
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true);
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "declarative-agents", "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true);
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint)
{
@@ -41,7 +41,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
private static string GetWorkflowPath(string workflowFileName, bool isSample) =>
isSample
? Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName)
? Path.Combine(GetRepoFolder(), "declarative-agents", "workflow-samples", workflowFileName)
: Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName);
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint)
@@ -126,6 +126,13 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
{
hasRequest = true;
}
else
{
// This is a republished event for the request we're already responding to
// (emitted by RepublishUnservicedRequestsAsync during checkpoint resume).
// Skip yielding it so downstream code doesn't treat it as a new pending request.
continue;
}
break;
case ConversationUpdateEvent conversationEvent:
@@ -94,7 +94,7 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o
while (current is not null)
{
if (Directory.Exists(Path.Combine(current.FullName, "workflow-samples")))
if (Directory.Exists(Path.Combine(current.FullName, "declarative-agents", "workflow-samples")))
{
return current.FullName;
}
@@ -10,7 +10,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative.AzureAI\Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
</ItemGroup>
@@ -27,7 +27,7 @@
<None Update="Agents\*.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="$(MSBuildThisFileDirectory)\..\..\..\workflow-samples\Setup\*.yaml" LinkBase="Agents">
<None Include="$(MSBuildThisFileDirectory)\..\..\..\declarative-agents\workflow-samples\Setup\*.yaml" LinkBase="Agents">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
<None Update="Testcases\*.json">
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class AggregatingExecutorTests
{
[Fact]
public async Task AggregatingExecutor_HandleAsync_AggregatesIncrementallyAsync()
{
AggregatingExecutor<string, string> executor = new("sum", (aggregate, input) =>
aggregate == null ? input : $"{aggregate}+{input}");
TestWorkflowContext context = new(executor.Id);
string? result1 = await executor.HandleAsync("a", context, default);
string? result2 = await executor.HandleAsync("b", context, default);
string? result3 = await executor.HandleAsync("c", context, default);
result1.Should().Be("a");
result2.Should().Be("a+b");
result3.Should().Be("a+b+c");
}
[Fact]
public async Task AggregatingExecutor_HandleAsync_FirstCallReceivesNullAggregateAsync()
{
string? receivedAggregate = "sentinel";
AggregatingExecutor<string, string> executor = new("first-call", (aggregate, input) =>
{
receivedAggregate = aggregate;
return input;
});
TestWorkflowContext context = new(executor.Id);
await executor.HandleAsync("hello", context, default);
receivedAggregate.Should().BeNull("the first invocation should receive a null aggregate for reference types");
}
[Fact]
public async Task AggregatingExecutor_HandleAsync_AggregatorReturningNullClearsStateAsync()
{
AggregatingExecutor<string, string> executor = new("nullable", (aggregate, input) =>
input == "clear" ? null : (aggregate ?? "") + input);
TestWorkflowContext context = new(executor.Id);
string? result1 = await executor.HandleAsync("a", context, default);
result1.Should().Be("a");
string? result2 = await executor.HandleAsync("clear", context, default);
result2.Should().BeNull("the aggregator returned null to clear the state");
// After clearing, the next call should receive null aggregate again
string? result3 = await executor.HandleAsync("b", context, default);
result3.Should().Be("b", "the aggregate should restart from null after being cleared");
}
[Fact]
public async Task AggregatingExecutor_HandleAsync_PersistsStateBetweenCallsAsync()
{
AggregatingExecutor<string, string> executor = new("counter", (aggregate, _) =>
aggregate == null ? "1" : $"{int.Parse(aggregate) + 1}");
TestWorkflowContext context = new(executor.Id);
for (int i = 1; i <= 5; i++)
{
string? result = await executor.HandleAsync("tick", context, default);
result.Should().Be($"{i}", "the aggregate should increment with each call");
}
}
}
@@ -0,0 +1,445 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.Sample;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Regression tests for GH-2485: pending <see cref="RequestInfoEvent"/> objects must be
/// re-emitted after resuming a workflow from a checkpoint.
/// </summary>
public class CheckpointResumeTests
{
/// <summary>
/// Verifies that a resumed workflow re-emits <see cref="RequestInfoEvent"/>s for
/// pending external requests that existed at the time of the checkpoint.
/// </summary>
[Theory]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
internal async Task Checkpoint_Resume_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment)
{
// Arrange
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
ForwardMessageExecutor<string> processor = new("Processor");
Workflow workflow = new WorkflowBuilder(requestPort)
.AddEdge(requestPort, processor)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
// Act 1: Run workflow, collect pending requests and a checkpoint.
List<ExternalRequest> originalRequests = [];
CheckpointInfo? checkpoint = null;
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
.RunStreamingAsync(workflow, "Hello"))
{
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
{
if (evt is RequestInfoEvent requestInfo)
{
originalRequests.Add(requestInfo.Request);
}
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
checkpoint = cp;
}
}
originalRequests.Should().NotBeEmpty("the workflow should have created at least one external request");
checkpoint.Should().NotBeNull("a checkpoint should have been created");
}
// Act 2: Resume from the checkpoint.
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
.ResumeStreamingAsync(workflow, checkpoint!);
// Assert: The pending requests should be re-emitted.
List<ExternalRequest> reEmittedRequests = [];
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
if (evt is RequestInfoEvent requestInfo)
{
reEmittedRequests.Add(requestInfo.Request);
}
}
reEmittedRequests.Should().HaveCount(originalRequests.Count,
"all pending requests from the checkpoint should be re-emitted after resume");
reEmittedRequests.Select(r => r.RequestId)
.Should().BeEquivalentTo(originalRequests.Select(r => r.RequestId),
"the re-emitted request IDs should match the original pending request IDs");
}
/// <summary>
/// Verifies that <see cref="RunStatus"/> transitions to <see cref="RunStatus.PendingRequests"/>
/// after resuming from a checkpoint with pending external requests (not stuck at NotStarted).
/// </summary>
[Theory]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
internal async Task Checkpoint_Resume_WithPendingRequests_RunStatusIsPendingRequestsAsync(ExecutionEnvironment environment)
{
// Arrange
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
ForwardMessageExecutor<string> processor = new("Processor");
Workflow workflow = new WorkflowBuilder(requestPort)
.AddEdge(requestPort, processor)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
// First run: collect a checkpoint with pending requests.
CheckpointInfo? checkpoint = null;
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
.RunStreamingAsync(workflow, "Hello"))
{
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
{
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
checkpoint = cp;
}
}
checkpoint.Should().NotBeNull();
}
// Act: Resume from the checkpoint and consume events so the run loop processes.
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
.ResumeStreamingAsync(workflow, checkpoint!);
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
await foreach (WorkflowEvent _ in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
// Consume all events until the stream completes.
}
// Assert
RunStatus status = await resumed.GetStatusAsync();
status.Should().Be(RunStatus.PendingRequests,
"the resumed workflow should report PendingRequests after rehydration");
}
/// <summary>
/// Verifies the full roundtrip: resume from checkpoint, observe the re-emitted request,
/// send a response, and verify the workflow completes without duplicating the request.
/// </summary>
[Theory]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
internal async Task Checkpoint_Resume_RespondToPendingRequest_CompletesWithoutDuplicateAsync(ExecutionEnvironment environment)
{
// Arrange
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
ForwardMessageExecutor<string> processor = new("Processor");
Workflow workflow = new WorkflowBuilder(requestPort)
.AddEdge(requestPort, processor)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
// First run: collect checkpoint + pending request.
ExternalRequest? pendingRequest = null;
CheckpointInfo? checkpoint = null;
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
.RunStreamingAsync(workflow, "Hello"))
{
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
{
if (evt is RequestInfoEvent requestInfo)
{
pendingRequest = requestInfo.Request;
}
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
checkpoint = cp;
}
}
pendingRequest.Should().NotBeNull();
checkpoint.Should().NotBeNull();
}
// Act: Resume and respond to the restored request.
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
.ResumeStreamingAsync(workflow, checkpoint!);
int requestEventCount = 0;
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
// Use blockOnPendingRequest: false for the first pass to see the re-emitted requests.
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
if (evt is RequestInfoEvent requestInfo)
{
requestEventCount++;
requestInfo.Request.RequestId.Should().Be(pendingRequest!.RequestId,
"the re-emitted request should match the original");
}
}
requestEventCount.Should().Be(1,
"the pending request should be emitted exactly once (no duplicates)");
// Assert intermediate state before responding: the run should be in PendingRequests
// and we should have observed the re-emitted request. If the first WatchStreamAsync
// didn't complete or yielded nothing, these assertions catch it with a clear message.
RunStatus statusBeforeResponse = await resumed.GetStatusAsync();
statusBeforeResponse.Should().Be(RunStatus.PendingRequests,
"the run should be in PendingRequests state before we send a response");
// Now send the response and verify the workflow processes it.
ExternalResponse response = pendingRequest!.CreateResponse("World");
await resumed.SendResponseAsync(response);
// Consume the resulting events to verify the workflow progresses without errors.
List<WorkflowEvent> postResponseEvents = [];
using CancellationTokenSource cts2 = new(TimeSpan.FromSeconds(10));
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts2.Token))
{
postResponseEvents.Add(evt);
}
postResponseEvents.Should().NotBeEmpty(
"the workflow should process the response and produce events");
postResponseEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
"no errors should occur when processing the restored request's response");
}
/// <summary>
/// Verifies that restoring a live run to a checkpoint re-emits pending requests and allows
/// the workflow to continue from that restored point.
/// </summary>
[Theory]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
internal async Task Checkpoint_Restore_WithPendingRequests_RepublishesRequestInfoEventsAsync(ExecutionEnvironment environment)
{
// Arrange
Workflow workflow = CreateSimpleRequestWorkflow();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
await using StreamingRun run = await env.WithCheckpointing(checkpointManager)
.RunStreamingAsync(workflow, "Hello");
(ExternalRequest pendingRequest, CheckpointInfo checkpoint) = await CapturePendingRequestAndCheckpointAsync(run);
// Advance the run past the checkpoint so the restore has meaningful work to undo.
await run.SendResponseAsync(pendingRequest.CreateResponse("World"));
List<WorkflowEvent> firstCompletionEvents = await ReadToHaltAsync(run);
firstCompletionEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
"the workflow should continue cleanly before we restore");
RunStatus statusAfterFirstResponse = await run.GetStatusAsync();
statusAfterFirstResponse.Should().Be(RunStatus.Idle,
"the workflow should finish processing the first response before we restore");
// Act
await run.RestoreCheckpointAsync(checkpoint);
// Assert
List<WorkflowEvent> restoredEvents = await ReadToHaltAsync(run);
ExternalRequest[] replayedRequests = [.. restoredEvents.OfType<RequestInfoEvent>().Select(evt => evt.Request)];
replayedRequests.Should().ContainSingle("runtime restore should re-emit the restored pending request");
replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId,
"the replayed request should match the request captured at the checkpoint");
await run.SendResponseAsync(replayedRequests[0].CreateResponse("Again"));
List<WorkflowEvent> secondCompletionEvents = await ReadToHaltAsync(run);
secondCompletionEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
"runtime restore replay should not introduce workflow errors");
RunStatus statusAfterRestoreResponse = await run.GetStatusAsync();
statusAfterRestoreResponse.Should().Be(RunStatus.Idle,
"the workflow should be able to continue after the runtime restore replay");
}
/// <summary>
/// Verifies that a resumed parent workflow re-emits pending requests that originated in a subworkflow.
/// </summary>
[Theory]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
internal async Task Checkpoint_Resume_SubworkflowWithPendingRequests_RepublishesQualifiedRequestInfoEventsAsync(ExecutionEnvironment environment)
{
// Arrange
Workflow workflow = CreateCheckpointedSubworkflowRequestWorkflow();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
ExternalRequest pendingRequest;
CheckpointInfo checkpoint;
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
.RunStreamingAsync(workflow, "Hello"))
{
(pendingRequest, checkpoint) = await CapturePendingRequestAndCheckpointAsync(firstRun);
}
// Act
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
.ResumeStreamingAsync(workflow, checkpoint);
// Assert
List<WorkflowEvent> resumedEvents = await ReadToHaltAsync(resumed);
ExternalRequest[] replayedRequests = [.. resumedEvents.OfType<RequestInfoEvent>().Select(evt => evt.Request)];
replayedRequests.Should().ContainSingle("the resumed parent workflow should surface the subworkflow request once");
replayedRequests[0].RequestId.Should().Be(pendingRequest.RequestId,
"the replayed subworkflow request should match the checkpointed request");
replayedRequests[0].PortInfo.PortId.Should().Be(pendingRequest.PortInfo.PortId,
"the replayed request should remain qualified through the subworkflow boundary");
await resumed.SendResponseAsync(replayedRequests[0].CreateResponse("World"));
List<WorkflowEvent> completionEvents = await ReadToHaltAsync(resumed);
completionEvents.OfType<RequestInfoEvent>().Should().BeEmpty(
"the resumed subworkflow request should not be replayed twice");
completionEvents.OfType<WorkflowErrorEvent>().Should().BeEmpty(
"subworkflow replay should not introduce workflow errors");
RunStatus statusAfterSubworkflowResponse = await resumed.GetStatusAsync();
statusAfterSubworkflowResponse.Should().Be(RunStatus.Idle,
"the resumed subworkflow should continue after responding to the replayed request");
}
/// <summary>
/// Verifies that when <c>republishPendingEvents</c> is <see langword="false"/>,
/// no <see cref="RequestInfoEvent"/> is re-emitted after resuming from a checkpoint.
/// </summary>
[Theory]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
internal async Task Checkpoint_Resume_WithRepublishDisabled_DoesNotEmitRequestInfoEventsAsync(ExecutionEnvironment environment)
{
// Arrange
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
ForwardMessageExecutor<string> processor = new("Processor");
Workflow workflow = new WorkflowBuilder(requestPort)
.AddEdge(requestPort, processor)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
// First run: collect a checkpoint with pending requests.
CheckpointInfo? checkpoint = null;
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
.RunStreamingAsync(workflow, "Hello"))
{
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
{
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
checkpoint = cp;
}
}
checkpoint.Should().NotBeNull();
}
// Act: Resume with republishPendingEvents: false via the internal API.
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
.ResumeStreamingInternalAsync(workflow, checkpoint!, republishPendingEvents: false);
// Assert: No RequestInfoEvent should appear in the event stream.
int requestEventCount = 0;
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
if (evt is RequestInfoEvent)
{
requestEventCount++;
}
}
requestEventCount.Should().Be(0,
"no RequestInfoEvent should be emitted when republishPendingEvents is false");
}
private static Workflow CreateSimpleRequestWorkflow(
string requestPortId = "TestPort",
string processorId = "Processor")
{
RequestPort<string, string> requestPort = RequestPort.Create<string, string>(requestPortId);
ForwardMessageExecutor<string> processor = new(processorId);
return new WorkflowBuilder(requestPort)
.AddEdge(requestPort, processor)
.Build();
}
private static Workflow CreateCheckpointedSubworkflowRequestWorkflow()
{
ExecutorBinding subworkflow = CreateSimpleRequestWorkflow(
requestPortId: "InnerTestPort",
processorId: "InnerProcessor")
.BindAsExecutor("Subworkflow");
return new WorkflowBuilder(subworkflow)
.AddExternalRequest<string, string>(subworkflow, id: "ForwardedSubworkflowRequest")
.Build();
}
private static async ValueTask<(ExternalRequest PendingRequest, CheckpointInfo Checkpoint)> CapturePendingRequestAndCheckpointAsync(StreamingRun run)
{
ExternalRequest? pendingRequest = null;
CheckpointInfo? checkpoint = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false))
{
if (evt is RequestInfoEvent requestInfo)
{
pendingRequest ??= requestInfo.Request;
}
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
checkpoint = cp;
}
}
pendingRequest.Should().NotBeNull("the workflow should have emitted a pending request");
checkpoint.Should().NotBeNull("the workflow should have produced a checkpoint");
return (pendingRequest!, checkpoint!);
}
private static async ValueTask<List<WorkflowEvent>> ReadToHaltAsync(StreamingRun run)
{
List<WorkflowEvent> events = [];
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
events.Add(evt);
}
return events;
}
}
@@ -132,6 +132,53 @@ public class InProcessExecutionTests
"both versions should produce the same number of agent events");
}
/// <summary>
/// This test checks that the logic around waiting for input and halting appropriately works right when the
/// workflow runs to halting before the EventStream is watched by the user.
/// </summary>
[Fact]
public async Task RunStreamingAsyncWaitToTakeStreamAsync()
{
// Arrange: Create a simple agent that responds to messages
var agent = new SimpleTestAgent("test-agent");
var workflow = AgentWorkflowBuilder.BuildSequential(agent);
var inputMessage = new ChatMessage(ChatRole.User, "Hello");
// Act: Execute using streaming version with TurnToken
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, new List<ChatMessage> { inputMessage });
// Send TurnToken to actually trigger execution (this is the key step)
bool messageSent = await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
messageSent.Should().BeTrue("TurnToken should be accepted");
while (await run.GetStatusAsync() != RunStatus.Idle)
{
await Task.Delay(200);
}
// Collect events
List<WorkflowEvent> events = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert: The workflow should have executed and produced events
RunStatus status = await run.GetStatusAsync();
status.Should().Be(RunStatus.Idle, "workflow should complete execution");
events.Should().NotBeEmpty("workflow should produce events during execution");
// Check that we have agent execution events
var agentEvents = events.OfType<AgentResponseUpdateEvent>().ToList();
agentEvents.Should().NotBeEmpty("agent should have executed and produced update events");
// Check that we have output events
var outputEvents = events.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().NotBeEmpty("workflow should produce output events");
}
/// <summary>
/// Simple test agent that echoes back the input message.
/// </summary>
@@ -0,0 +1,145 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public sealed class InputWaiterTests : IDisposable
{
private readonly InputWaiter _waiter = new();
public void Dispose()
{
this._waiter.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task InputWaiter_WaitForInputAsync_CompletesAfterSignalAsync()
{
this._waiter.SignalInput();
// WaitForInputAsync should complete immediately since input was already signaled
Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None);
Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1)));
completed.Should().BeSameAs(waitTask, "the wait task should complete before the timeout");
await waitTask;
}
[Fact]
public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync()
{
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(5));
await Task.Delay(50);
waitTask.IsCompleted.Should().BeFalse("the waiter should block until input is signaled");
this._waiter.SignalInput();
Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1)));
completed.Should().BeSameAs(waitTask, "the wait task should complete after being signaled");
await waitTask;
}
[Fact]
public void InputWaiter_SignalInput_DoubleSignalDoesNotThrow()
{
// Binary semaphore behavior: double signal should be idempotent
FluentActions.Invoking(() =>
{
this._waiter.SignalInput();
this._waiter.SignalInput();
}).Should().NotThrow("double signaling should be handled gracefully");
}
[Fact]
public async Task InputWaiter_WaitForInputAsync_RespectsCancellationAsync()
{
using CancellationTokenSource cts = new();
Task waitTask = this._waiter.WaitForInputAsync(cts.Token);
cts.Cancel();
Func<Task> act = () => waitTask;
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task InputWaiter_WaitForInputAsync_DoesNotCompleteWhenNotSignaledAsync()
{
using CancellationTokenSource cts = new();
Task waitTask = this._waiter.WaitForInputAsync(cts.Token);
Task completed = await Task.WhenAny(waitTask, Task.Delay(100));
completed.Should().NotBeSameAs(waitTask, "the wait task should not complete when input is not signaled");
// Cancel and observe the pending task to avoid an unobserved exception on Dispose
cts.Cancel();
try { await waitTask; }
catch (OperationCanceledException) { }
}
[Fact]
public async Task InputWaiter_WaitForInputAsync_CanBeSignaledMultipleTimesSequentiallyAsync()
{
// First signal/wait cycle
this._waiter.SignalInput();
await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1));
// Second signal/wait cycle
this._waiter.SignalInput();
await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1));
}
}
public class OutputFilterTests
{
private static OutputFilter CreateFilterWithOutputFrom(string outputExecutorId)
{
NoOpExecutor start = new("start");
NoOpExecutor end = new("end");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(start, end)
.WithOutputFrom(outputExecutorId == "end" ? end : start)
.Build();
return new OutputFilter(workflow);
}
[Fact]
public void OutputFilter_CanOutput_ReturnsTrueForRegisteredExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.CanOutput("end", "some output").Should().BeTrue("the executor was registered via WithOutputFrom");
}
[Fact]
public void OutputFilter_CanOutput_ReturnsFalseForUnregisteredExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.CanOutput("start", "some output").Should().BeFalse("start was not registered as an output executor");
}
[Fact]
public void OutputFilter_CanOutput_ReturnsFalseForNonExistentExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.CanOutput("nonexistent", "some output").Should().BeFalse("an executor not in the workflow should not be an output executor");
}
private sealed class NoOpExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class RoundRobinGroupChatManagerTests
{
[Fact]
public async Task RoundRobinGroupChat_SelectNextAgent_CyclesInOrderAsync()
{
TestEchoAgent agent1 = new(id: "agent1");
TestEchoAgent agent2 = new(id: "agent2");
TestEchoAgent agent3 = new(id: "agent3");
List<AIAgent> agents = [agent1, agent2, agent3];
List<ChatMessage> history = [];
RoundRobinGroupChatManager manager = new(agents);
AIAgent first = await manager.SelectNextAgentAsync(history);
AIAgent second = await manager.SelectNextAgentAsync(history);
AIAgent third = await manager.SelectNextAgentAsync(history);
first.Should().BeSameAs(agent1);
second.Should().BeSameAs(agent2);
third.Should().BeSameAs(agent3);
}
[Fact]
public async Task RoundRobinGroupChat_SelectNextAgent_WrapsAroundAsync()
{
TestEchoAgent agent1 = new(id: "agent1");
TestEchoAgent agent2 = new(id: "agent2");
List<AIAgent> agents = [agent1, agent2];
List<ChatMessage> history = [];
RoundRobinGroupChatManager manager = new(agents);
await manager.SelectNextAgentAsync(history);
await manager.SelectNextAgentAsync(history);
AIAgent wrappedAgent = await manager.SelectNextAgentAsync(history);
wrappedAgent.Should().BeSameAs(agent1, "the manager should wrap around to the first agent after cycling through all agents");
}
[Fact]
public async Task RoundRobinGroupChat_ShouldTerminate_DefaultBehaviorTerminatesAtMaxIterationsAsync()
{
TestEchoAgent agent1 = new(id: "agent1");
List<AIAgent> agents = [agent1];
List<ChatMessage> history = [];
RoundRobinGroupChatManager manager = new(agents) { MaximumIterationCount = 3 };
manager.IterationCount = 2;
bool shouldTerminateBefore = await manager.ShouldTerminateAsync(history);
shouldTerminateBefore.Should().BeFalse("the iteration count has not yet reached the maximum");
manager.IterationCount = 3;
bool shouldTerminateAt = await manager.ShouldTerminateAsync(history);
shouldTerminateAt.Should().BeTrue("the iteration count has reached the maximum");
}
[Fact]
public async Task RoundRobinGroupChat_ShouldTerminate_CustomFuncTerminatesEarlyAsync()
{
TestEchoAgent agent1 = new(id: "agent1");
List<AIAgent> agents = [agent1];
List<ChatMessage> history = [new ChatMessage(ChatRole.Assistant, "done")];
RoundRobinGroupChatManager manager = new(agents,
shouldTerminateFunc: (_, messages, _) => new(messages.Any(m => m.Text == "done")))
{
MaximumIterationCount = 100
};
bool shouldTerminate = await manager.ShouldTerminateAsync(history);
shouldTerminate.Should().BeTrue("the custom termination function should cause early termination");
}
[Fact]
public async Task RoundRobinGroupChat_ShouldTerminate_CustomFuncDoesNotTerminateWhenNotMetAsync()
{
TestEchoAgent agent1 = new(id: "agent1");
List<AIAgent> agents = [agent1];
List<ChatMessage> history = [new ChatMessage(ChatRole.Assistant, "continue")];
RoundRobinGroupChatManager manager = new(agents,
shouldTerminateFunc: (_, messages, _) => new(messages.Any(m => m.Text == "done")))
{
MaximumIterationCount = 100
};
bool shouldTerminate = await manager.ShouldTerminateAsync(history);
shouldTerminate.Should().BeFalse("the custom termination function should not cause termination when condition is not met");
}
[Fact]
public async Task RoundRobinGroupChat_Reset_ResetsIterationCountAndAgentIndexAsync()
{
TestEchoAgent agent1 = new(id: "agent1");
TestEchoAgent agent2 = new(id: "agent2");
List<AIAgent> agents = [agent1, agent2];
List<ChatMessage> history = [];
RoundRobinGroupChatManager manager = new(agents);
manager.IterationCount = 5;
// Advance the internal index past the first agent
await manager.SelectNextAgentAsync(history);
manager.Reset();
manager.IterationCount.Should().Be(0, "Reset should clear the iteration count");
AIAgent afterReset = await manager.SelectNextAgentAsync(history);
afterReset.Should().BeSameAs(agent1, "Reset should cause the next selection to start from the first agent");
}
[Fact]
public void RoundRobinGroupChat_Constructor_ThrowsOnNullAgents()
{
FluentActions.Invoking(() => new RoundRobinGroupChatManager(null!))
.Should().Throw<System.ArgumentNullException>()
.WithParameterName("agents");
}
[Fact]
public void RoundRobinGroupChat_Constructor_ThrowsOnEmptyAgents()
{
FluentActions.Invoking(() => new RoundRobinGroupChatManager([]))
.Should().Throw<System.ArgumentException>();
}
}
@@ -1,268 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Type or member is obsolete - Testing deprecated OpenAI Assistants API extension methods
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using AgentConformance.IntegrationTests.Support;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Assistants;
using OpenAI.Files;
using OpenAI.VectorStores;
using Shared.IntegrationTests;
namespace OpenAIAssistant.IntegrationTests;
public class OpenAIAssistantClientExtensionsTests
{
private const string SkipCodeInterpreterReason = "OpenAI Assistant Code Interpreter intermittently fails in CI";
private readonly AssistantClient _assistantClient = new OpenAIClient(TestConfiguration.GetRequiredValue(TestSettings.OpenAIApiKey)).GetAssistantClient();
private readonly OpenAIFileClient _fileClient = new OpenAIClient(TestConfiguration.GetRequiredValue(TestSettings.OpenAIApiKey)).GetOpenAIFileClient();
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithParamsAsync")]
public async Task CreateAIAgentAsync_WithAIFunctionTool_InvokesFunctionAsync(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, nameof(GetWeather));
// Act
var agent = createMechanism switch
{
"CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName),
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = AgentInstructions,
Tools = [weatherFunction]
}
}),
"CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName),
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = AgentInstructions,
Tools = [weatherFunction]
}
}),
"CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName),
instructions: AgentInstructions,
tools: [weatherFunction]),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
try
{
// Trigger function call.
var response = await agent.RunAsync("What is the weather like in Amsterdam?");
var text = response.Text;
// Assert
Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase);
}
finally
{
await this._assistantClient.DeleteAssistantAsync(agent.Id);
}
}
[Theory(Skip = SkipCodeInterpreterReason)]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithParamsAsync")]
public async Task CreateAIAgentAsync_WithHostedCodeInterpreter_RunsCodeAsync(string createMechanism)
{
// Arrange
const string Instructions = "Use the Code Interpreter Tool to run the uploaded python file and respond only with the secret number.";
// Create a python file that prints a known value.
var codeFilePath = Path.GetTempFileName() + "openai_secret_number.py";
File.WriteAllText(
path: codeFilePath,
contents: "print(\"OPENAI_SECRET=13579\")" // Deterministic output we will look for.
);
// Upload file to OpenAI Assistants file store for use with the Code Interpreter.
var uploadResult = await this._fileClient.UploadFileAsync(codeFilePath, FileUploadPurpose.Assistants);
string uploadedFileId = uploadResult.Value.Id;
var codeInterpreterTool = new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedFileId)] };
var agent = createMechanism switch
{
"CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName),
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = Instructions,
Tools = [codeInterpreterTool]
}
}),
"CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName),
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = Instructions,
Tools = [codeInterpreterTool]
}
}),
"CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName),
instructions: Instructions,
tools: [codeInterpreterTool]),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
try
{
var response = await agent.RunAsync("What is the OPENAI_SECRET number?");
var text = response.ToString();
Assert.Contains("13579", text);
}
finally
{
await this._assistantClient.DeleteAssistantAsync(agent.Id);
await this._fileClient.DeleteFileAsync(uploadedFileId);
File.Delete(codeFilePath);
}
}
[Theory(Skip = "For manual testing only")]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithParamsAsync")]
public async Task CreateAIAgentAsync_WithHostedFileSearchTool_SearchesFilesAsync(string createMechanism)
{
// Arrange.
const string Instructions = """
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 local file with deterministic content and upload it.
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.");
var uploadResult = await this._fileClient.UploadFileAsync(searchFilePath, FileUploadPurpose.Assistants);
string uploadedFileId = uploadResult.Value.Id;
// Create a vector store backing the file search (HostedFileSearchTool requires a vector store id).
var vectorStoreClient = new OpenAIClient(TestConfiguration.GetRequiredValue(TestSettings.OpenAIApiKey)).GetVectorStoreClient();
var vectorStoreCreate = await vectorStoreClient.CreateVectorStoreAsync(options: new VectorStoreCreationOptions()
{
Name = "WordCodeLookup_VectorStore",
FileIds = { uploadedFileId }
});
string vectorStoreId = vectorStoreCreate.Value.Id;
// Wait for vector store indexing to complete before using it
await WaitForVectorStoreReadyAsync(vectorStoreClient, vectorStoreId);
var fileSearchTool = new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] };
var agent = createMechanism switch
{
"CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName),
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = Instructions,
Tools = [fileSearchTool]
}
}),
"CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName),
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = Instructions,
Tools = [fileSearchTool]
}
}),
"CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync(
model: TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName),
instructions: Instructions,
tools: [fileSearchTool]),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
try
{
// Act - ask about banana code which must be retrieved via file search.
var response = await agent.RunAsync("Can you give me the documented code for 'banana'?");
var text = response.ToString();
Assert.Contains("673457", text);
}
finally
{
await this._assistantClient.DeleteAssistantAsync(agent.Id);
await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId);
await this._fileClient.DeleteFileAsync(uploadedFileId);
File.Delete(searchFilePath);
}
}
/// <summary>
/// Waits for a vector store to complete indexing by polling its status.
/// </summary>
/// <param name="client">The vector store 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 static async Task WaitForVectorStoreReadyAsync(
VectorStoreClient client,
string vectorStoreId,
int maxWaitSeconds = 30)
{
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < maxWaitSeconds)
{
VectorStore vectorStore = await client.GetVectorStoreAsync(vectorStoreId);
VectorStoreStatus status = vectorStore.Status;
if (status == VectorStoreStatus.Completed)
{
if (vectorStore.FileCounts.Failed > 0)
{
throw new InvalidOperationException("Vector store indexing failed for some files");
}
return;
}
if (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");
}
}