test
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
Python - Merge - Tests / paths-filter (push) Has been cancelled
Python - Merge - Tests / Python Tests - Core (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / Python Tests - Azure AI (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / python-integration-tests-check (push) Has been cancelled
Python - Lab Tests / paths-filter (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.14) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.14) (push) Has been cancelled
Check .md links / markdown-link-check (push) Has been cancelled

This commit is contained in:
2026-01-24 03:05:12 +11:00
parent f78f2388b3
commit 539852f81c
2584 changed files with 287471 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Azure.AI.Projects.OpenAI;
using Microsoft.Extensions.Configuration;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal abstract class AgentProvider(IConfiguration configuration)
{
public static class Names
{
public const string FunctionTool = "FUNCTIONTOOL";
public const string Marketing = "MARKETING";
public const string MathChat = "MATHCHAT";
public const string InputArguments = "INPUTARGUMENTS";
public const string Vision = "VISION";
}
public static class Settings
{
public const string FoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT";
public const string FoundryModelMini = "FOUNDRY_MODEL_DEPLOYMENT_NAME";
public const string FoundryModelFull = "FOUNDRY_MEDIA_DEPLOYMENT_NAME";
public const string FoundryGroundingTool = "FOUNDRY_CONNECTION_GROUNDING_TOOL";
}
public static AgentProvider Create(IConfiguration configuration, string providerType) =>
providerType.ToUpperInvariant() switch
{
Names.FunctionTool => new FunctionToolAgentProvider(configuration),
Names.Marketing => new MarketingAgentProvider(configuration),
Names.MathChat => new MathChatAgentProvider(configuration),
Names.InputArguments => new PoemAgentProvider(configuration),
Names.Vision => new VisionAgentProvider(configuration),
_ => new TestAgentProvider(configuration),
};
public async ValueTask CreateAgentsAsync()
{
Uri foundryEndpoint = new(this.GetSetting(Settings.FoundryEndpoint));
await foreach (AgentVersion agent in this.CreateAgentsAsync(foundryEndpoint))
{
Console.WriteLine($"Created agent: {agent.Name}:{agent.Version}");
}
}
protected abstract IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint);
protected string GetSetting(string settingName) =>
configuration[settingName] ??
throw new InvalidOperationException($"Undefined configuration setting: {settingName}");
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using OpenAI.Responses;
using Shared.Foundry;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class FunctionToolAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
MenuPlugin menuPlugin = new();
AIFunction[] functions =
[
AIFunctionFactory.Create(menuPlugin.GetMenu),
AIFunctionFactory.Create(menuPlugin.GetSpecials),
AIFunctionFactory.Create(menuPlugin.GetItemPrice),
];
AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await aiProjectClient.CreateAgentAsync(
agentName: "MenuAgent",
agentDefinition: this.DefineMenuAgent(functions),
agentDescription: "Provides information about the restaurant menu");
}
private PromptAgentDefinition DefineMenuAgent(AIFunction[] functions)
{
PromptAgentDefinition agentDefinition =
new(this.GetSetting(Settings.FoundryModelMini))
{
Instructions =
"""
Answer the users questions on the menu.
For questions or input that do not require searching the documentation, inform the
user that you can only answer questions what's on the menu.
"""
};
foreach (AIFunction function in functions)
{
agentDefinition.Tools.Add(function.AsOpenAIResponseTool());
}
return agentDefinition;
}
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class MarketingAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await aiProjectClient.CreateAgentAsync(
agentName: "AnalystAgent",
agentDefinition: this.DefineAnalystAgent(),
agentDescription: "Analyst agent for Marketing workflow");
yield return
await aiProjectClient.CreateAgentAsync(
agentName: "WriterAgent",
agentDefinition: this.DefineWriterAgent(),
agentDescription: "Writer agent for Marketing workflow");
yield return
await aiProjectClient.CreateAgentAsync(
agentName: "EditorAgent",
agentDefinition: this.DefineEditorAgent(),
agentDescription: "Editor agent for Marketing workflow");
}
private PromptAgentDefinition DefineAnalystAgent() =>
new(this.GetSetting(Settings.FoundryModelFull))
{
Instructions =
"""
You are a marketing analyst. Given a product description, identify:
- Key features
- Target audience
- Unique selling points
""",
Tools =
{
//AgentTool.CreateBingGroundingTool( // TODO: Use Bing Grounding when available
// new BingGroundingSearchToolParameters(
// [new BingGroundingSearchConfiguration(this.GetSetting(Settings.FoundryGroundingTool))]))
}
};
private PromptAgentDefinition DefineWriterAgent() =>
new(this.GetSetting(Settings.FoundryModelFull))
{
Instructions =
"""
You are a marketing copywriter. Given a block of text describing features, audience, and USPs,
compose a compelling marketing copy (like a newsletter section) that highlights these points.
Output should be short (around 150 words), output just the copy as a single text block.
"""
};
private PromptAgentDefinition DefineEditorAgent() =>
new(this.GetSetting(Settings.FoundryModelFull))
{
Instructions =
"""
You are an editor. Given the draft copy, correct grammar, improve clarity, ensure consistent tone,
give format and make it polished. Output the final improved copy as a single text block.
"""
};
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class MathChatAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await aiProjectClient.CreateAgentAsync(
agentName: "StudentAgent",
agentDefinition: this.DefineStudentAgent(),
agentDescription: "Student agent for MathChat workflow");
yield return
await aiProjectClient.CreateAgentAsync(
agentName: "TeacherAgent",
agentDefinition: this.DefineTeacherAgent(),
agentDescription: "Teacher agent for MathChat workflow");
}
private PromptAgentDefinition DefineStudentAgent() =>
new(this.GetSetting(Settings.FoundryModelMini))
{
Instructions =
"""
Your job is help a math teacher practice teaching by making intentional mistakes.
You attempt to solve the given math problem, but with intentional mistakes so the teacher can help.
Always incorporate the teacher's advice to fix your next response.
You have the math-skills of a 6th grader.
"""
};
private PromptAgentDefinition DefineTeacherAgent() =>
new(this.GetSetting(Settings.FoundryModelMini))
{
Instructions =
"""
Review and coach the student's approach to solving the given math problem.
Don't repeat the solution or try and solve it.
If the student has demonstrated comprehension and responded to all of your feedback,
give the student your congratulations by using the word "congratulations".
"""
};
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
#pragma warning disable CA1822
public sealed class MenuPlugin
{
public IEnumerable<AIFunction> GetTools()
{
yield return AIFunctionFactory.Create(this.GetMenu);
yield return AIFunctionFactory.Create(this.GetSpecials);
yield return AIFunctionFactory.Create(this.GetItemPrice);
}
[Description("Provides a list items on the menu.")]
public MenuItem[] GetMenu()
{
return s_menuItems;
}
[Description("Provides a list of specials from the menu.")]
public MenuItem[] GetSpecials()
{
return [.. s_menuItems.Where(i => i.IsSpecial)];
}
[Description("Provides the price of the requested menu item.")]
public float? GetItemPrice(
[Description("The name of the menu item.")]
string name)
{
return s_menuItems.FirstOrDefault(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Price;
}
private static readonly MenuItem[] s_menuItems =
[
new()
{
Category = "Soup",
Name = "Clam Chowder",
Price = 4.95f,
IsSpecial = true,
},
new()
{
Category = "Soup",
Name = "Tomato Soup",
Price = 4.95f,
IsSpecial = false,
},
new()
{
Category = "Salad",
Name = "Cobb Salad",
Price = 9.99f,
},
new()
{
Category = "Salad",
Name = "House Salad",
Price = 4.95f,
},
new()
{
Category = "Drink",
Name = "Chai Tea",
Price = 2.95f,
IsSpecial = true,
},
new()
{
Category = "Drink",
Name = "Soda",
Price = 1.95f,
},
];
public sealed class MenuItem
{
public string Category { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public float Price { get; init; }
public bool IsSpecial { get; init; }
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class PoemAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await aiProjectClient.CreateAgentAsync(
agentName: "PoemAgent",
agentDefinition: this.DefinePoemAgent(),
agentDescription: "Authors original poems");
}
private PromptAgentDefinition DefinePoemAgent() =>
new(this.GetSetting(Settings.FoundryModelMini))
{
Instructions =
"""
Write a one verse poem on the requested topic in the style of: {{style}}.
""",
StructuredInputs =
{
["style"] =
new StructuredInputDefinition
{
IsRequired = false,
DefaultValue = BinaryData.FromString(@"""haiku"""),
Description = "The style of poem to write",
}
}
};
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class TestAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await aiProjectClient.CreateAgentAsync(
agentName: "TestAgent",
agentDefinition: this.DefineMenuAgent(),
agentDescription: "Basic agent");
}
private PromptAgentDefinition DefineMenuAgent() =>
new(this.GetSetting(Settings.FoundryModelFull));
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class VisionAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await aiProjectClient.CreateAgentAsync(
agentName: "VisionAgent",
agentDefinition: this.DefineVisionAgent(),
agentDescription: "Use computer vision to describe an image or document.");
}
private PromptAgentDefinition DefineVisionAgent() =>
new(this.GetSetting(Settings.FoundryModelFull))
{
Instructions =
"""
Describe the image or document contained in the user request, if any;
otherwise, suggest that the user provide an image or document.
""",
};
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
public sealed class AzureAgentProviderTest(ITestOutputHelper output) : IntegrationTest(output)
{
[Fact]
public async Task ConversationTestAsync()
{
// Arrange
AzureAgentProvider provider = new(this.TestEndpoint, new AzureCliCredential());
// Act
string conversationId = await provider.CreateConversationAsync();
// Assert
Assert.NotEmpty(conversationId);
// Arrange & Act
for (int index = 0; index < 3; ++index)
{
await provider.CreateMessageAsync(conversationId, new ChatMessage(ChatRole.User, $"Message #{index * 2}"));
await provider.CreateMessageAsync(conversationId, new ChatMessage(ChatRole.Assistant, $"Message #{(index * 2) + 1}"));
}
// Act
ChatMessage[] messages = await provider.GetMessagesAsync(conversationId).ToArrayAsync();
// Assert
Assert.Equal(6, messages.Length);
Assert.NotNull(messages[3].MessageId);
// Act
ChatMessage message = await provider.GetMessageAsync(conversationId, messages[3].MessageId!);
// Assert
Assert.NotNull(message);
Assert.Equal(messages[3].Text, message.Text);
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Theory]
[InlineData("CheckSystem.yaml", "CheckSystem.json")]
[InlineData("SendActivity.yaml", "SendActivity.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)]
[InlineData("ConversationMessages.yaml", "ConversationMessages.json")]
[InlineData("ConversationMessages.yaml", "ConversationMessages.json", true)]
public Task ValidateCaseAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
this.RunWorkflowAsync(Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName), testcaseFileName, externalConveration);
[Theory]
[InlineData("Marketing.yaml", "Marketing.json")]
[InlineData("Marketing.yaml", "Marketing.json", true)]
[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);
[Fact(Skip = "Needs template support")]
public Task ValidateMultiTurnAsync() =>
this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true);
protected override async Task RunAndVerifyAsync<TInput>(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint)
{
const string WorkflowNamespace = "Test.WorkflowProviders";
const string WorkflowPrefix = "Test";
string workflowProviderCode = DeclarativeWorkflowBuilder.Eject(workflowPath, DeclarativeWorkflowLanguage.CSharp, WorkflowNamespace, WorkflowPrefix);
try
{
WorkflowHarness harness = await WorkflowHarness.GenerateCodeAsync(
runId: Path.GetFileNameWithoutExtension(workflowPath),
workflowProviderCode,
workflowProviderName: $"{WorkflowPrefix}WorkflowProvider",
WorkflowNamespace,
workflowOptions,
input);
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input, useJsonCheckpoint).ConfigureAwait(false);
// Verify no action events are present
Assert.Empty(workflowEvents.ActionInvokeEvents);
Assert.Empty(workflowEvents.ActionCompleteEvents);
// Verify the associated conversations
AssertWorkflow.Conversation(workflowEvents.ConversationEvents, testcase);
// Verify executor events
AssertWorkflow.EventCounts(workflowEvents.ExecutorInvokeEvents.Count - 2, testcase);
AssertWorkflow.EventCounts(workflowEvents.ExecutorCompleteEvents.Count - 2, testcase);
// Verify action sequences
AssertWorkflow.EventSequence(workflowEvents.ExecutorInvokeEvents.Select(e => e.ExecutorId), testcase);
}
finally
{
this.Output.WriteLine($"CODE:\n{workflowProviderCode}");
}
}
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Theory]
[InlineData("CheckSystem.yaml", "CheckSystem.json")]
[InlineData("ConversationMessages.yaml", "ConversationMessages.json")]
[InlineData("ConversationMessages.yaml", "ConversationMessages.json", true)]
[InlineData("InputArguments.yaml", "InputArguments.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)]
[InlineData("SendActivity.yaml", "SendActivity.json")]
public Task ValidateCaseAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample: false), testcaseFileName, externalConveration);
[Theory]
[InlineData("Marketing.yaml", "Marketing.json")]
[InlineData("Marketing.yaml", "Marketing.json", true)]
[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(GetWorkflowPath(workflowFileName, isSample: true), testcaseFileName, externalConveration);
[Theory]
[InlineData("ConfirmInput.yaml", "ConfirmInput.json", false)]
[InlineData("RequestExternalInput.yaml", "RequestExternalInput.json", false)]
public Task ValidateMultiTurnAsync(string workflowFileName, string testcaseFileName, bool isSample) =>
this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample), testcaseFileName, useJsonCheckpoint: true);
private static string GetWorkflowPath(string workflowFileName, bool isSample) =>
isSample
? Path.Combine(GetRepoFolder(), "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)
{
AgentProvider agentProvider = AgentProvider.Create(this.Configuration, Path.GetFileNameWithoutExtension(workflowPath));
await agentProvider.CreateAgentsAsync().ConfigureAwait(false);
Workflow workflow = DeclarativeWorkflowBuilder.Build<TInput>(workflowPath, workflowOptions);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input, useJsonCheckpoint).ConfigureAwait(false);
// Verify executor events are present
Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents);
Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents);
// Verify the associated conversations
AssertWorkflow.Conversation(workflowEvents.ConversationEvents, testcase);
// Verify the agent responses
AssertWorkflow.Responses(workflowEvents.AgentResponseEvents, testcase);
// Verify the messages on the workflow conversation
await AssertWorkflow.MessagesAsync(
GetConversationId(workflowOptions.ConversationId, workflowEvents.ConversationEvents),
testcase,
workflowOptions.AgentProvider);
// Verify action events
AssertWorkflow.EventCounts(workflowEvents.ActionInvokeEvents.Count, testcase);
AssertWorkflow.EventCounts(workflowEvents.ActionCompleteEvents.Count, testcase, isCompletion: true);
// Verify action sequences
AssertWorkflow.EventSequence(workflowEvents.ActionInvokeEvents.Select(e => e.ActionId), testcase);
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
/// <summary>
/// Base class for workflow tests.
/// </summary>
public abstract class IntegrationTest : IDisposable
{
protected IConfigurationRoot Configuration => field ??= InitializeConfig();
public Uri TestEndpoint { get; }
public TestOutputAdapter Output { get; }
protected IntegrationTest(ITestOutputHelper output)
{
this.Output = new TestOutputAdapter(output);
this.TestEndpoint =
new Uri(
this.Configuration?[AgentProvider.Settings.FoundryEndpoint] ??
throw new InvalidOperationException($"Undefined configuration setting: {AgentProvider.Settings.FoundryEndpoint}"));
Console.SetOut(this.Output);
SetProduct();
}
public void Dispose()
{
this.Dispose(isDisposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool isDisposing)
{
if (isDisposing)
{
this.Output.Dispose();
}
}
protected static void SetProduct()
{
if (!ProductContext.IsLocalScopeSupported())
{
ProductContext.SetContext(Product.Foundry);
}
}
internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}";
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false, params IEnumerable<AIFunction> functionTools)
{
AzureAgentProvider agentProvider =
new(this.TestEndpoint, new AzureCliCredential())
{
Functions = functionTools,
};
string? conversationId = null;
if (externalConversation)
{
conversationId = await agentProvider.CreateConversationAsync().ConfigureAwait(false);
}
return
new DeclarativeWorkflowOptions(agentProvider)
{
ConversationId = conversationId,
LoggerFactory = this.Output
};
}
private static IConfigurationRoot InitializeConfig() =>
new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.Build();
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
public sealed class TestOutputAdapter(ITestOutputHelper output) : TextWriter, ILogger, ILoggerFactory
{
private readonly Stack<string> _scopes = [];
public override Encoding Encoding { get; } = Encoding.UTF8;
public void AddProvider(ILoggerProvider provider) => throw new NotSupportedException();
public ILogger CreateLogger(string categoryName) => this;
public bool IsEnabled(LogLevel logLevel) => true;
public override void WriteLine(object? value) => this.SafeWrite($"{value}");
public override void WriteLine(string? format, params object?[] arg) => this.SafeWrite(string.Format(format ?? string.Empty, arg));
public override void WriteLine(string? value) => this.SafeWrite(value ?? string.Empty);
public override void Write(object? value) => this.SafeWrite($"{value}");
public override void Write(char[]? buffer) => this.SafeWrite(new string(buffer));
public IDisposable BeginScope<TState>(TState state) where TState : notnull
{
this._scopes.Push($"{state}");
return new LoggerScope(() => this._scopes.Pop());
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
string message = formatter(state, exception);
string scope = this._scopes.Count > 0 ? $"[{this._scopes.Peek()}] " : string.Empty;
output.WriteLine($"{scope}{message}");
}
private void SafeWrite(string value)
{
try
{
output.WriteLine(value ?? string.Empty);
}
catch (InvalidOperationException exception) when (exception.Message == "There is no currently active test.")
{
// This exception is thrown when the test output is accessed outside of a test context.
// We can ignore it since we are not in a test context.
}
}
private sealed class LoggerScope(Action action) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (!this._disposed)
{
action.Invoke();
this._disposed = true;
}
}
}
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
public sealed class Testcase
{
[JsonConstructor]
public Testcase(string description, TestcaseSetup setup, TestcaseValidation validation)
{
this.Description = description;
this.Setup = setup;
this.Validation = validation;
}
public string Description { get; }
public TestcaseSetup Setup { get; }
public TestcaseValidation Validation { get; }
}
public sealed class TestcaseSetup
{
[JsonConstructor]
public TestcaseSetup(TestcaseInput input)
{
this.Input = input;
}
public TestcaseInput Input { get; }
public IList<TestcaseInput> Responses { get; init; } = [];
}
public sealed class TestcaseInput
{
[JsonConstructor]
public TestcaseInput(string type, string value)
{
this.Type = type;
this.Value = value;
}
public string Type { get; }
public string Value { get; }
}
public sealed class TestcaseValidation
{
[JsonConstructor]
public TestcaseValidation(int conversationCount, int minActionCount, int minResponseCount)
{
this.ConversationCount = conversationCount;
this.MinActionCount = minActionCount;
this.MinResponseCount = minResponseCount;
}
public TestcaseValidationActions Actions { get; init; } = TestcaseValidationActions.Empty;
public int ConversationCount { get; }
public int MinActionCount { get; }
// Default expectation is MinActionCount when not defined
public int? MaxActionCount { get; init; }
// Default expectation is MinResponseCount when not defined
public int? MinMessageCount { get; init; }
// Default expectation is MaxResponseCount when not defined
public int? MaxMessageCount { get; init; }
public int MinResponseCount { get; }
// Default expectation is MinResponseCount when not defined
public int? MaxResponseCount { get; init; }
}
public sealed class TestcaseValidationActions
{
public static TestcaseValidationActions Empty { get; } = new([]);
[JsonConstructor]
public TestcaseValidationActions(IList<string> start)
{
this.Start = start;
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IList<string> Start { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IList<string> Repeat { get; init; } = [];
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IList<string> Final { get; init; } = [];
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
internal sealed class WorkflowEvents
{
public WorkflowEvents(IReadOnlyList<WorkflowEvent> workflowEvents)
{
this.Events = workflowEvents;
this.EventCounts = workflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
this.ActionInvokeEvents = workflowEvents.OfType<DeclarativeActionInvokedEvent>().ToList();
this.ActionCompleteEvents = workflowEvents.OfType<DeclarativeActionCompletedEvent>().ToList();
this.ConversationEvents = workflowEvents.OfType<ConversationUpdateEvent>().ToList();
this.ExecutorInvokeEvents = workflowEvents.OfType<ExecutorInvokedEvent>().ToList();
this.ExecutorCompleteEvents = workflowEvents.OfType<ExecutorCompletedEvent>().ToList();
this.InputEvents = workflowEvents.OfType<RequestInfoEvent>().ToList();
this.AgentResponseEvents = workflowEvents.OfType<AgentResponseEvent>().ToList();
}
public IReadOnlyList<WorkflowEvent> Events { get; }
public IReadOnlyDictionary<Type, int> EventCounts { get; }
public IReadOnlyList<ConversationUpdateEvent> ConversationEvents { get; }
public IReadOnlyList<DeclarativeActionInvokedEvent> ActionInvokeEvents { get; }
public IReadOnlyList<DeclarativeActionCompletedEvent> ActionCompleteEvents { get; }
public IReadOnlyList<ExecutorInvokedEvent> ExecutorInvokeEvents { get; }
public IReadOnlyList<ExecutorCompletedEvent> ExecutorCompleteEvents { get; }
public IReadOnlyList<RequestInfoEvent> InputEvents { get; }
public IReadOnlyList<AgentResponseEvent> AgentResponseEvents { get; }
}

View File

@@ -0,0 +1,170 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Shared.Code;
using Xunit.Sdk;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
internal sealed class WorkflowHarness(Workflow workflow, string runId)
{
private CheckpointManager? _checkpointManager;
private CheckpointInfo? _lastCheckpoint;
public async Task<WorkflowEvents> RunTestcaseAsync<TInput>(Testcase testcase, TInput input, bool useJson = false) where TInput : notnull
{
WorkflowEvents workflowEvents = await this.RunWorkflowAsync(input, useJson);
int requestCount = workflowEvents.InputEvents.Count;
int responseCount = 0;
while (requestCount > responseCount)
{
ExternalRequest request = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1].Request;
Assert.NotNull(testcase.Setup.Responses);
Assert.NotEmpty(testcase.Setup.Responses);
string inputText = testcase.Setup.Responses[responseCount].Value;
Console.WriteLine($"ID: {request.RequestId}");
Console.WriteLine($"INPUT: {inputText}");
++responseCount;
ExternalResponse response = request.CreateResponse(new ExternalInputResponse(new ChatMessage(ChatRole.User, inputText)));
WorkflowEvents runEvents = await this.ResumeAsync(response).ConfigureAwait(false);
workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]);
requestCount = workflowEvents.InputEvents.Count;
}
return workflowEvents;
}
public async Task<WorkflowEvents> RunWorkflowAsync<TInput>(TInput input, bool useJson = false) where TInput : notnull
{
Console.WriteLine("RUNNING WORKFLOW...");
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, this.GetCheckpointManager(useJson), runId);
IReadOnlyList<WorkflowEvent> workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run).ToArrayAsync();
this._lastCheckpoint = workflowEvents.OfType<SuperStepCompletedEvent>().LastOrDefault()?.CompletionInfo?.Checkpoint;
return new WorkflowEvents(workflowEvents);
}
public async Task<WorkflowEvents> ResumeAsync(ExternalResponse response)
{
Console.WriteLine("\nRESUMING WORKFLOW...");
Assert.NotNull(this._lastCheckpoint);
Checkpointed<StreamingRun> run = await InProcessExecution.ResumeStreamAsync(workflow, this._lastCheckpoint, this.GetCheckpointManager());
IReadOnlyList<WorkflowEvent> workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync();
return new WorkflowEvents(workflowEvents);
}
public static async Task<WorkflowHarness> GenerateCodeAsync<TInput>(
string runId,
string workflowProviderCode,
string workflowProviderName,
string workflowProviderNamespace,
DeclarativeWorkflowOptions options,
TInput input) where TInput : notnull
{
// Compile the code
Assembly assembly = Compiler.Build(workflowProviderCode, Compiler.RepoDependencies(typeof(DeclarativeWorkflowBuilder)));
Type? type = assembly.GetType($"{workflowProviderNamespace}.{workflowProviderName}");
Assert.NotNull(type);
MethodInfo? method = type.GetMethod("CreateWorkflow");
Assert.NotNull(method);
MethodInfo genericMethod = method.MakeGenericMethod(typeof(TInput));
object? workflowObject = genericMethod.Invoke(null, [options, null]);
Workflow workflow = Assert.IsType<Workflow>(workflowObject);
return new WorkflowHarness(workflow, runId);
}
private CheckpointManager GetCheckpointManager(bool useJson = false)
{
if (useJson && this._checkpointManager is null)
{
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}"));
this._checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder));
}
else
{
this._checkpointManager ??= CheckpointManager.CreateInMemory();
}
return this._checkpointManager;
}
private static async IAsyncEnumerable<WorkflowEvent> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, ExternalResponse? response = null)
{
await using IAsyncDisposable disposeRun = run;
if (response is not null)
{
await run.Run.SendResponseAsync(response).ConfigureAwait(false);
}
bool exitLoop = false;
bool hasRequest = false;
await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync().ConfigureAwait(false))
{
switch (workflowEvent)
{
case SuperStepCompletedEvent:
if (hasRequest)
{
exitLoop = true;
}
break;
case RequestInfoEvent requestInfo:
Console.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
hasRequest = true;
break;
case ConversationUpdateEvent conversationEvent:
Console.WriteLine($"CONVERSATION: {conversationEvent.ConversationId}");
break;
case ExecutorFailedEvent failureEvent:
Console.WriteLine($"Executor failed [{failureEvent.ExecutorId}]: {failureEvent.Data?.Message ?? "Unknown"}");
break;
case WorkflowErrorEvent errorEvent:
throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure...");
case ExecutorInvokedEvent executorInvokeEvent:
Console.WriteLine($"EXEC: {executorInvokeEvent.ExecutorId}");
break;
case DeclarativeActionInvokedEvent actionInvokeEvent:
Console.WriteLine($"ACTION: {actionInvokeEvent.ActionId} [{actionInvokeEvent.ActionType}]");
break;
case AgentResponseEvent responseEvent:
if (!string.IsNullOrEmpty(responseEvent.Response.Text))
{
Console.WriteLine($"AGENT: {responseEvent.Response.AgentId}: {responseEvent.Response.Text}");
}
else
{
foreach (FunctionCallContent toolCall in responseEvent.Response.Messages.SelectMany(m => m.Contents.OfType<FunctionCallContent>()))
{
Console.WriteLine($"TOOL: {toolCall.Name} [{responseEvent.Response.AgentId}]");
}
}
break;
}
yield return workflowEvent;
if (exitLoop)
{
break;
}
}
Console.WriteLine("SUSPENDING WORKFLOW...\n");
}
}

