Merge branch 'main' into feat/durable_task

This commit is contained in:
Shyju Krishnankutty
2026-03-10 14:28:08 -07:00
committed by GitHub
Unverified
42 changed files with 1745 additions and 364 deletions
+1
View File
@@ -14,6 +14,7 @@
"src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj",
"src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj",
"src\\Microsoft.Agents.AI.DurableTask\\Microsoft.Agents.AI.DurableTask.csproj",
"src\\Microsoft.Agents.AI.FoundryMemory\\Microsoft.Agents.AI.FoundryMemory.csproj",
"src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -27,7 +26,7 @@ ChatClient chatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
AIAgent agent = chatClient.AsIChatClient().AsAIAgent(
AIAgent agent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant.");
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -82,7 +82,7 @@ ChatClient chatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
ChatClientAgent agent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant with access to restaurant information.",
tools: tools);
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -27,7 +26,7 @@ ChatClient chatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
AIAgent agent = chatClient.AsIChatClient().AsAIAgent(
AIAgent agent = chatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant.");
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -60,7 +60,7 @@ ChatClient openAIChatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
ChatClientAgent baseAgent = openAIChatClient.AsIChatClient().AsAIAgent(
ChatClientAgent baseAgent = openAIChatClient.AsAIAgent(
name: "AGUIAssistant",
instructions: "You are a helpful assistant in charge of approving expenses",
tools: tools);
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using OpenAI.Chat;
using RecipeAssistant;
@@ -37,7 +36,7 @@ ChatClient chatClient = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName);
AIAgent baseAgent = chatClient.AsIChatClient().AsAIAgent(
AIAgent baseAgent = chatClient.AsAIAgent(
name: "RecipeAgent",
instructions: """
You are a helpful recipe assistant. When users ask you to create or suggest a recipe,
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -12,11 +12,8 @@ using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// Memory store configuration
// NOTE: Memory stores must be created beforehand via Azure Portal or Python SDK.
// The .NET SDK currently only supports using existing memory stores with agents.
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? throw new InvalidOperationException("AZURE_AI_MEMORY_STORE_ID is not set.");
string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? $"foundry-memory-sample-{Guid.NewGuid():N}";
const string AgentInstructions = """
You are a helpful assistant that remembers past conversations.
@@ -32,71 +29,57 @@ const string AgentNameNative = "MemorySearchAgent-NATIVE";
string userScope = $"user_{Environment.MachineName}";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
DefaultAzureCredential credential = new();
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
// Ensure the memory store exists and has memories to retrieve.
await EnsureMemoryStoreAsync();
// Create the Memory Search tool configuration
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope)
{
// Optional: Configure how quickly new memories are indexed (in seconds)
UpdateDelay = 1,
// Optional: Configure search behavior
SearchOptions = new MemorySearchToolOptions
{
// Additional search options can be configured here if needed
}
};
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelay = 0 };
// Create agent using Option 1 (MEAI) or Option 2 (Native SDK)
AIAgent agent = await CreateAgentWithMEAI();
// AIAgent agent = await CreateAgentWithNativeSDK();
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
// Conversation 1: Share some personal information
Console.WriteLine("User: My name is Alice and I love programming in C#.");
AgentResponse response1 = await agent.RunAsync("My name is Alice and I love programming in C#.");
Console.WriteLine($"Agent: {response1.Messages.LastOrDefault()?.Text}\n");
// Allow time for memory to be indexed
await Task.Delay(2000);
// Conversation 2: Test if the agent remembers
Console.WriteLine("User: What's my name and what programming language do I prefer?");
AgentResponse response2 = await agent.RunAsync("What's my name and what programming language do I prefer?");
Console.WriteLine($"Agent: {response2.Messages.LastOrDefault()?.Text}\n");
// Inspect memory search results if available in raw response items
// Note: Memory search tool call results appear as AgentResponseItem types
foreach (var message in response2.Messages)
try
{
if (message.RawRepresentation is AgentResponseItem agentResponseItem &&
agentResponseItem is MemorySearchToolCallResponseItem memorySearchResult)
{
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
foreach (var result in memorySearchResult.Results)
// The agent uses the memory search tool to recall stored information.
Console.WriteLine("User: What's my name and what programming language do I prefer?");
AgentResponse response = await agent.RunAsync("What's my name and what programming language do I prefer?");
Console.WriteLine($"Agent: {response.Messages.LastOrDefault()?.Text}\n");
// Inspect memory search results if available in raw response items.
foreach (var message in response.Messages)
{
if (message.RawRepresentation is MemorySearchToolCallResponseItem memorySearchResult)
{
var memoryItem = result.MemoryItem;
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
Console.WriteLine($" Scope: {memoryItem.Scope}");
Console.WriteLine($" Content: {memoryItem.Content}");
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
foreach (var result in memorySearchResult.Results)
{
var memoryItem = result.MemoryItem;
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
Console.WriteLine($" Scope: {memoryItem.Scope}");
Console.WriteLine($" Content: {memoryItem.Content}");
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
}
}
}
}
finally
{
// Cleanup: Delete the agent and memory store.
Console.WriteLine("\nCleaning up...");
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
Console.WriteLine("Agent deleted.");
await aiProjectClient.MemoryStores.DeleteMemoryStoreAsync(memoryStoreName);
Console.WriteLine("Memory store deleted.");
}
// Cleanup: Delete the agent (memory store persists and should be cleaned up separately if needed)
Console.WriteLine("\nCleaning up agent...");
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
Console.WriteLine("Agent deleted successfully.");
// NOTE: Memory stores are long-lived resources and are NOT deleted with the agent.
// To delete a memory store, use the Azure Portal or Python SDK:
// await project_client.memory_stores.delete(memory_store.name)
// --- Agent Creation Options ---
#pragma warning disable CS8321 // Local function is declared but never used
// Option 1 - Using MemorySearchTool wrapped as MEAI AITool
@@ -122,3 +105,36 @@ async Task<AIAgent> CreateAgentWithNativeSDK()
})
);
}
// Helpers — kept at the bottom so the main agent flow above stays clean.
async Task EnsureMemoryStoreAsync()
{
Console.WriteLine($"Creating memory store '{memoryStoreName}'...");
try
{
await aiProjectClient.MemoryStores.GetMemoryStoreAsync(memoryStoreName);
Console.WriteLine("Memory store already exists.");
}
catch (System.ClientModel.ClientResultException ex) when (ex.Status == 404)
{
MemoryStoreDefaultDefinition definition = new(deploymentName, embeddingModelName);
await aiProjectClient.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, "Sample memory store for Memory Search demo");
Console.WriteLine("Memory store created.");
}
Console.WriteLine("Storing memories from a prior conversation...");
MemoryUpdateOptions memoryOptions = new(userScope) { UpdateDelay = 0 };
memoryOptions.Items.Add(ResponseItem.CreateUserMessageItem("My name is Alice and I love programming in C#."));
MemoryUpdateResult updateResult = await aiProjectClient.MemoryStores.WaitForMemoriesUpdateAsync(
memoryStoreName: memoryStoreName,
options: memoryOptions,
pollingInterval: 500);
if (updateResult.Status == MemoryStoreUpdateStatus.Failed)
{
throw new InvalidOperationException($"Memory update failed: {updateResult.ErrorDetails}");
}
Console.WriteLine($"Memory update completed (status: {updateResult.Status}).\n");
}
@@ -10,7 +10,7 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ChatClient = OpenAI.Chat.ChatClient;
using OpenAI.Chat;
namespace AGUIDojoServer;
@@ -36,7 +36,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().AsAIAgent(
return chatClient.AsAIAgent(
name: "AgenticChat",
description: "A simple chat agent using Azure OpenAI");
}
@@ -45,7 +45,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().AsAIAgent(
return chatClient.AsAIAgent(
name: "BackendToolRenderer",
description: "An agent that can render backend tools using Azure OpenAI",
tools: [AIFunctionFactory.Create(
@@ -59,7 +59,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().AsAIAgent(
return chatClient.AsAIAgent(
name: "HumanInTheLoopAgent",
description: "An agent that involves human feedback in its decision-making process using Azure OpenAI");
}
@@ -68,7 +68,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().AsAIAgent(
return chatClient.AsAIAgent(
name: "ToolBasedGenerativeUIAgent",
description: "An agent that uses tools to generate user interfaces using Azure OpenAI");
}
@@ -76,7 +76,7 @@ internal static class ChatClientAgentFactory
public static AIAgent CreateAgenticUI(JsonSerializerOptions options)
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsIChatClient().AsAIAgent(new ChatClientAgentOptions
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "AgenticUIAgent",
Description = "An agent that generates agentic user interfaces using Azure OpenAI",
@@ -119,7 +119,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsIChatClient().AsAIAgent(
var baseAgent = chatClient.AsAIAgent(
name: "SharedStateAgent",
description: "An agent that demonstrates shared state patterns using Azure OpenAI");
@@ -130,7 +130,7 @@ internal static class ChatClientAgentFactory
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsIChatClient().AsAIAgent(new ChatClientAgentOptions
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "PredictiveStateUpdatesAgent",
Description = "An agent that demonstrates predictive state updates using Azure OpenAI",
@@ -74,7 +74,7 @@ AzureOpenAIClient azureOpenAIClient = new AzureOpenAIClient(
ChatClient chatClient = azureOpenAIClient.GetChatClient(deploymentName);
// Create AI agent
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
ChatClientAgent agent = chatClient.AsAIAgent(
name: "ChatAssistant",
instructions: "You are a helpful assistant.");
@@ -162,7 +162,7 @@ dotnet run
Edit the instructions in `Server/Program.cs`:
```csharp
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
ChatClientAgent agent = chatClient.AsAIAgent(
name: "ChatAssistant",
instructions: "You are a helpful coding assistant specializing in C# and .NET.");
```
@@ -6,7 +6,6 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -28,7 +27,7 @@ AzureOpenAIClient azureOpenAIClient = new(
ChatClient chatClient = azureOpenAIClient.GetChatClient(deploymentName);
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
ChatClientAgent agent = chatClient.AsAIAgent(
name: "ChatAssistant",
instructions: "You are a helpful assistant.");
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -89,7 +90,6 @@ builder.Services.AddSingleton<AIAgent>(sp =>
return new OpenAIClient(apiKey)
.GetChatClient(model)
.AsIChatClient()
.AsAIAgent(
name: "ExpenseApprovalAgent",
instructions: "You are an expense approval assistant. You can list pending expenses "
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
@@ -13,10 +13,6 @@
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- Disable packing until we are ready to release this as a nuget -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
@@ -3,6 +3,7 @@
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
@@ -133,7 +134,25 @@ internal sealed class ExecutorProtocol(MessageRouter router, ISet<Type> sendType
public bool CanHandle(Type type) => router.CanHandle(type);
public bool CanOutput(Type type) => this._yieldTypes.Contains(new(type));
private readonly ConcurrentDictionary<Type, bool> _canOutputCache = new();
public bool CanOutput(Type type)
{
return this._canOutputCache.GetOrAdd(type, this.CanOutputCore);
}
private bool CanOutputCore(Type type)
{
foreach (TypeId yieldType in this._yieldTypes)
{
if (yieldType.IsMatchPolymorphic(type))
{
return true;
}
}
return false;
}
public ProtocolDescriptor Describe() => new(this.Router.IncomingTypes, yieldTypes, sendTypes, this.Router.HasCatchAll);
}
@@ -14,6 +14,8 @@ namespace AzureAIAgentsPersistent.IntegrationTests;
public class AzureAIAgentsPersistentCreateTests
{
private const string SkipCodeInterpreterReason = "Azure AI Code Interpreter intermittently fails to execute uploaded files in CI";
private readonly PersistentAgentsClient _persistentAgentsClient = new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint), TestAzureCliCredentials.CreateAzureCliCredential());
[Theory]
@@ -131,11 +133,11 @@ public class AzureAIAgentsPersistentCreateTests
}
}
[Fact]
[Fact(Skip = SkipCodeInterpreterReason)]
public Task CreateAgent_CreatesAgentWithCodeInterpreter_ChatClientAgentOptionsAsync()
=> this.CreateAgent_CreatesAgentWithCodeInterpreterAsync("CreateWithChatClientAgentOptionsAsync");
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
[Fact(Skip = SkipCodeInterpreterReason)]
public Task CreateAgent_CreatesAgentWithCodeInterpreter_FoundryOptionsAsync()
=> this.CreateAgent_CreatesAgentWithCodeInterpreterAsync("CreateWithFoundryOptionsAsync");
@@ -0,0 +1,276 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Regression tests for polymorphic output type handling in workflows.
/// Verifies that executors can return derived types when the declared output type is a base class.
/// </summary>
/// <remarks>
/// This addresses GitHub issue #4134: InvalidOperationException when returning derived type as workflow output.
/// </remarks>
public partial class PolymorphicOutputTests
{
#region Test Type Hierarchy
/// <summary>
/// Base class used as declared output type.
/// </summary>
public class BaseOutput
{
public virtual string Name => "BaseOutput";
}
/// <summary>
/// Derived class returned at runtime.
/// </summary>
public class DerivedOutput : BaseOutput
{
public override string Name => "DerivedOutput";
}
/// <summary>
/// Second-level derived class for testing multiple inheritance levels.
/// </summary>
public class GrandchildOutput : DerivedOutput
{
public override string Name => "GrandchildOutput";
}
/// <summary>
/// Unrelated class that should NOT be accepted as output.
/// </summary>
public class UnrelatedOutput
{
public string Name => "UnrelatedOutput";
}
#endregion
#region Test Executors
/// <summary>
/// Executor that declares BaseOutput as yield type but returns DerivedOutput.
/// </summary>
internal sealed class DerivedOutputExecutor() : Executor(nameof(DerivedOutputExecutor))
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string, BaseOutput>(this.HandleAsync));
}
private async ValueTask<BaseOutput> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
{
await Task.Delay(10, cancellationToken);
// Arrange: Return a derived type where the method signature declares the base type
return new DerivedOutput();
}
}
/// <summary>
/// Executor that declares BaseOutput as yield type but returns GrandchildOutput (two levels deep).
/// </summary>
internal sealed class GrandchildOutputExecutor() : Executor(nameof(GrandchildOutputExecutor))
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string, BaseOutput>(this.HandleAsync));
}
private async ValueTask<BaseOutput> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
{
await Task.Delay(10, cancellationToken);
// Arrange: Return a grandchild type (two inheritance levels)
return new GrandchildOutput();
}
}
/// <summary>
/// Executor that attempts to return an unrelated type - should fail validation.
/// This executor intentionally bypasses type safety to test runtime validation.
/// </summary>
internal sealed class UnrelatedOutputExecutor() : Executor(nameof(UnrelatedOutputExecutor))
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string, BaseOutput>(this.HandleAsync));
}
private async ValueTask<BaseOutput> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
{
// Arrange: Attempt to yield an unrelated type - should throw
UnrelatedOutput unrelated = new();
await context.YieldOutputAsync(unrelated, cancellationToken).ConfigureAwait(false);
// This line should not be reached
return new BaseOutput();
}
}
/// <summary>
/// Executor that returns the exact declared type (baseline test).
/// </summary>
internal sealed class ExactTypeExecutor() : Executor(nameof(ExactTypeExecutor))
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string, BaseOutput>(this.HandleAsync));
}
private ValueTask<BaseOutput> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
{
BaseOutput result = new();
return new ValueTask<BaseOutput>(result);
}
}
#endregion
#region Tests
/// <summary>
/// Verifies that returning a derived type when the declared output type is a base class succeeds.
/// This is the main regression test for GitHub issue #4134.
/// </summary>
[Fact]
public async Task ReturningDerivedType_WhenBaseTypeIsDeclared_ShouldSucceedAsync()
{
// Arrange
DerivedOutputExecutor executor = new();
WorkflowBuilder builder = new WorkflowBuilder(executor).WithOutputFrom(executor);
Workflow workflow = builder.Build();
// Act
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "test input");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert
events.Should().NotBeEmpty("workflow should produce events");
List<WorkflowOutputEvent> outputEvents = events.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().ContainSingle("workflow should produce exactly one output event");
WorkflowOutputEvent outputEvent = outputEvents.Single();
outputEvent.Data.Should().BeOfType<DerivedOutput>("output should be the derived type");
((DerivedOutput)outputEvent.Data!).Name.Should().Be("DerivedOutput");
// Verify no error events
List<WorkflowErrorEvent> errorEvents = events.OfType<WorkflowErrorEvent>().ToList();
errorEvents.Should().BeEmpty("workflow should not produce error events");
}
/// <summary>
/// Verifies that returning a grandchild type (multiple inheritance levels) succeeds.
/// </summary>
[Fact]
public async Task ReturningGrandchildType_WhenBaseTypeIsDeclared_ShouldSucceedAsync()
{
// Arrange
GrandchildOutputExecutor executor = new();
WorkflowBuilder builder = new WorkflowBuilder(executor).WithOutputFrom(executor);
Workflow workflow = builder.Build();
// Act
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "test input");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert
events.Should().NotBeEmpty("workflow should produce events");
List<WorkflowOutputEvent> outputEvents = events.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().ContainSingle("workflow should produce exactly one output event");
WorkflowOutputEvent outputEvent = outputEvents.Single();
outputEvent.Data.Should().BeOfType<GrandchildOutput>("output should be the grandchild type");
((GrandchildOutput)outputEvent.Data!).Name.Should().Be("GrandchildOutput");
// Verify no error events
List<WorkflowErrorEvent> errorEvents = events.OfType<WorkflowErrorEvent>().ToList();
errorEvents.Should().BeEmpty("workflow should not produce error events");
}
/// <summary>
/// Verifies that returning an unrelated type still throws InvalidOperationException.
/// This ensures the fix doesn't break the existing validation for truly incompatible types.
/// </summary>
[Fact]
public async Task ReturningUnrelatedType_WhenBaseTypeIsDeclared_ShouldFailAsync()
{
// Arrange
UnrelatedOutputExecutor executor = new();
WorkflowBuilder builder = new WorkflowBuilder(executor).WithOutputFrom(executor);
Workflow workflow = builder.Build();
// Act
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "test input");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert: Should have an error event with InvalidOperationException message
List<WorkflowErrorEvent> errorEvents = events.OfType<WorkflowErrorEvent>().ToList();
errorEvents.Should().ContainSingle("workflow should produce exactly one error event");
WorkflowErrorEvent errorEvent = errorEvents.Single();
string errorMessage = errorEvent.Data?.ToString() ?? string.Empty;
errorMessage.Should().Contain("Cannot output object of type UnrelatedOutput");
errorMessage.Should().Contain("BaseOutput");
}
/// <summary>
/// Verifies that returning the exact declared type still works (baseline test).
/// </summary>
[Fact]
public async Task ReturningExactType_WhenSameTypeIsDeclared_ShouldSucceedAsync()
{
// Arrange: Create an executor that returns the exact declared type
ExactTypeExecutor executor = new();
WorkflowBuilder builder = new WorkflowBuilder(executor).WithOutputFrom(executor);
Workflow workflow = builder.Build();
// Act
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "test input");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert
events.Should().NotBeEmpty("workflow should produce events");
List<WorkflowOutputEvent> outputEvents = events.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().ContainSingle("workflow should produce exactly one output event");
WorkflowOutputEvent outputEvent = outputEvents.Single();
outputEvent.Data.Should().BeOfType<BaseOutput>("output should be the exact base type");
// Verify no error events
List<WorkflowErrorEvent> errorEvents = events.OfType<WorkflowErrorEvent>().ToList();
errorEvents.Should().BeEmpty("workflow should not produce error events");
}
#endregion
}
@@ -19,6 +19,8 @@ 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();
@@ -81,7 +83,7 @@ public class OpenAIAssistantClientExtensionsTests
}
}
[Theory]
[Theory(Skip = SkipCodeInterpreterReason)]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithParamsAsync")]