mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into features/3768-devui-aspire-integration
This commit is contained in:
-18
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace Foundry.IntegrationTests;
|
||||
|
||||
public class FoundryVersionedAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests<FoundryVersionedAgentFixture>(() => new())
|
||||
{
|
||||
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.Skip("No messages is not supported");
|
||||
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace Foundry.IntegrationTests;
|
||||
|
||||
public class FoundryVersionedAgentChatClientRunTests() : ChatClientAgentRunTests<FoundryVersionedAgentFixture>(() => new())
|
||||
{
|
||||
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync()
|
||||
{
|
||||
Assert.Skip("No messages is not supported");
|
||||
return base.RunWithInstructionsAndNoMessageReturnsExpectedResultAsync();
|
||||
}
|
||||
}
|
||||
+74
-88
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -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 () =>
|
||||
{
|
||||
+3
-6
@@ -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 () =>
|
||||
{
|
||||
+10
-13
@@ -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<T> is ignored by AzureAIProjectChatClient and is only used
|
||||
/// for deserializing the agent response by AgentResponse<T>.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()
|
||||
{
|
||||
+71
-13
@@ -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);
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAI.IntegrationTests;
|
||||
namespace Foundry.IntegrationTests;
|
||||
|
||||
public class ResponsesAgentChatClientRunStreamingTests() : ChatClientAgentRunStreamingTests<ResponsesAgentFixture>(() => new())
|
||||
{
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests;
|
||||
|
||||
namespace AzureAI.IntegrationTests;
|
||||
namespace Foundry.IntegrationTests;
|
||||
|
||||
public class ResponsesAgentChatClientRunTests() : ChatClientAgentRunTests<ResponsesAgentFixture>(() => new())
|
||||
{
|
||||
+26
-10
@@ -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!;
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -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;
|
||||
+1
-1
@@ -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())
|
||||
{
|
||||
+1
-1
@@ -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())
|
||||
{
|
||||
+1
-1
@@ -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())
|
||||
{
|
||||
-3472
File diff suppressed because it is too large
Load Diff
+1814
File diff suppressed because it is too large
Load Diff
+18
-34
@@ -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();
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
+1
-1
@@ -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.
|
||||
+1
-1
@@ -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
|
||||
{
|
||||
+1
-1
@@ -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.
|
||||
+1
-1
@@ -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.
|
||||
+6
-1
@@ -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>
|
||||
+1
-1
@@ -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.
|
||||
+9
-9
@@ -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())
|
||||
-21
@@ -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>
|
||||
-16
@@ -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>
|
||||
-1013
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+44
-44
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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] ??
|
||||
|
||||
+3
-3
@@ -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 =
|
||||
|
||||
+5
-5
@@ -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 =
|
||||
|
||||
+3
-3
@@ -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 =
|
||||
|
||||
+2
-2
@@ -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 =
|
||||
|
||||
+2
-2
@@ -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));
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 =
|
||||
|
||||
+2
-2
@@ -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)
|
||||
{
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+7
@@ -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:
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+145
@@ -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
-1
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+139
@@ -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>();
|
||||
}
|
||||
}
|
||||
-268
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user