View File

@@ -0,0 +1,217 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
/// <summary>
/// Base class for workflow tests.
/// </summary>
public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
{
protected abstract Task RunAndVerifyAsync<TInput>(
Testcase testcase,
string workflowPath,
DeclarativeWorkflowOptions workflowOptions,
TInput input,
bool useJsonCheckpoint) where TInput : notnull;
protected Task RunWorkflowAsync(
string workflowPath,
string testcaseFileName,
bool externalConversation = false,
bool useJsonCheckpoint = false)
{
this.Output.WriteLine($"WORKFLOW: {workflowPath}");
this.Output.WriteLine($"TESTCASE: {testcaseFileName}");
Testcase testcase = ReadTestcase(testcaseFileName);
this.Output.WriteLine($" {testcase.Description}");
return
testcase.Setup.Input.Type switch
{
nameof(ChatMessage) => TestWorkflowAsync<ChatMessage>(),
nameof(String) => TestWorkflowAsync<string>(),
_ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."),
};
async Task TestWorkflowAsync<TInput>() where TInput : notnull
{
this.Output.WriteLine($"INPUT: {testcase.Setup.Input.Value}");
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation).ConfigureAwait(false);
TInput input = (TInput)GetInput<TInput>(testcase);
await this.RunAndVerifyAsync(testcase, workflowPath, workflowOptions, input, useJsonCheckpoint);
}
}
protected static string? GetConversationId(string? conversationId, IReadOnlyList<ConversationUpdateEvent> conversationEvents)
{
if (!string.IsNullOrEmpty(conversationId))
{
return conversationId;
}
if (conversationEvents.Count > 0)
{
return conversationEvents.SingleOrDefault(conversationEvent => conversationEvent.IsWorkflow)?.ConversationId;
}
return null;
}
protected static Testcase ReadTestcase(string testcaseFileName)
{
string testcaseJson = File.ReadAllText(Path.Combine("Testcases", testcaseFileName));
Testcase? testcase = JsonSerializer.Deserialize<Testcase>(testcaseJson, s_jsonSerializerOptions);
Assert.NotNull(testcase);
return testcase;
}
private static object GetInput<TInput>(Testcase testcase) where TInput : notnull =>
testcase.Setup.Input.Type switch
{
nameof(ChatMessage) => new ChatMessage(ChatRole.User, testcase.Setup.Input.Value),
nameof(String) => testcase.Setup.Input.Value,
_ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."),
};
internal static string GetRepoFolder()
{
DirectoryInfo? current = new(Directory.GetCurrentDirectory());
while (current is not null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return current.FullName;
}
current = current.Parent;
}
throw new XunitException("Unable to locate repository root folder.");
}
protected static class AssertWorkflow
{
public static void Conversation(IReadOnlyList<ConversationUpdateEvent> conversationEvents, Testcase testcase)
{
Assert.Equal(testcase.Validation.ConversationCount, conversationEvents.Count);
}
// "isCompletion" adjusts validation logic to account for when condition completion is not experienced due to goto. Remove this test logic once addressed.
public static void EventCounts(int actualCount, Testcase testcase, bool isCompletion = false)
{
Assert.True(actualCount + (isCompletion ? 1 : 0) >= testcase.Validation.MinActionCount, $"Event count less than expected: {testcase.Validation.MinActionCount} (Actual: {actualCount}).");
if (testcase.Validation.MaxActionCount != -1)
{
int maxExpectedCount = testcase.Validation.MaxActionCount ?? testcase.Validation.MinActionCount;
Assert.True(actualCount <= maxExpectedCount, $"Event count greater than expected: {maxExpectedCount} (Actual: {actualCount}).");
}
}
public static void Responses(IReadOnlyList<AgentResponseEvent> responseEvents, Testcase testcase)
{
Assert.True(responseEvents.Count >= testcase.Validation.MinResponseCount, $"Response count less than expected: {testcase.Validation.MinResponseCount} (Actual: {responseEvents.Count})");
if (testcase.Validation.MaxResponseCount != -1)
{
int maxExpectedCount = testcase.Validation.MaxResponseCount ?? testcase.Validation.MinResponseCount;
Assert.True(responseEvents.Count <= maxExpectedCount, $"Response count greater than expected: {maxExpectedCount} (Actual: {responseEvents.Count}).");
}
}
public static async ValueTask MessagesAsync(string? conversationId, Testcase testcase, WorkflowAgentProvider agentProvider)
{
int minExpectedCount = testcase.Validation.MinMessageCount ?? testcase.Validation.MinResponseCount;
int maxExpectedCount = testcase.Validation.MaxMessageCount ?? testcase.Validation.MaxResponseCount ?? minExpectedCount;
int messageCount = 0;
if (!string.IsNullOrEmpty(conversationId))
{
messageCount = await agentProvider.GetMessagesAsync(conversationId).CountAsync();
}
++minExpectedCount;
Assert.True(messageCount >= minExpectedCount, $"Workflow message count less than expected: {minExpectedCount} (Actual: {messageCount}).");
if (maxExpectedCount != -1)
{
++maxExpectedCount;
Assert.True(messageCount <= maxExpectedCount, $"Workflow message count greater than expected: {maxExpectedCount} (Actual: {messageCount}).");
}
}
internal static void EventSequence(IEnumerable<string> sourceIds, Testcase testcase)
{
string lastId = string.Empty;
Queue<string> startIds = [];
Queue<string> repeatIds = [];
bool validateStart = false;
bool validateRepeat = false;
foreach (string sourceId in sourceIds)
{
if (!validateStart && testcase.Validation.Actions.Start.Count > 0)
{
if (testcase.Validation.Actions.Start.Count > 0 &&
startIds.Count == 0 &&
sourceId.Equals(testcase.Validation.Actions.Start[0], StringComparison.Ordinal))
{
// Initialize start sequence
startIds = new(testcase.Validation.Actions.Start);
}
// Verify start sequence
if (startIds.Count > 0)
{
Assert.Equal(startIds.Dequeue(), sourceId);
validateStart = startIds.Count == 0;
}
}
else
{
if (testcase.Validation.Actions.Repeat.Count > 0 &&
repeatIds.Count == 0 &&
sourceId.Equals(testcase.Validation.Actions.Repeat[0], StringComparison.Ordinal))
{
// Initialize repeat sequence
repeatIds = new(testcase.Validation.Actions.Repeat);
}
// Verify repeat sequence
if (repeatIds.Count > 0)
{
Assert.Equal(repeatIds.Dequeue(), sourceId);
validateRepeat = true;
}
}
lastId = sourceId;
}
Assert.Equal(testcase.Validation.Actions.Start.Count > 0, validateStart);
Assert.Equal(testcase.Validation.Actions.Repeat.Count > 0, validateRepeat);
Assert.NotEmpty(lastId);
HashSet<string> finalIds = [.. testcase.Validation.Actions.Final];
Assert.Contains(lastId, finalIds);
}
}
protected static readonly JsonSerializerOptions s_jsonSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
ReadCommentHandling = JsonCommentHandling.Skip,
WriteIndented = true,
};
}

View File

@@ -0,0 +1,102 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
public sealed class FunctionCallingWorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
{
[Fact]
public Task ValidateAutoInvokeAsync() =>
this.RunWorkflowAsync(autoInvoke: true, new MenuPlugin().GetTools());
[Fact]
public Task ValidateRequestInvokeAsync() =>
this.RunWorkflowAsync(autoInvoke: false, new MenuPlugin().GetTools());
private static string GetWorkflowPath(string workflowFileName) => Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName);
private async Task RunWorkflowAsync(bool autoInvoke, params IEnumerable<AIFunction> functionTools)
{
AgentProvider agentProvider = AgentProvider.Create(this.Configuration, AgentProvider.Names.FunctionTool);
await agentProvider.CreateAgentsAsync().ConfigureAwait(false);
string workflowPath = GetWorkflowPath("FunctionTool.yaml");
Dictionary<string, AIFunction> functionMap = autoInvoke ? [] : functionTools.ToDictionary(tool => tool.Name, tool => tool);
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false, autoInvoke ? functionTools : []);
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(workflowPath, workflowOptions);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("hi!").ConfigureAwait(false);
int requestCount = (workflowEvents.InputEvents.Count + 1) / 2;
int responseCount = 0;
while (requestCount > responseCount)
{
Assert.False(autoInvoke);
RequestInfoEvent inputEvent = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1];
ExternalInputRequest? toolRequest = inputEvent.Request.Data.As<ExternalInputRequest>();
Assert.NotNull(toolRequest);
List<(FunctionCallContent, AIFunction)> functionCalls = [];
foreach (FunctionCallContent functionCall in toolRequest.AgentResponse.Messages.SelectMany(message => message.Contents).OfType<FunctionCallContent>())
{
this.Output.WriteLine($"TOOL REQUEST: {functionCall.Name}");
if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool))
{
Assert.Fail($"TOOL FAILURE [{functionCall.Name}] - MISSING");
return;
}
functionCalls.Add((functionCall, functionTool));
}
IList<AIContent> functionResults = await InvokeToolsAsync(functionCalls);
++responseCount;
ChatMessage resultMessage = new(ChatRole.Tool, functionResults);
WorkflowEvents runEvents = await harness.ResumeAsync(inputEvent.Request.CreateResponse(new ExternalInputResponse(resultMessage))).ConfigureAwait(false);
workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]);
}
if (autoInvoke)
{
Assert.Empty(workflowEvents.InputEvents);
}
else
{
Assert.NotEmpty(workflowEvents.InputEvents);
}
Assert.Equal(autoInvoke ? 3 : 4, workflowEvents.AgentResponseEvents.Count);
Assert.All(workflowEvents.AgentResponseEvents, response => response.Response.Text.Contains("4.95"));
}
private static async ValueTask<IList<AIContent>> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls)
{
List<AIContent> results = [];
foreach ((FunctionCallContent functionCall, AIFunction functionTool) in functionCalls)
{
AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues());
object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false);
results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result)));
}
return results;
}
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Extensions.AI;
using OpenAI.Files;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(output)
{
private const string WorkflowFileName = "MediaInput.yaml";
private const string PdfReference = "https://sample-files.com/downloads/documents/pdf/basic-text.pdf";
private const string ImageReference = "https://sample-files.com/downloads/images/jpg/web_optimized_1200x800_97kb.jpg";
[Theory]
[InlineData(ImageReference, "image/jpeg", Skip = "Failing consistently in the agent service api")]
[InlineData(PdfReference, "application/pdf", Skip = "Not currently supported by agent service api")]
public async Task ValidateFileUrlAsync(string fileSource, string mediaType)
{
this.Output.WriteLine($"File: {ImageReference}");
await this.ValidateFileAsync(new UriContent(fileSource, mediaType));
}
[Theory]
[InlineData(ImageReference, "image/jpeg")]
[InlineData(PdfReference, "application/pdf")]
public async Task ValidateFileDataAsync(string fileSource, string mediaType)
{
byte[] fileData = await DownloadFileAsync(fileSource);
string encodedData = Convert.ToBase64String(fileData);
string fileUrl = $"data:{mediaType};base64,{encodedData}";
this.Output.WriteLine($"Content: {fileUrl.Substring(0, 112)}...");
await this.ValidateFileAsync(new DataContent(fileUrl));
}
[Fact(Skip = "Not currently supported by agent service api")]
public async Task ValidateFileUploadAsync()
{
byte[] fileData = await DownloadFileAsync(PdfReference);
AIProjectClient client = new(this.TestEndpoint, new AzureCliCredential());
using MemoryStream contentStream = new(fileData);
OpenAIFileClient fileClient = client.GetProjectOpenAIClient().GetOpenAIFileClient();
OpenAIFile fileInfo = await fileClient.UploadFileAsync(contentStream, "basic-text.pdf", FileUploadPurpose.Assistants);
try
{
this.Output.WriteLine($"File: {fileInfo.Id}");
await this.ValidateFileAsync(new HostedFileContent(fileInfo.Id));
}
finally
{
await fileClient.DeleteFileAsync(fileInfo.Id);
}
}
private static async Task<byte[]> DownloadFileAsync(string uri)
{
using HttpClient client = new();
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0");
return await client.GetByteArrayAsync(new Uri(uri));
}
private async Task ValidateFileAsync(AIContent fileContent)
{
AgentProvider agentProvider = AgentProvider.Create(this.Configuration, AgentProvider.Names.Vision);
await agentProvider.CreateAgentsAsync().ConfigureAwait(false);
ChatMessage inputMessage = new(ChatRole.User, [new TextContent("I've provided a file:"), fileContent]);
DeclarativeWorkflowOptions options = await this.CreateOptionsAsync();
Workflow workflow = DeclarativeWorkflowBuilder.Build<ChatMessage>(Path.Combine(Environment.CurrentDirectory, "Workflows", WorkflowFileName), options);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(WorkflowFileName));
WorkflowEvents workflowEvents = await harness.RunWorkflowAsync(inputMessage).ConfigureAwait(false);
ConversationUpdateEvent conversationEvent = Assert.Single(workflowEvents.ConversationEvents);
this.Output.WriteLine("CONVERSATION: " + conversationEvent.ConversationId);
AgentResponseEvent agentResponseEvent = Assert.Single(workflowEvents.AgentResponseEvents);
this.Output.WriteLine("RESPONSE: " + agentResponseEvent.Response.Text);
Assert.NotEmpty(agentResponseEvent.Response.Text);
}
}

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedBuildTestCode>true</InjectSharedBuildTestCode>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
</PropertyGroup>
<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" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
<ItemGroup>
<None Update="Agents\*.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="$(MSBuildThisFileDirectory)\..\..\..\workflow-samples\Setup\*.yaml" LinkBase="Agents">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
<None Update="Testcases\*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Workflows\*.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,24 @@
{
"description": "Send an activity message.",
"setup": {
"input": {
"type": "String",
"value": "Everything good?"
}
},
"validation": {
"conversation_count": 1,
"min_action_count": 2,
"max_action_count": -1,
"min_response_count": 0,
"actions": {
"start": [
"check_system"
],
"final": [
"activity_passed",
"check_system_Post"
]
}
}
}

View File

@@ -0,0 +1,29 @@
{
"description": "Human in the loop sample - RequestExternalInput.yaml.",
"setup": {
"input": {
"type": "String",
"value": "1234"
},
"responses": [
{
"type": "String",
"value": "1234"
}
]
},
"validation": {
"conversation_count": 1,
"min_action_count": 4,
"max_action_count": -1,
"min_response_count": 0,
"actions": {
"start": [
"set_project"
],
"final": [
"sendActivity_confirmed"
]
}
}
}

View File

@@ -0,0 +1,30 @@
{
"description": "Create conversation and manipulate messages.",
"setup": {
"input": {
"type": "String",
"value": "Why is the sky blue?"
}
},
"validation": {
"conversation_count": 2,
"min_action_count": 8,
"min_message_count": 1,
"min_response_count": 1,
"actions": {
"start": [
"conversation_create1",
"sendActivity_conversation",
"add_message",
"get_message_single",
"sendActivity_message",
"copy_messages",
"get_messages_all",
"sendActivity_copy"
],
"final": [
"sendActivity_copy"
]
}
}
}

View File

@@ -0,0 +1,43 @@
{
"description": "Planned orchestration sample - DeepResearch.yaml.",
"setup": {
"input": {
"type": "String",
"value": "What is the closest bus-stop that is next to ISHONI YAKINIKU in Seattle?"
}
},
"validation": {
"conversation_count": 2,
"min_action_count": 25,
"max_action_count": -1,
"min_response_count": 1,
"max_response_count": -1,
"actions": {
"start": [
"setVariable_aASlmF",
"setVariable_V6yEbo",
"setVariable_NZ2u0l",
"setVariable_10u2ZN",
"sendActivity_yFsbRy",
"conversation_1a2b3c",
"question_UDoMUw",
"sendActivity_yFsbRz",
"question_DsBaJU",
"setVariable_Kk2LDL",
"sendActivity_bwNZiM",
"question_o3BQkf",
"parse_rNZtlV",
"conditionGroup_mVIecC"
],
"repeat": [
"question_o3BQkf",
"parse_rNZtlV",
"conditionGroup_mVIecC"
],
"final": [
"end_SVoNSV",
"end_GHVrFh"
]
}
}
}

View File

@@ -0,0 +1,35 @@
{
"description": "Human in the loop sample - HumanInLoop.yaml.",
"setup": {
"input": {
"type": "String",
"value": "Iko"
},
"responses": [
{
"type": "String",
"value": "Adsf"
},
{
"type": "String",
"value": "Iko"
}
]
},
"validation": {
"conversation_count": 1,
"min_action_count": 8,
"min_response_count": 0,
"actions": {
"start": [
"set_project"
],
"repeat": [
"question_confirm"
],
"final": [
"sendActivity_confirmed"
]
}
}
}

View File

@@ -0,0 +1,23 @@
{
"description": "Authors a poem in the style specified by the input argument.",
"setup": {
"input": {
"type": "String",
"value": "Why is the sky blue?"
}
},
"validation": {
"conversation_count": 1,
"min_action_count": 1,
"min_response_count": 1,
"min_message_count": 2,
"actions": {
"start": [
"invoke_poem"
],
"final": [
"invoke_poem"
]
}
}
}

View File

@@ -0,0 +1,25 @@
{
"description": "Produce a single response from an agent.",
"setup": {
"input": {
"type": "String",
"value": "Why is the sky blue?"
}
},
"validation": {
"conversation_count": 3,
"min_action_count": 3,
"min_response_count": 3,
"min_message_count": 4,
"actions": {
"start": [
"invoke_inner1",
"invoke_inner2",
"invoke_external"
],
"final": [
"invoke_external"
]
}
}
}

View File

@@ -0,0 +1,25 @@
{
"description": "Sequential agent invocation sample - Marketing.yaml.",
"setup": {
"input": {
"type": "String",
"value": "An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours."
}
},
"validation": {
"conversation_count": 1,
"min_action_count": 3,
"min_response_count": 3,
"min_message_count": 6,
"actions": {
"start": [
"invoke_analyst",
"invoke_writer",
"invoke_editor"
],
"final": [
"invoke_editor"
]
}
}
}

View File

@@ -0,0 +1,33 @@
{
"description": "Student/Teacher sample - MathChat.yaml.",
"setup": {
"input": {
"type": "String",
"value": "How could one compute the value of PI?"
}
},
"validation": {
"conversation_count": 1,
"min_action_count": 6,
"max_action_count": -1,
"min_response_count": 2,
"max_response_count": 8,
"min_message_count": 4,
"max_message_count": -1,
"actions": {
"start": [
],
"repeat": [
"question_student",
"question_teacher",
"set_count_increment",
"check_completion"
],
"final": [
"sendActivity_done",
"sendActivity_tired",
"check_completion_Post"
]
}
}
}

View File

@@ -0,0 +1,29 @@
{
"description": "Human in the loop sample - RequestExternalInput.yaml.",
"setup": {
"input": {
"type": "String",
"value": "n/a"
},
"responses": [
{
"type": "String",
"value": "This is external input"
}
]
},
"validation": {
"conversation_count": 1,
"min_action_count": 2,
"min_response_count": 0,
"min_message_count": 1,
"actions": {
"start": [
"get_input"
],
"final": [
"show_input"
]
}
}
}

View File

@@ -0,0 +1,24 @@
{
"description": "Send an activity message.",
"setup": {
"input": {
"type": "String",
"value": "Why is the sky blue?"
}
},
"validation": {
"conversation_count": 1,
"min_action_count": 3,
"min_response_count": 0,
"actions": {
"start": [
"set_user_input",
"set_user_name",
"send_result"
],
"final": [
"send_result"
]
}
}
}

View File

@@ -0,0 +1,57 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: ConditionGroup
id: check_system
conditions:
- condition: =IsBlank(System.Conversation)
id: conversation_check
actions:
- kind: EndWorkflow
id: conversation_bad
- condition: =IsBlank(System.Conversation.Id)
id: conversation_id_check1
actions:
- kind: EndWorkflow
id: conversation_id_bad1
- condition: =IsBlank(System.ConversationId)
id: conversation_id_check2
actions:
- kind: EndWorkflow
id: conversation_id_bad2
- condition: =IsBlank(System.LastMessage)
id: message_check
actions:
- kind: EndWorkflow
id: message_bad
- condition: =IsBlank(System.LastMessage.Id)
id: message_id_check1
actions:
- kind: EndWorkflow
id: message_id_bad1
- condition: =IsBlank(System.LastMessageId)
id: message_id_check2
actions:
- kind: EndWorkflow
id: message_id_bad2
- condition: =IsBlank(System.LastMessageText)
id: message_text_check
actions:
- kind: EndWorkflow
id: message_text_bad
elseActions:
- kind: SendActivity
id: activity_passed
activity: PASSED!

View File

@@ -0,0 +1,61 @@
#
# This workflow demonstrates how to use the Question action
# to request user input and confirm it matches the original input.
#
# Note: This workflow doesn't make use of any agents.
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_demo
actions:
# Capture original input
- kind: SetVariable
id: set_project
variable: Local.OriginalInput
value: =System.LastMessage.Text
# Request input from user
- kind: Question
id: question_confirm
alwaysPrompt: false
autoSend: false
property: Local.ConfirmedInput
prompt:
kind: Message
text:
- "CONFIRM:"
entity:
kind: StringPrebuiltEntity
# Confirm input
- kind: ConditionGroup
id: check_completion
conditions:
# Didn't match
- condition: =Local.OriginalInput <> Local.ConfirmedInput
id: check_confirm
actions:
- kind: SendActivity
id: sendActivity_mismatch
activity: |-
"{Local.ConfirmedInput}" does not match the original input of "{Local.OriginalInput}". Please try again.
- kind: GotoAction
id: goto_again
actionId: question_confirm
# Confirmed
elseActions:
- kind: SendActivity
id: sendActivity_confirmed
activity: |-
You entered:
{Local.OriginalInput}
Confirmed input:
{Local.ConfirmedInput}

View File

@@ -0,0 +1,50 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: CreateConversation
id: conversation_create1
conversationId: Local.PrivateConversationId
- kind: SendActivity
id: sendActivity_conversation
activity: |-
Conversation 1: {Local.PrivateConversationId}
Conversation 2: {System.ConversationId}
- kind: AddConversationMessage
id: add_message
message: Local.MyMessage1
role: User
conversationId: =Local.PrivateConversationId
content:
- type: Text
value: {System.LastMessage.Text}
- kind: RetrieveConversationMessage
id: get_message_single
message: Local.MyMessage1Copy
conversationId: =Local.PrivateConversationId
messageId: =Local.MyMessage1.Id
- kind: SendActivity
id: sendActivity_message
activity: |-
Message 1: {Local.MyMessage1}
- kind: CopyConversationMessages
id: copy_messages
conversationId: =System.ConversationId
messages: =[Local.MyMessage1]
- kind: RetrieveConversationMessages
id: get_messages_all
messages: Local.AllMessages
conversationId: =System.ConversationId
- kind: SendActivity
id: sendActivity_copy
activity: Done!

View File

@@ -0,0 +1,28 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: InvokeAzureAgent
id: invoke_greet
conversationId: =System.ConversationId
agent:
name: MenuAgent
- kind: InvokeAzureAgent
id: invoke_menu
conversationId: =System.ConversationId
agent:
name: MenuAgent
input:
messages: =UserMessage("What's on today's menu?")
- kind: InvokeAzureAgent
id: invoke_item
conversationId: =System.ConversationId
agent:
name: MenuAgent
input:
messages: =UserMessage("How much is the clam chowder?")

View File

@@ -0,0 +1,15 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: InvokeAzureAgent
id: invoke_poem
conversationId: =System.ConversationId
agent:
name: PoemAgent
input:
arguments:
style: "ee cummings"

View File

@@ -0,0 +1,32 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: InvokeAzureAgent
id: invoke_inner1
agent:
name: TestAgent
input:
messages: =UserMessage("Can an LLM think of funny jokes?")
- kind: InvokeAzureAgent
id: invoke_inner2
agent:
name: TestAgent
input:
messages: =UserMessage("Do you know the joke about the chicken crossing the road? Tell me an improved version of that joke.")
output:
autoSend: true
- kind: InvokeAzureAgent
id: invoke_external
conversationId: =System.ConversationId
agent:
name: TestAgent
input:
messages: =UserMessage("Rate the originality of this well known joke that is being re-told on a scale of 1 to 10. Take note on where improvements or changes were made.")
output:
messages: Local.RatingResponse

View File

@@ -0,0 +1,12 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: InvokeAzureAgent
id: invoke_vision
conversationId: =System.ConversationId
agent:
name: VisionAgent

View File

@@ -0,0 +1,14 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: RequestExternalInput
id: get_input
variable: Local.MyInput
- kind: SendMessage
id: show_input
message: "You provided: {Local.MyInput}"

View File

@@ -0,0 +1,25 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
# Capture input
- kind: SetVariable
id: set_user_input
variable: Local.UserInput
value: =System.LastMessage.Text
# Capture environment variable
- kind: SetVariable
id: set_user_name
variable: Global.UserName
value: TestAgent
# Respond with input
- kind: SendActivity
id: send_result
activity: |-
Hello {Global.UserName},
You said, "{Local.UserInput}"