diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 5ee419114b..a5d695dbad 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -143,6 +143,7 @@
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 002efdbab1..9e210a7062 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -396,6 +396,7 @@
+
@@ -435,6 +436,7 @@
+
\ No newline at end of file
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs
new file mode 100644
index 0000000000..cdea7178ce
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs
@@ -0,0 +1,550 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using Microsoft.Agents.AI.Workflows.Generators.Diagnostics;
+using Microsoft.Agents.AI.Workflows.Generators.Models;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Analysis;
+
+///
+/// Provides semantic analysis of executor route candidates.
+///
+///
+/// Analysis is split into two phases for efficiency with incremental generators:
+///
+/// - - Called per method, extracts data and performs method-level validation only.
+/// - - Groups methods by class and performs class-level validation once.
+///
+/// This avoids redundant class validation when multiple handlers exist in the same class.
+///
+internal static class SemanticAnalyzer
+{
+ // Fully-qualified type names used for symbol comparison
+ private const string ExecutorTypeName = "Microsoft.Agents.AI.Workflows.Executor";
+ private const string WorkflowContextTypeName = "Microsoft.Agents.AI.Workflows.IWorkflowContext";
+ private const string CancellationTokenTypeName = "System.Threading.CancellationToken";
+ private const string ValueTaskTypeName = "System.Threading.Tasks.ValueTask";
+ private const string MessageHandlerAttributeName = "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute";
+ private const string SendsMessageAttributeName = "Microsoft.Agents.AI.Workflows.SendsMessageAttribute";
+ private const string YieldsMessageAttributeName = "Microsoft.Agents.AI.Workflows.YieldsMessageAttribute";
+
+ ///
+ /// Analyzes a method with [MessageHandler] attribute found by ForAttributeWithMetadataName.
+ /// Returns a MethodAnalysisResult containing both method info and class context.
+ ///
+ ///
+ /// This method only extracts raw data and performs method-level validation.
+ /// Class-level validation is deferred to to avoid
+ /// redundant validation when a class has multiple handler methods.
+ ///
+ public static MethodAnalysisResult AnalyzeMethod(
+ GeneratorAttributeSyntaxContext context,
+ CancellationToken cancellationToken)
+ {
+ // The target should be a method
+ if (context.TargetSymbol is not IMethodSymbol methodSymbol)
+ {
+ return CreateEmptyResult();
+ }
+
+ // Get the containing class
+ var classSymbol = methodSymbol.ContainingType;
+ if (classSymbol is null)
+ {
+ return CreateEmptyResult();
+ }
+
+ // Get the method syntax for location info
+ var methodSyntax = context.TargetNode as MethodDeclarationSyntax;
+
+ // Extract class-level info (raw facts, no validation here)
+ var classKey = GetClassKey(classSymbol);
+ var isPartialClass = IsPartialClass(classSymbol, cancellationToken);
+ var derivesFromExecutor = DerivesFromExecutor(classSymbol);
+ var hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol);
+
+ // Extract class metadata
+ var @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
+ ? null
+ : classSymbol.ContainingNamespace?.ToDisplayString();
+ var className = classSymbol.Name;
+ var genericParameters = GetGenericParameters(classSymbol);
+ var isNested = classSymbol.ContainingType != null;
+ var containingTypeChain = GetContainingTypeChain(classSymbol);
+ var baseHasConfigureRoutes = BaseHasConfigureRoutes(classSymbol);
+ var classSendTypes = GetClassLevelTypes(classSymbol, SendsMessageAttributeName);
+ var classYieldTypes = GetClassLevelTypes(classSymbol, YieldsMessageAttributeName);
+
+ // Get class location for class-level diagnostics
+ var classLocation = GetClassLocation(classSymbol, cancellationToken);
+
+ // Analyze the handler method (method-level validation only)
+ // Skip method analysis if class doesn't derive from Executor (class-level diagnostic will be reported later)
+ var methodDiagnostics = ImmutableArray.CreateBuilder();
+ HandlerInfo? handler = null;
+ if (derivesFromExecutor)
+ {
+ handler = AnalyzeHandler(methodSymbol, methodSyntax, methodDiagnostics);
+ }
+
+ return new MethodAnalysisResult(
+ classKey, @namespace, className, genericParameters, isNested, containingTypeChain,
+ baseHasConfigureRoutes, classSendTypes, classYieldTypes,
+ isPartialClass, derivesFromExecutor, hasManualConfigureRoutes,
+ classLocation,
+ handler,
+ Diagnostics: new ImmutableEquatableArray(methodDiagnostics.ToImmutable()));
+ }
+
+ ///
+ /// Combines multiple MethodAnalysisResults for the same class into an AnalysisResult.
+ /// Performs class-level validation once (instead of per-method) for efficiency.
+ ///
+ public static AnalysisResult CombineMethodResults(IEnumerable methodResults)
+ {
+ var methods = methodResults.ToList();
+ if (methods.Count == 0)
+ {
+ return AnalysisResult.Empty;
+ }
+
+ // All methods should have same class info - take from first
+ var first = methods[0];
+ var classLocation = first.ClassLocation?.ToRoslynLocation() ?? Location.None;
+
+ // Collect method-level diagnostics
+ var allDiagnostics = ImmutableArray.CreateBuilder();
+ foreach (var method in methods)
+ {
+ foreach (var diag in method.Diagnostics)
+ {
+ allDiagnostics.Add(diag.ToRoslynDiagnostic(null));
+ }
+ }
+
+ // Class-level validation (done once, not per-method)
+ if (!first.DerivesFromExecutor)
+ {
+ allDiagnostics.Add(Diagnostic.Create(
+ DiagnosticDescriptors.NotAnExecutor,
+ classLocation,
+ first.ClassName,
+ first.ClassName));
+ return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
+ }
+
+ if (!first.IsPartialClass)
+ {
+ allDiagnostics.Add(Diagnostic.Create(
+ DiagnosticDescriptors.ClassMustBePartial,
+ classLocation,
+ first.ClassName));
+ return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
+ }
+
+ if (first.HasManualConfigureRoutes)
+ {
+ allDiagnostics.Add(Diagnostic.Create(
+ DiagnosticDescriptors.ConfigureRoutesAlreadyDefined,
+ classLocation,
+ first.ClassName));
+ return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
+ }
+
+ // Collect valid handlers
+ var handlers = methods
+ .Where(m => m.Handler is not null)
+ .Select(m => m.Handler!)
+ .ToImmutableArray();
+
+ if (handlers.Length == 0)
+ {
+ return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
+ }
+
+ var executorInfo = new ExecutorInfo(
+ first.Namespace,
+ first.ClassName,
+ first.GenericParameters,
+ first.IsNested,
+ first.ContainingTypeChain,
+ first.BaseHasConfigureRoutes,
+ new ImmutableEquatableArray(handlers),
+ first.ClassSendTypes,
+ first.ClassYieldTypes);
+
+ if (allDiagnostics.Count > 0)
+ {
+ return AnalysisResult.WithInfoAndDiagnostics(executorInfo, allDiagnostics.ToImmutable());
+ }
+
+ return AnalysisResult.Success(executorInfo);
+ }
+
+ ///
+ /// Creates a placeholder result for invalid targets (e.g., attribute on non-method).
+ ///
+ private static MethodAnalysisResult CreateEmptyResult()
+ {
+ return new MethodAnalysisResult(
+ string.Empty, null, string.Empty, null, false, string.Empty,
+ false, ImmutableEquatableArray.Empty, ImmutableEquatableArray.Empty,
+ false, false, false,
+ null, null, ImmutableEquatableArray.Empty);
+ }
+
+ ///
+ /// Gets the source location of the class identifier for diagnostic reporting.
+ ///
+ private static DiagnosticLocationInfo? GetClassLocation(INamedTypeSymbol classSymbol, CancellationToken cancellationToken)
+ {
+ foreach (var syntaxRef in classSymbol.DeclaringSyntaxReferences)
+ {
+ var syntax = syntaxRef.GetSyntax(cancellationToken);
+ if (syntax is ClassDeclarationSyntax classDecl)
+ {
+ return DiagnosticLocationInfo.FromLocation(classDecl.Identifier.GetLocation());
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Returns a unique identifier for the class used to group methods by their containing type.
+ ///
+ private static string GetClassKey(INamedTypeSymbol classSymbol)
+ {
+ return classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ }
+
+ ///
+ /// Checks if any declaration of the class has the 'partial' modifier.
+ ///
+ private static bool IsPartialClass(INamedTypeSymbol classSymbol, CancellationToken cancellationToken)
+ {
+ foreach (var syntaxRef in classSymbol.DeclaringSyntaxReferences)
+ {
+ var syntax = syntaxRef.GetSyntax(cancellationToken);
+ if (syntax is ClassDeclarationSyntax classDecl &&
+ classDecl.Modifiers.Any(SyntaxKind.PartialKeyword))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Walks the inheritance chain to check if the class derives from Executor or Executor<T>.
+ ///
+ private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol)
+ {
+ var current = classSymbol.BaseType;
+ while (current != null)
+ {
+ var fullName = current.OriginalDefinition.ToDisplayString();
+ if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ current = current.BaseType;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks if this class directly defines ConfigureRoutes (not inherited).
+ /// If so, we skip generation to avoid conflicting with user's manual implementation.
+ ///
+ private static bool HasConfigureRoutesDefined(INamedTypeSymbol classSymbol)
+ {
+ foreach (var member in classSymbol.GetMembers("ConfigureRoutes"))
+ {
+ if (member is IMethodSymbol method && !method.IsAbstract &&
+ SymbolEqualityComparer.Default.Equals(method.ContainingType, classSymbol))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks if any base class (between this class and Executor) defines ConfigureRoutes.
+ /// If so, generated code should call base.ConfigureRoutes() to preserve inherited handlers.
+ ///
+ private static bool BaseHasConfigureRoutes(INamedTypeSymbol classSymbol)
+ {
+ var baseType = classSymbol.BaseType;
+ while (baseType != null)
+ {
+ var fullName = baseType.OriginalDefinition.ToDisplayString();
+ // Stop at Executor - its ConfigureRoutes is abstract/empty
+ if (fullName == ExecutorTypeName)
+ {
+ return false;
+ }
+
+ foreach (var member in baseType.GetMembers("ConfigureRoutes"))
+ {
+ if (member is IMethodSymbol method && !method.IsAbstract)
+ {
+ return true;
+ }
+ }
+
+ baseType = baseType.BaseType;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Validates a handler method's signature and extracts metadata.
+ ///
+ ///
+ /// Valid signatures:
+ ///
+ /// - void Handle(TMessage, IWorkflowContext, [CancellationToken])
+ /// - ValueTask HandleAsync(TMessage, IWorkflowContext, [CancellationToken])
+ /// - ValueTask<TResult> HandleAsync(TMessage, IWorkflowContext, [CancellationToken])
+ /// - TResult Handle(TMessage, IWorkflowContext, [CancellationToken]) (sync with result)
+ ///
+ ///
+ private static HandlerInfo? AnalyzeHandler(
+ IMethodSymbol methodSymbol,
+ MethodDeclarationSyntax? methodSyntax,
+ ImmutableArray.Builder diagnostics)
+ {
+ var location = methodSyntax?.Identifier.GetLocation() ?? Location.None;
+
+ // Check if static
+ if (methodSymbol.IsStatic)
+ {
+ diagnostics.Add(DiagnosticInfo.Create("MAFGENWF007", location, methodSymbol.Name));
+ return null;
+ }
+
+ // Check parameter count
+ if (methodSymbol.Parameters.Length < 2)
+ {
+ diagnostics.Add(DiagnosticInfo.Create("MAFGENWF005", location, methodSymbol.Name));
+ return null;
+ }
+
+ // Check second parameter is IWorkflowContext
+ var secondParam = methodSymbol.Parameters[1];
+ if (secondParam.Type.ToDisplayString() != WorkflowContextTypeName)
+ {
+ diagnostics.Add(DiagnosticInfo.Create("MAFGENWF001", location, methodSymbol.Name));
+ return null;
+ }
+
+ // Check for optional CancellationToken as third parameter
+ var hasCancellationToken = methodSymbol.Parameters.Length >= 3 &&
+ methodSymbol.Parameters[2].Type.ToDisplayString() == CancellationTokenTypeName;
+
+ // Analyze return type
+ var returnType = methodSymbol.ReturnType;
+ var signatureKind = GetSignatureKind(returnType);
+ if (signatureKind == null)
+ {
+ diagnostics.Add(DiagnosticInfo.Create("MAFGENWF002", location, methodSymbol.Name));
+ return null;
+ }
+
+ // Get input type
+ var inputType = methodSymbol.Parameters[0].Type;
+ var inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ // Get output type
+ string? outputTypeName = null;
+ if (signatureKind == HandlerSignatureKind.ResultSync)
+ {
+ outputTypeName = returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ }
+ else if (signatureKind == HandlerSignatureKind.ResultAsync && returnType is INamedTypeSymbol namedReturn)
+ {
+ if (namedReturn.TypeArguments.Length == 1)
+ {
+ outputTypeName = namedReturn.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ }
+ }
+
+ // Get Yield and Send types from attribute
+ var (yieldTypes, sendTypes) = GetAttributeTypeArrays(methodSymbol);
+
+ return new HandlerInfo(
+ methodSymbol.Name,
+ inputTypeName,
+ outputTypeName,
+ signatureKind.Value,
+ hasCancellationToken,
+ yieldTypes,
+ sendTypes);
+ }
+
+ ///
+ /// Determines the handler signature kind from the return type.
+ ///
+ /// The signature kind, or null if the return type is not supported (e.g., Task, Task<T>).
+ private static HandlerSignatureKind? GetSignatureKind(ITypeSymbol returnType)
+ {
+ var returnTypeName = returnType.ToDisplayString();
+
+ if (returnType.SpecialType == SpecialType.System_Void)
+ {
+ return HandlerSignatureKind.VoidSync;
+ }
+
+ if (returnTypeName == ValueTaskTypeName)
+ {
+ return HandlerSignatureKind.VoidAsync;
+ }
+
+ if (returnType is INamedTypeSymbol namedType &&
+ namedType.OriginalDefinition.ToDisplayString() == "System.Threading.Tasks.ValueTask")
+ {
+ return HandlerSignatureKind.ResultAsync;
+ }
+
+ // Any non-void, non-Task type is treated as a synchronous result
+ if (returnType.SpecialType != SpecialType.System_Void &&
+ !returnTypeName.StartsWith("System.Threading.Tasks.Task", StringComparison.Ordinal) &&
+ !returnTypeName.StartsWith("System.Threading.Tasks.ValueTask", StringComparison.Ordinal))
+ {
+ return HandlerSignatureKind.ResultSync;
+ }
+
+ // Task/Task not supported - must use ValueTask
+ return null;
+ }
+
+ ///
+ /// Extracts Yield and Send type arrays from the [MessageHandler] attribute's named arguments.
+ ///
+ ///
+ /// [MessageHandler(Yield = new[] { typeof(OutputA), typeof(OutputB) }, Send = new[] { typeof(Request) })]
+ ///
+ private static (ImmutableEquatableArray YieldTypes, ImmutableEquatableArray SendTypes) GetAttributeTypeArrays(
+ IMethodSymbol methodSymbol)
+ {
+ var yieldTypes = ImmutableArray.Empty;
+ var sendTypes = ImmutableArray.Empty;
+
+ foreach (var attr in methodSymbol.GetAttributes())
+ {
+ if (attr.AttributeClass?.ToDisplayString() != MessageHandlerAttributeName)
+ {
+ continue;
+ }
+
+ foreach (var namedArg in attr.NamedArguments)
+ {
+ if (namedArg.Key == "Yield" && !namedArg.Value.IsNull)
+ {
+ yieldTypes = ExtractTypeArray(namedArg.Value);
+ }
+ else if (namedArg.Key == "Send" && !namedArg.Value.IsNull)
+ {
+ sendTypes = ExtractTypeArray(namedArg.Value);
+ }
+ }
+ }
+
+ return (new ImmutableEquatableArray(yieldTypes), new ImmutableEquatableArray(sendTypes));
+ }
+
+ ///
+ /// Converts a TypedConstant array (from attribute argument) to fully-qualified type name strings.
+ ///
+ private static ImmutableArray ExtractTypeArray(TypedConstant typedConstant)
+ {
+ if (typedConstant.Kind != TypedConstantKind.Array)
+ {
+ return ImmutableArray.Empty;
+ }
+
+ var builder = ImmutableArray.CreateBuilder();
+ foreach (var value in typedConstant.Values)
+ {
+ if (value.Value is INamedTypeSymbol typeSymbol)
+ {
+ builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
+ }
+ }
+
+ return builder.ToImmutable();
+ }
+
+ ///
+ /// Collects types from [SendsMessage] or [YieldsMessage] attributes applied to the class.
+ ///
+ ///
+ /// [SendsMessage(typeof(Request))]
+ /// [YieldsMessage(typeof(Response))]
+ /// public partial class MyExecutor : Executor { }
+ ///
+ private static ImmutableEquatableArray GetClassLevelTypes(INamedTypeSymbol classSymbol, string attributeName)
+ {
+ var builder = ImmutableArray.CreateBuilder();
+
+ foreach (var attr in classSymbol.GetAttributes())
+ {
+ if (attr.AttributeClass?.ToDisplayString() == attributeName &&
+ attr.ConstructorArguments.Length > 0 &&
+ attr.ConstructorArguments[0].Value is INamedTypeSymbol typeSymbol)
+ {
+ builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
+ }
+ }
+
+ return new ImmutableEquatableArray(builder.ToImmutable());
+ }
+
+ ///
+ /// Builds the chain of containing types for nested classes, outermost first.
+ ///
+ ///
+ /// For class Outer.Middle.Inner.MyExecutor, returns "Outer.Middle.Inner"
+ ///
+ private static string GetContainingTypeChain(INamedTypeSymbol classSymbol)
+ {
+ var chain = new List();
+ var current = classSymbol.ContainingType;
+
+ while (current != null)
+ {
+ chain.Insert(0, current.Name);
+ current = current.ContainingType;
+ }
+
+ return string.Join(".", chain);
+ }
+
+ ///
+ /// Returns the generic type parameter clause (e.g., "<T, U>") for generic classes, or null for non-generic.
+ ///
+ private static string? GetGenericParameters(INamedTypeSymbol classSymbol)
+ {
+ if (!classSymbol.IsGenericType)
+ {
+ return null;
+ }
+
+ var parameters = string.Join(", ", classSymbol.TypeParameters.Select(p => p.Name));
+ return $"<{parameters}>";
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs
new file mode 100644
index 0000000000..4afc7a1697
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs
@@ -0,0 +1,107 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Diagnostics;
+
+///
+/// Diagnostic descriptors for the executor route source generator.
+///
+internal static class DiagnosticDescriptors
+{
+ private const string Category = "Microsoft.Agents.AI.Workflows.Generators";
+
+ private static readonly Dictionary s_descriptorsById = new();
+
+ ///
+ /// Gets a diagnostic descriptor by its ID.
+ ///
+ public static DiagnosticDescriptor? GetById(string id)
+ {
+ return s_descriptorsById.TryGetValue(id, out var descriptor) ? descriptor : null;
+ }
+
+ private static DiagnosticDescriptor Register(DiagnosticDescriptor descriptor)
+ {
+ s_descriptorsById[descriptor.Id] = descriptor;
+ return descriptor;
+ }
+
+ ///
+ /// MAFGENWF001: Handler method must have IWorkflowContext parameter.
+ ///
+ public static readonly DiagnosticDescriptor MissingWorkflowContext = Register(new(
+ id: "MAFGENWF001",
+ title: "Handler missing IWorkflowContext parameter",
+ messageFormat: "Method '{0}' marked with [MessageHandler] must have IWorkflowContext as the second parameter",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true));
+
+ ///
+ /// MAFGENWF002: Handler method has invalid return type.
+ ///
+ public static readonly DiagnosticDescriptor InvalidReturnType = Register(new(
+ id: "MAFGENWF002",
+ title: "Handler has invalid return type",
+ messageFormat: "Method '{0}' marked with [MessageHandler] must return void, ValueTask, or ValueTask",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true));
+
+ ///
+ /// MAFGENWF003: Executor with [MessageHandler] must be partial.
+ ///
+ public static readonly DiagnosticDescriptor ClassMustBePartial = Register(new(
+ id: "MAFGENWF003",
+ title: "Executor with [MessageHandler] must be partial",
+ messageFormat: "Class '{0}' contains [MessageHandler] methods but is not declared as partial",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true));
+
+ ///
+ /// MAFGENWF004: [MessageHandler] on non-Executor class.
+ ///
+ public static readonly DiagnosticDescriptor NotAnExecutor = Register(new(
+ id: "MAFGENWF004",
+ title: "[MessageHandler] on non-Executor class",
+ messageFormat: "Method '{0}' is marked with [MessageHandler] but class '{1}' does not derive from Executor",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true));
+
+ ///
+ /// MAFGENWF005: Handler method has insufficient parameters.
+ ///
+ public static readonly DiagnosticDescriptor InsufficientParameters = Register(new(
+ id: "MAFGENWF005",
+ title: "Handler has insufficient parameters",
+ messageFormat: "Method '{0}' marked with [MessageHandler] must have at least 2 parameters (message and IWorkflowContext)",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true));
+
+ ///
+ /// MAFGENWF006: ConfigureRoutes already defined.
+ ///
+ public static readonly DiagnosticDescriptor ConfigureRoutesAlreadyDefined = Register(new(
+ id: "MAFGENWF006",
+ title: "ConfigureRoutes already defined",
+ messageFormat: "Class '{0}' already defines ConfigureRoutes; [MessageHandler] methods will be ignored",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: true));
+
+ ///
+ /// MAFGENWF007: Handler method is static.
+ ///
+ public static readonly DiagnosticDescriptor HandlerCannotBeStatic = Register(new(
+ id: "MAFGENWF007",
+ title: "Handler cannot be static",
+ messageFormat: "Method '{0}' marked with [MessageHandler] cannot be static",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true));
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets
new file mode 100644
index 0000000000..9808af77f0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets
@@ -0,0 +1,18 @@
+
+
+
+ <_ParentTargetsPath>$([MSBuild]::GetPathOfFileAbove(Directory.Build.targets, $(MSBuildThisFileDirectory)..))
+
+
+
+
+
+ <_SkipIncompatibleBuild>true
+
+
+ true
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs
new file mode 100644
index 0000000000..46a1e34d9f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Linq;
+using System.Text;
+using Microsoft.Agents.AI.Workflows.Generators.Analysis;
+using Microsoft.Agents.AI.Workflows.Generators.Generation;
+using Microsoft.Agents.AI.Workflows.Generators.Models;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Microsoft.Agents.AI.Workflows.Generators;
+
+///
+/// Roslyn incremental source generator that generates ConfigureRoutes implementations
+/// for executor classes with [MessageHandler] attributed methods.
+///
+[Generator]
+public sealed class ExecutorRouteGenerator : IIncrementalGenerator
+{
+ private const string MessageHandlerAttributeFullName = "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute";
+
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Step 1: Use ForAttributeWithMetadataName to efficiently find methods with [MessageHandler] attribute. For each method found, build a MethodAnalysisResult.
+ var methodAnalysisResults = context.SyntaxProvider
+ .ForAttributeWithMetadataName(
+ fullyQualifiedMetadataName: MessageHandlerAttributeFullName,
+ predicate: static (node, _) => node is MethodDeclarationSyntax,
+ transform: static (ctx, ct) => SemanticAnalyzer.AnalyzeMethod(ctx, ct))
+ .Where(static result => !string.IsNullOrEmpty(result.ClassKey));
+
+ // Step 2: Collect all MethodAnalysisResults, group by class, and then combine into a single AnalysisResult per class.
+ var groupedByClass = methodAnalysisResults
+ .Collect()
+ .SelectMany(static (results, _) =>
+ {
+ // Group by class key and combine into AnalysisResult
+ return results
+ .GroupBy(r => r.ClassKey)
+ .Select(group => SemanticAnalyzer.CombineMethodResults(group));
+ });
+
+ // Step 3: Generate source for valid executors using the associted AnalysisResult.
+ context.RegisterSourceOutput(
+ groupedByClass.Where(static r => r.ExecutorInfo is not null),
+ static (ctx, result) =>
+ {
+ var source = SourceBuilder.Generate(result.ExecutorInfo!);
+ var hintName = GetHintName(result.ExecutorInfo!);
+ ctx.AddSource(hintName, SourceText.From(source, Encoding.UTF8));
+ });
+
+ // Step 4: Report diagnostics
+ context.RegisterSourceOutput(
+ groupedByClass.Where(static r => !r.Diagnostics.IsEmpty),
+ static (ctx, result) =>
+ {
+ foreach (var diagnostic in result.Diagnostics)
+ {
+ ctx.ReportDiagnostic(diagnostic);
+ }
+ });
+ }
+
+ ///
+ /// Generates a hint (virtual file) name for the generated source file based on the ExecutorInfo.
+ ///
+ private static string GetHintName(ExecutorInfo info)
+ {
+ var sb = new StringBuilder();
+
+ if (!string.IsNullOrEmpty(info.Namespace))
+ {
+ sb.Append(info.Namespace)
+ .Append('.');
+ }
+
+ if (info.IsNested)
+ {
+ sb.Append(info.ContainingTypeChain)
+ .Append('.');
+ }
+
+ sb.Append(info.ClassName);
+
+ // Handle generic type parameters in hint name
+ if (!string.IsNullOrEmpty(info.GenericParameters))
+ {
+ // Replace < > with underscores for valid file name
+ sb.Append('_')
+ .Append(info.GenericParameters!.Length - 2); // Number of type params approximation
+ }
+
+ sb.Append(".g.cs");
+
+ return sb.ToString();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs
new file mode 100644
index 0000000000..5cc0a9e4ba
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs
@@ -0,0 +1,247 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.Agents.AI.Workflows.Generators.Models;
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Generation;
+
+///
+/// Generates source code for executor route configuration.
+///
+///
+/// This builder produces a partial class file that overrides ConfigureRoutes to register
+/// handlers discovered via [MessageHandler] attributes. It may also generate ConfigureSentTypes
+/// and ConfigureYieldTypes overrides when [SendsMessage] or [YieldsMessage] attributes are present.
+///
+internal static class SourceBuilder
+{
+ ///
+ /// Generates the complete source file for an executor's generated partial class.
+ ///
+ /// The analyzed executor information containing class metadata and handler details.
+ /// The generated C# source code as a string.
+ public static string Generate(ExecutorInfo info)
+ {
+ var sb = new StringBuilder();
+
+ // File header
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine();
+
+ // Using directives
+ sb.AppendLine("using System;");
+ sb.AppendLine("using System.Collections.Generic;");
+ sb.AppendLine("using Microsoft.Agents.AI.Workflows;");
+ sb.AppendLine();
+
+ // Namespace
+ if (!string.IsNullOrEmpty(info.Namespace))
+ {
+ sb.AppendLine($"namespace {info.Namespace};");
+ sb.AppendLine();
+ }
+
+ // For nested classes, we must emit partial declarations for each containing type.
+ // Example: if MyExecutor is nested in Outer.Inner, we emit:
+ // partial class Outer { partial class Inner { partial class MyExecutor { ... } } }
+ var indent = "";
+ if (info.IsNested)
+ {
+ foreach (var containingType in info.ContainingTypeChain.Split('.'))
+ {
+ sb.AppendLine($"{indent}partial class {containingType}");
+ sb.AppendLine($"{indent}{{");
+ indent += " ";
+ }
+ }
+
+ // Class declaration
+ sb.AppendLine($"{indent}partial class {info.ClassName}{info.GenericParameters}");
+ sb.AppendLine($"{indent}{{");
+
+ var memberIndent = indent + " ";
+
+ GenerateConfigureRoutes(sb, info, memberIndent);
+
+ // Only generate protocol overrides if [SendsMessage] or [YieldsMessage] attributes are present.
+ // Without these attributes, we rely on the base class defaults.
+ if (info.ShouldGenerateProtocolOverrides)
+ {
+ sb.AppendLine();
+ GenerateConfigureSentTypes(sb, info, memberIndent);
+ sb.AppendLine();
+ GenerateConfigureYieldTypes(sb, info, memberIndent);
+ }
+
+ // Close class
+ sb.AppendLine($"{indent}}}");
+
+ // Close nested classes
+ if (info.IsNested)
+ {
+ var containingTypes = info.ContainingTypeChain.Split('.');
+ for (int i = containingTypes.Length - 1; i >= 0; i--)
+ {
+ indent = new string(' ', i * 4);
+ sb.AppendLine($"{indent}}}");
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Generates the ConfigureRoutes override that registers all [MessageHandler] methods.
+ ///
+ private static void GenerateConfigureRoutes(StringBuilder sb, ExecutorInfo info, string indent)
+ {
+ sb.AppendLine($"{indent}protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)");
+ sb.AppendLine($"{indent}{{");
+
+ var bodyIndent = indent + " ";
+
+ // If a base class has its own ConfigureRoutes, chain to it first to preserve inherited handlers.
+ if (info.BaseHasConfigureRoutes)
+ {
+ sb.AppendLine($"{bodyIndent}routeBuilder = base.ConfigureRoutes(routeBuilder);");
+ sb.AppendLine();
+ }
+
+ // Generate handler registrations using fluent AddHandler calls.
+ // RouteBuilder.AddHandler registers a void handler; AddHandler registers one with a return value.
+ if (info.Handlers.Count == 1)
+ {
+ var handler = info.Handlers[0];
+ sb.AppendLine($"{bodyIndent}return routeBuilder");
+ sb.Append($"{bodyIndent} .AddHandler");
+ AppendHandlerGenericArgs(sb, handler);
+ sb.AppendLine($"(this.{handler.MethodName});");
+ }
+ else
+ {
+ // Multiple handlers: chain fluent calls, semicolon only on the last one.
+ sb.AppendLine($"{bodyIndent}return routeBuilder");
+
+ for (int i = 0; i < info.Handlers.Count; i++)
+ {
+ var handler = info.Handlers[i];
+ var isLast = i == info.Handlers.Count - 1;
+
+ sb.Append($"{bodyIndent} .AddHandler");
+ AppendHandlerGenericArgs(sb, handler);
+ sb.Append($"(this.{handler.MethodName})");
+
+ if (isLast)
+ {
+ sb.AppendLine(";");
+ }
+ else
+ {
+ sb.AppendLine();
+ }
+ }
+ }
+
+ sb.AppendLine($"{indent}}}");
+ }
+
+ ///
+ /// Appends generic type arguments for AddHandler based on whether the handler returns a value.
+ ///
+ private static void AppendHandlerGenericArgs(StringBuilder sb, HandlerInfo handler)
+ {
+ // Handlers returning ValueTask use single type arg; ValueTask uses two.
+ if (handler.HasOutput && handler.OutputTypeName != null)
+ {
+ sb.Append($"<{handler.InputTypeName}, {handler.OutputTypeName}>");
+ }
+ else
+ {
+ sb.Append($"<{handler.InputTypeName}>");
+ }
+ }
+
+ ///
+ /// Generates ConfigureSentTypes override declaring message types this executor sends via context.SendMessageAsync.
+ ///
+ ///
+ /// Types come from [SendsMessage] attributes on the class or individual handler methods.
+ /// This enables workflow protocol validation at build time.
+ ///
+ private static void GenerateConfigureSentTypes(StringBuilder sb, ExecutorInfo info, string indent)
+ {
+ sb.AppendLine($"{indent}protected override ISet ConfigureSentTypes()");
+ sb.AppendLine($"{indent}{{");
+
+ var bodyIndent = indent + " ";
+
+ sb.AppendLine($"{bodyIndent}var types = base.ConfigureSentTypes();");
+
+ foreach (var type in info.ClassSendTypes)
+ {
+ sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
+ }
+
+ foreach (var handler in info.Handlers)
+ {
+ foreach (var type in handler.SendTypes)
+ {
+ sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
+ }
+ }
+
+ sb.AppendLine($"{bodyIndent}return types;");
+ sb.AppendLine($"{indent}}}");
+ }
+
+ ///
+ /// Generates ConfigureYieldTypes override declaring message types this executor yields via context.YieldOutputAsync.
+ ///
+ ///
+ /// Types come from [YieldsMessage] attributes and handler return types (ValueTask<T>).
+ /// This enables workflow protocol validation at build time.
+ ///
+ private static void GenerateConfigureYieldTypes(StringBuilder sb, ExecutorInfo info, string indent)
+ {
+ sb.AppendLine($"{indent}protected override ISet ConfigureYieldTypes()");
+ sb.AppendLine($"{indent}{{");
+
+ var bodyIndent = indent + " ";
+
+ sb.AppendLine($"{bodyIndent}var types = base.ConfigureYieldTypes();");
+
+ // Track types to avoid emitting duplicate Add calls (the set handles runtime dedup,
+ // but cleaner generated code is easier to read).
+ var addedTypes = new HashSet();
+
+ foreach (var type in info.ClassYieldTypes)
+ {
+ if (addedTypes.Add(type))
+ {
+ sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
+ }
+ }
+
+ foreach (var handler in info.Handlers)
+ {
+ foreach (var type in handler.YieldTypes)
+ {
+ if (addedTypes.Add(type))
+ {
+ sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
+ }
+ }
+
+ // Handler return types (ValueTask) are implicitly yielded.
+ if (handler.HasOutput && handler.OutputTypeName != null && addedTypes.Add(handler.OutputTypeName))
+ {
+ sb.AppendLine($"{bodyIndent}types.Add(typeof({handler.OutputTypeName}));");
+ }
+ }
+
+ sb.AppendLine($"{bodyIndent}return types;");
+ sb.AppendLine($"{indent}}}");
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj
new file mode 100644
index 0000000000..82a1b0adef
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj
@@ -0,0 +1,65 @@
+
+
+
+
+ netstandard2.0
+
+
+
+ latest
+ enable
+
+
+ true
+
+
+ true
+ true
+
+
+ false
+ true
+
+
+ $(NoWarn);nullable
+
+ $(NoWarn);RS2008
+
+ $(NoWarn);NU5128
+
+
+
+ preview
+
+
+
+
+
+
+ Microsoft Agent Framework Workflows Source Generators
+ Provides Roslyn source generators for Microsoft Agent Framework Workflows, enabling compile-time route configuration for executors.
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs
new file mode 100644
index 0000000000..249b05e5af
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Models;
+
+///
+/// Represents the result of analyzing a class with [MessageHandler] attributed methods.
+/// Combines the executor info (if valid) with any diagnostics to report.
+/// Note: Instances of this class should not be used within the analyzers caching
+/// layer because it directly contains a collection of objects.
+///
+/// The executor information.
+/// Any diagnostics to report.
+internal sealed class AnalysisResult(ExecutorInfo? executorInfo, ImmutableArray diagnostics)
+{
+ ///
+ /// Gets the executor information.
+ ///
+ public ExecutorInfo? ExecutorInfo { get; } = executorInfo;
+
+ ///
+ /// Gets the diagnostics to report.
+ ///
+ public ImmutableArray Diagnostics { get; } = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics;
+
+ ///
+ /// Creates a successful result with executor info and no diagnostics.
+ ///
+ public static AnalysisResult Success(ExecutorInfo info) =>
+ new(info, ImmutableArray.Empty);
+
+ ///
+ /// Creates a result with only diagnostics (no valid executor info).
+ ///
+ public static AnalysisResult WithDiagnostics(ImmutableArray diagnostics) =>
+ new(null, diagnostics);
+
+ ///
+ /// Creates a result with executor info and diagnostics.
+ ///
+ public static AnalysisResult WithInfoAndDiagnostics(ExecutorInfo info, ImmutableArray diagnostics) =>
+ new(info, diagnostics);
+
+ ///
+ /// Creates an empty result (no info, no diagnostics).
+ ///
+ public static AnalysisResult Empty => new(null, ImmutableArray.Empty);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs
new file mode 100644
index 0000000000..8826c4dbcc
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Workflows.Generators.Diagnostics;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Models;
+
+///
+/// Represents diagnostic information in a form that supports value equality.
+/// Location is stored as file path + span, which can be used to recreate a Location.
+///
+internal sealed record DiagnosticInfo(
+ string DiagnosticId,
+ string FilePath,
+ TextSpan Span,
+ LinePositionSpan LineSpan,
+ ImmutableEquatableArray MessageArgs)
+{
+ ///
+ /// Creates a DiagnosticInfo from a location and message arguments.
+ ///
+ public static DiagnosticInfo Create(string diagnosticId, Location location, params string[] messageArgs)
+ {
+ var lineSpan = location.GetLineSpan();
+ return new DiagnosticInfo(
+ diagnosticId,
+ lineSpan.Path ?? string.Empty,
+ location.SourceSpan,
+ lineSpan.Span,
+ new ImmutableEquatableArray(System.Collections.Immutable.ImmutableArray.Create(messageArgs)));
+ }
+
+ ///
+ /// Converts this info back to a Roslyn Diagnostic.
+ ///
+ public Diagnostic ToRoslynDiagnostic(SyntaxTree? syntaxTree)
+ {
+ var descriptor = DiagnosticDescriptors.GetById(this.DiagnosticId);
+ if (descriptor is null)
+ {
+ // Fallback - should not happen
+ var fallbackArgs = new object[this.MessageArgs.Count];
+ for (int i = 0; i < this.MessageArgs.Count; i++)
+ {
+ fallbackArgs[i] = this.MessageArgs[i];
+ }
+
+ return Diagnostic.Create(
+ DiagnosticDescriptors.InsufficientParameters,
+ Location.None,
+ fallbackArgs);
+ }
+
+ Location location;
+ if (syntaxTree is not null)
+ {
+ location = Location.Create(syntaxTree, this.Span);
+ }
+ else if (!string.IsNullOrEmpty(this.FilePath))
+ {
+ location = Location.Create(this.FilePath, this.Span, this.LineSpan);
+ }
+ else
+ {
+ location = Location.None;
+ }
+
+ var args = new object[this.MessageArgs.Count];
+ for (int i = 0; i < this.MessageArgs.Count; i++)
+ {
+ args[i] = this.MessageArgs[i];
+ }
+
+ return Diagnostic.Create(descriptor, location, args);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticLocationInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticLocationInfo.cs
new file mode 100644
index 0000000000..07b6fbfa7b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticLocationInfo.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Models;
+
+///
+/// Represents location information in a form that supports value equality making it friendly for source gen caching.
+///
+internal sealed record DiagnosticLocationInfo(
+ string FilePath,
+ TextSpan Span,
+ LinePositionSpan LineSpan)
+{
+ ///
+ /// Creates a DiagnosticLocationInfo from a Roslyn Location.
+ ///
+ public static DiagnosticLocationInfo? FromLocation(Location? location)
+ {
+ if (location is null || location == Location.None)
+ {
+ return null;
+ }
+
+ var lineSpan = location.GetLineSpan();
+ return new DiagnosticLocationInfo(
+ lineSpan.Path ?? string.Empty,
+ location.SourceSpan,
+ lineSpan.Span);
+ }
+
+ ///
+ /// Converts back to a Roslyn Location.
+ ///
+ public Location ToRoslynLocation()
+ {
+ if (string.IsNullOrEmpty(this.FilePath))
+ {
+ return Location.None;
+ }
+
+ return Location.Create(this.FilePath, this.Span, this.LineSpan);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs
new file mode 100644
index 0000000000..bbdf5731e7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Models;
+
+///
+/// Contains all information needed to generate code for an executor class.
+/// Uses record for automatic value equality, which is required for incremental generator caching.
+///
+/// The namespace of the executor class.
+/// The name of the executor class.
+/// The generic type parameters of the class (e.g., "<T, U>"), or null if not generic.
+/// Whether the class is nested inside another class.
+/// The chain of containing types for nested classes (e.g., "OuterClass.InnerClass"). Empty string if not nested.
+/// Whether the base class has a ConfigureRoutes method that should be called.
+/// The list of handler methods to register.
+/// The types declared via class-level [SendsMessage] attributes.
+/// The types declared via class-level [YieldsMessage] attributes.
+internal sealed record ExecutorInfo(
+ string? Namespace,
+ string ClassName,
+ string? GenericParameters,
+ bool IsNested,
+ string ContainingTypeChain,
+ bool BaseHasConfigureRoutes,
+ ImmutableEquatableArray Handlers,
+ ImmutableEquatableArray ClassSendTypes,
+ ImmutableEquatableArray ClassYieldTypes)
+{
+ ///
+ /// Gets whether any protocol type overrides should be generated.
+ ///
+ public bool ShouldGenerateProtocolOverrides =>
+ !this.ClassSendTypes.IsEmpty ||
+ !this.ClassYieldTypes.IsEmpty ||
+ this.HasHandlerWithSendTypes ||
+ this.HasHandlerWithYieldTypes;
+
+ ///
+ /// Gets whether any handler has explicit Send types.
+ ///
+ public bool HasHandlerWithSendTypes
+ {
+ get
+ {
+ foreach (var handler in this.Handlers)
+ {
+ if (!handler.SendTypes.IsEmpty)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ ///
+ /// Gets whether any handler has explicit Yield types or output types.
+ ///
+ public bool HasHandlerWithYieldTypes
+ {
+ get
+ {
+ foreach (var handler in this.Handlers)
+ {
+ if (!handler.YieldTypes.IsEmpty)
+ {
+ return true;
+ }
+
+ if (handler.HasOutput)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs
new file mode 100644
index 0000000000..f5d8b5642f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Models;
+
+///
+/// Represents the signature kind of a message handler method.
+///
+internal enum HandlerSignatureKind
+{
+ /// Void synchronous: void Handler(T, IWorkflowContext) or void Handler(T, IWorkflowContext, CT)
+ VoidSync,
+
+ /// Void asynchronous: ValueTask Handler(T, IWorkflowContext[, CT])
+ VoidAsync,
+
+ /// Result synchronous: TResult Handler(T, IWorkflowContext[, CT])
+ ResultSync,
+
+ /// Result asynchronous: ValueTask<TResult> Handler(T, IWorkflowContext[, CT])
+ ResultAsync
+}
+
+///
+/// Contains information about a single message handler method.
+/// Uses record for automatic value equality, which is required for incremental generator caching.
+///
+/// The name of the handler method.
+/// The fully-qualified type name of the input message type.
+/// The fully-qualified type name of the output type, or null if the handler is void.
+/// The signature kind of the handler.
+/// Whether the handler method has a CancellationToken parameter.
+/// The types explicitly declared in the Yield property of [MessageHandler].
+/// The types explicitly declared in the Send property of [MessageHandler].
+internal sealed record HandlerInfo(
+ string MethodName,
+ string InputTypeName,
+ string? OutputTypeName,
+ HandlerSignatureKind SignatureKind,
+ bool HasCancellationToken,
+ ImmutableEquatableArray YieldTypes,
+ ImmutableEquatableArray SendTypes)
+{
+ ///
+ /// Gets whether this handler returns a value (either sync or async).
+ ///
+ public bool HasOutput => this.SignatureKind == HandlerSignatureKind.ResultSync || this.SignatureKind == HandlerSignatureKind.ResultAsync;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ImmutableEquatableArray.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ImmutableEquatableArray.cs
new file mode 100644
index 0000000000..f39a36c85e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ImmutableEquatableArray.cs
@@ -0,0 +1,125 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Models;
+
+///
+/// Provides an immutable list implementation which implements sequence equality.
+/// Copied from: https://github.com/dotnet/runtime/blob/main/src/libraries/Common/src/SourceGenerators/ImmutableEquatableArray.cs
+///
+internal sealed class ImmutableEquatableArray : IEquatable>, IReadOnlyList
+ where T : IEquatable
+{
+ ///
+ /// Creates a new empty .
+ ///
+ public static ImmutableEquatableArray Empty { get; } = new ImmutableEquatableArray(Array.Empty());
+
+ private readonly T[] _values;
+
+ ///
+ /// Gets the element at the specified index.
+ ///
+ ///
+ ///
+ public T this[int index] => this._values[index];
+
+ ///
+ /// Gets the number of elements contained in the collection.
+ ///
+ public int Count => this._values.Length;
+
+ ///
+ /// Gets whether the array is empty.
+ ///
+ public bool IsEmpty => this._values.Length == 0;
+
+ ///
+ /// Initializes a new instance of the ImmutableEquatableArray{T} class that contains the elements from the specified
+ /// collection.
+ ///
+ /// The elements from the provided collection are copied into the immutable array. Subsequent
+ /// changes to the original collection do not affect the contents of this array.
+ /// The collection of elements to initialize the array with. Cannot be null.
+ public ImmutableEquatableArray(IEnumerable values) => this._values = values.ToArray();
+
+ ///
+ public bool Equals(ImmutableEquatableArray? other) => other != null && ((ReadOnlySpan)this._values).SequenceEqual(other._values);
+
+ ///
+ public override bool Equals(object? obj)
+ => obj is ImmutableEquatableArray other && this.Equals(other);
+
+ ///
+ public override int GetHashCode()
+ {
+ int hash = 0;
+ foreach (T value in this._values)
+ {
+ hash = HashHelpers.Combine(hash, value is null ? 0 : value.GetHashCode());
+ }
+
+ return hash;
+ }
+
+ ///
+ public Enumerator GetEnumerator() => new(this._values);
+
+ IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this._values).GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => this._values.GetEnumerator();
+
+ ///
+ public struct Enumerator
+ {
+ private readonly T[] _values;
+ private int _index;
+
+ internal Enumerator(T[] values)
+ {
+ this._values = values;
+ this._index = -1;
+ }
+
+ ///
+ public bool MoveNext()
+ {
+ int newIndex = this._index + 1;
+
+ if ((uint)newIndex < (uint)this._values.Length)
+ {
+ this._index = newIndex;
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// The element at the current position of the enumerator.
+ ///
+ public readonly T Current => this._values[this._index];
+ }
+}
+
+internal static class ImmutableEquatableArray
+{
+ public static ImmutableEquatableArray ToImmutableEquatableArray(this IEnumerable values) where T : IEquatable
+ => new(values);
+}
+
+// Copied from https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Numerics/Hashing/HashHelpers.cs#L6
+internal static class HashHelpers
+{
+ public static int Combine(int h1, int h2)
+ {
+ // RyuJIT optimizes this to use the ROL instruction
+ // Related GitHub pull request: https://github.com/dotnet/coreclr/pull/1830
+ uint rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27);
+ return ((int)rol5 + h1) ^ h2;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs
new file mode 100644
index 0000000000..725b53e8aa
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Models;
+
+///
+/// Represents the result of analyzing a single method with [MessageHandler].
+/// Contains both the method's handler info and class context for grouping.
+/// Uses value-equatable types to support incremental generator caching.
+///
+///
+/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureRoutes)
+/// is extracted here but validated once per class in CombineMethodResults to avoid
+/// redundant validation work when a class has multiple handlers.
+///
+internal sealed record MethodAnalysisResult(
+ // Class identification for grouping
+ string ClassKey,
+
+ // Class-level info (extracted once per method, will be same for all methods in class)
+ string? Namespace,
+ string ClassName,
+ string? GenericParameters,
+ bool IsNested,
+ string ContainingTypeChain,
+ bool BaseHasConfigureRoutes,
+ ImmutableEquatableArray ClassSendTypes,
+ ImmutableEquatableArray ClassYieldTypes,
+
+ // Class-level facts (used for validation in CombineMethodResults)
+ bool IsPartialClass,
+ bool DerivesFromExecutor,
+ bool HasManualConfigureRoutes,
+
+ // Class location for diagnostics (value-equatable)
+ DiagnosticLocationInfo? ClassLocation,
+
+ // Method-level info (null if method validation failed)
+ HandlerInfo? Handler,
+
+ // Method-level diagnostics only (class-level diagnostics created in CombineMethodResults)
+ ImmutableEquatableArray Diagnostics);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/SkipIncompatibleBuild.targets b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/SkipIncompatibleBuild.targets
new file mode 100644
index 0000000000..bd5d7b835f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/SkipIncompatibleBuild.targets
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs
new file mode 100644
index 0000000000..7f40b3573d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Workflows;
+
+///
+/// Marks a method as a message handler for source-generated route configuration.
+/// The method signature determines the input type and optional output type.
+///
+///
+///
+/// Methods marked with this attribute must have a signature matching one of the following patterns:
+///
+/// - void Handler(TMessage, IWorkflowContext)
+/// - void Handler(TMessage, IWorkflowContext, CancellationToken)
+/// - ValueTask Handler(TMessage, IWorkflowContext)
+/// - ValueTask Handler(TMessage, IWorkflowContext, CancellationToken)
+/// - TResult Handler(TMessage, IWorkflowContext)
+/// - TResult Handler(TMessage, IWorkflowContext, CancellationToken)
+/// - ValueTask<TResult> Handler(TMessage, IWorkflowContext)
+/// - ValueTask<TResult> Handler(TMessage, IWorkflowContext, CancellationToken)
+///
+///
+///
+/// The containing class must be partial and derive from .
+///
+///
+///
+///
+/// public partial class MyExecutor : Executor
+/// {
+/// [MessageHandler]
+/// private async ValueTask<MyResponse> HandleQueryAsync(
+/// MyQuery query, IWorkflowContext ctx, CancellationToken ct)
+/// {
+/// return new MyResponse();
+/// }
+///
+/// [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])]
+/// private void HandleStream(StreamRequest req, IWorkflowContext ctx)
+/// {
+/// // Handler with explicit yield and send types
+/// }
+/// }
+///
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
+public sealed class MessageHandlerAttribute : Attribute
+{
+ ///
+ /// Gets or sets the types that this handler may yield as workflow outputs.
+ ///
+ ///
+ /// If not specified, the return type (if any) is used as the default yield type.
+ /// Use this property to explicitly declare additional output types or to override
+ /// the default inference from the return type.
+ ///
+ public Type[]? Yield { get; set; }
+
+ ///
+ /// Gets or sets the types that this handler may send as messages to other executors.
+ ///
+ ///
+ /// Use this property to declare the message types that this handler may send
+ /// via during its execution.
+ /// This information is used for protocol validation and documentation.
+ ///
+ public Type[]? Send { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs
new file mode 100644
index 0000000000..3b5620fc37
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Workflows;
+
+///
+/// Declares that an executor may send messages of the specified type.
+///
+///
+///
+/// Apply this attribute to an class to declare the types of messages
+/// it may send via . This information is used
+/// for protocol validation and documentation.
+///
+///
+/// This attribute can be applied multiple times to declare multiple message types.
+/// It is inherited by derived classes, allowing base executors to declare common message types.
+///
+///
+///
+///
+/// [SendsMessage(typeof(PollToken))]
+/// [SendsMessage(typeof(StatusUpdate))]
+/// public partial class MyExecutor : Executor
+/// {
+/// // ...
+/// }
+///
+///
+[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
+public sealed class SendsMessageAttribute : Attribute
+{
+ ///
+ /// Gets the type of message that the executor may send.
+ ///
+ public Type Type { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The type of message that the executor may send.
+ /// is .
+ public SendsMessageAttribute(Type type)
+ {
+ this.Type = Throw.IfNull(type);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs
new file mode 100644
index 0000000000..82ca9106b7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Workflows;
+
+///
+/// Declares that an executor may yield messages of the specified type as workflow outputs.
+///
+///
+///
+/// Apply this attribute to an class to declare the types of messages
+/// it may yield via . This information is used
+/// for protocol validation and documentation.
+///
+///
+/// This attribute can be applied multiple times to declare multiple output types.
+/// It is inherited by derived classes, allowing base executors to declare common output types.
+///
+///
+///
+///
+/// [YieldsMessage(typeof(FinalResult))]
+/// [YieldsMessage(typeof(StreamChunk))]
+/// public partial class MyExecutor : Executor
+/// {
+/// // ...
+/// }
+///
+///
+[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
+public sealed class YieldsMessageAttribute : Attribute
+{
+ ///
+ /// Gets the type of message that the executor may yield.
+ ///
+ public Type Type { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The type of message that the executor may yield.
+ /// is .
+ public YieldsMessageAttribute(Type type)
+ {
+ this.Type = Throw.IfNull(type);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj
index 7379d9a6ac..3ecf31e132 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj
@@ -25,6 +25,15 @@
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs
new file mode 100644
index 0000000000..e77eec46b5
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs
@@ -0,0 +1,916 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using FluentAssertions;
+
+namespace Microsoft.Agents.AI.Workflows.Generators.UnitTests;
+
+///
+/// Tests for the ExecutorRouteGenerator source generator.
+///
+public class ExecutorRouteGeneratorTests
+{
+ #region Single Handler Tests
+
+ [Fact]
+ public void SingleHandler_VoidReturn_GeneratesCorrectRoute()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context)
+ {
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain("protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)");
+ generated.Should().Contain(".AddHandler(this.HandleMessage)");
+ }
+
+ [Fact]
+ public void SingleHandler_ValueTaskReturn_GeneratesCorrectRoute()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private ValueTask HandleMessageAsync(string message, IWorkflowContext context)
+ {
+ return default;
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain(".AddHandler(this.HandleMessageAsync)");
+ }
+
+ [Fact]
+ public void SingleHandler_WithOutput_GeneratesCorrectRoute()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private ValueTask HandleMessageAsync(string message, IWorkflowContext context)
+ {
+ return new ValueTask(42);
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain(".AddHandler(this.HandleMessageAsync)");
+ }
+
+ [Fact]
+ public void SingleHandler_WithCancellationToken_GeneratesCorrectRoute()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private ValueTask HandleMessageAsync(string message, IWorkflowContext context, CancellationToken ct)
+ {
+ return default;
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain(".AddHandler(this.HandleMessageAsync)");
+ }
+
+ #endregion
+
+ #region Multiple Handler Tests
+
+ [Fact]
+ public void MultipleHandlers_GeneratesAllRoutes()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleString(string message, IWorkflowContext context) { }
+
+ [MessageHandler]
+ private void HandleInt(int message, IWorkflowContext context) { }
+
+ [MessageHandler]
+ private ValueTask HandleDoubleAsync(double message, IWorkflowContext context)
+ {
+ return new ValueTask("result");
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain(".AddHandler(this.HandleString)");
+ generated.Should().Contain(".AddHandler(this.HandleInt)");
+ generated.Should().Contain(".AddHandler(this.HandleDoubleAsync)");
+ }
+
+ #endregion
+
+ #region Yield and Send Type Tests
+
+ [Fact]
+ public void Handler_WithYieldTypes_GeneratesConfigureYieldTypes()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class OutputMessage { }
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler(Yield = new[] { typeof(OutputMessage) })]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain("protected override ISet ConfigureYieldTypes()");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.OutputMessage))");
+ }
+
+ [Fact]
+ public void Handler_WithSendTypes_GeneratesConfigureSentTypes()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class SendMessage { }
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler(Send = new[] { typeof(SendMessage) })]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain("protected override ISet ConfigureSentTypes()");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.SendMessage))");
+ }
+
+ [Fact]
+ public void ClassLevel_SendsMessageAttribute_GeneratesConfigureSentTypes()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class BroadcastMessage { }
+
+ [SendsMessage(typeof(BroadcastMessage))]
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain("protected override ISet ConfigureSentTypes()");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.BroadcastMessage))");
+ }
+
+ [Fact]
+ public void ClassLevel_YieldsMessageAttribute_GeneratesConfigureYieldTypes()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class YieldedMessage { }
+
+ [YieldsMessage(typeof(YieldedMessage))]
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain("protected override ISet ConfigureYieldTypes()");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.YieldedMessage))");
+ }
+
+ #endregion
+
+ #region Nested Class Tests
+
+ [Fact]
+ public void NestedClass_SingleLevel_GeneratesCorrectPartialHierarchy()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class OuterClass
+ {
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+ result.RunResult.Diagnostics.Should().BeEmpty();
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+
+ // Verify partial declarations are present
+ generated.Should().Contain("partial class OuterClass");
+ generated.Should().Contain("partial class TestExecutor");
+
+ // Verify proper nesting structure with braces
+ // The outer class should open before the inner class
+ var outerIndex = generated.IndexOf("partial class OuterClass", StringComparison.Ordinal);
+ var innerIndex = generated.IndexOf("partial class TestExecutor", StringComparison.Ordinal);
+ outerIndex.Should().BeLessThan(innerIndex, "outer class should appear before inner class");
+
+ // Verify handler registration is present
+ generated.Should().Contain(".AddHandler(this.HandleMessage)");
+ }
+
+ [Fact]
+ public void NestedClass_TwoLevels_GeneratesCorrectPartialHierarchy()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class Outer
+ {
+ public partial class Inner
+ {
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+ result.RunResult.Diagnostics.Should().BeEmpty();
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+
+ // Verify all three partial declarations are present in correct order
+ generated.Should().Contain("partial class Outer");
+ generated.Should().Contain("partial class Inner");
+ generated.Should().Contain("partial class TestExecutor");
+
+ var outerIndex = generated.IndexOf("partial class Outer", StringComparison.Ordinal);
+ var innerIndex = generated.IndexOf("partial class Inner", StringComparison.Ordinal);
+ var executorIndex = generated.IndexOf("partial class TestExecutor", StringComparison.Ordinal);
+
+ outerIndex.Should().BeLessThan(innerIndex, "Outer should appear before Inner");
+ innerIndex.Should().BeLessThan(executorIndex, "Inner should appear before TestExecutor");
+
+ // Verify handler registration
+ generated.Should().Contain(".AddHandler(this.HandleMessage)");
+ }
+
+ [Fact]
+ public void NestedClass_ThreeLevels_GeneratesCorrectPartialHierarchy()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class Level1
+ {
+ public partial class Level2
+ {
+ public partial class Level3
+ {
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(int message, IWorkflowContext context) { }
+ }
+ }
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+ result.RunResult.Diagnostics.Should().BeEmpty();
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+
+ // All four partial class declarations should be present
+ generated.Should().Contain("partial class Level1");
+ generated.Should().Contain("partial class Level2");
+ generated.Should().Contain("partial class Level3");
+ generated.Should().Contain("partial class TestExecutor");
+
+ // Verify correct ordering
+ var level1Index = generated.IndexOf("partial class Level1", StringComparison.Ordinal);
+ var level2Index = generated.IndexOf("partial class Level2", StringComparison.Ordinal);
+ var level3Index = generated.IndexOf("partial class Level3", StringComparison.Ordinal);
+ var executorIndex = generated.IndexOf("partial class TestExecutor", StringComparison.Ordinal);
+
+ level1Index.Should().BeLessThan(level2Index);
+ level2Index.Should().BeLessThan(level3Index);
+ level3Index.Should().BeLessThan(executorIndex);
+
+ // Verify handler registration
+ generated.Should().Contain(".AddHandler(this.HandleMessage)");
+ }
+
+ [Fact]
+ public void NestedClass_WithoutNamespace_GeneratesCorrectly()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ public partial class OuterClass
+ {
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+ result.RunResult.Diagnostics.Should().BeEmpty();
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+
+ // Should not contain namespace declaration
+ generated.Should().NotContain("namespace ");
+
+ // Should still have proper partial hierarchy
+ generated.Should().Contain("partial class OuterClass");
+ generated.Should().Contain("partial class TestExecutor");
+ generated.Should().Contain(".AddHandler(this.HandleMessage)");
+ }
+
+ [Fact]
+ public void NestedClass_GeneratedCodeCompiles()
+ {
+ // This test verifies that the generated code actually compiles by checking
+ // for compilation errors in the output (beyond our generator diagnostics)
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class Outer
+ {
+ public partial class Inner
+ {
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private ValueTask HandleMessage(int message, IWorkflowContext context)
+ {
+ return new ValueTask("result");
+ }
+ }
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ // No generator diagnostics
+ result.RunResult.Diagnostics.Should().BeEmpty();
+
+ // Check that the combined compilation (source + generated) has no errors
+ var compilationDiagnostics = result.OutputCompilation.GetDiagnostics()
+ .Where(d => d.Severity == CodeAnalysis.DiagnosticSeverity.Error)
+ .ToList();
+
+ compilationDiagnostics.Should().BeEmpty(
+ "generated code for nested classes should compile without errors");
+ }
+
+ [Fact]
+ public void NestedClass_BraceBalancing_IsCorrect()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class Outer
+ {
+ public partial class Inner
+ {
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+
+ // Count braces - they should be balanced
+ var openBraces = generated.Count(c => c == '{');
+ var closeBraces = generated.Count(c => c == '}');
+
+ openBraces.Should().Be(closeBraces, "generated code should have balanced braces");
+
+ // For Outer.Inner.TestExecutor, we expect:
+ // - 1 for Outer class
+ // - 1 for Inner class
+ // - 1 for TestExecutor class
+ // - 1 for ConfigureRoutes method
+ // = 4 pairs minimum
+ openBraces.Should().BeGreaterThanOrEqualTo(4, "should have braces for all nested classes and method");
+ }
+
+ #endregion
+
+ #region Multi-File Partial Class Tests
+
+ [Fact]
+ public void PartialClass_SplitAcrossFiles_GeneratesCorrectly()
+ {
+ // File 1: The "main" partial with constructor and base class
+ var file1 = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ // Some other business logic could be here
+ public void DoSomething() { }
+ }
+ """;
+
+ // File 2: Another partial with [MessageHandler] methods
+ var file2 = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor
+ {
+ [MessageHandler]
+ private void HandleString(string message, IWorkflowContext context) { }
+
+ [MessageHandler]
+ private ValueTask HandleIntAsync(int message, IWorkflowContext context)
+ {
+ return default;
+ }
+ }
+ """;
+
+ // Run generator with both files
+ var result = GeneratorTestHelper.RunGenerator(file1, file2);
+
+ // Should generate one file for the executor
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+ result.RunResult.Diagnostics.Should().BeEmpty();
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+
+ // Should have both handlers registered
+ generated.Should().Contain(".AddHandler(this.HandleString)");
+ generated.Should().Contain(".AddHandler(this.HandleIntAsync)");
+
+ // Verify the generated code compiles with all three partials combined
+ var compilationErrors = result.OutputCompilation.GetDiagnostics()
+ .Where(d => d.Severity == CodeAnalysis.DiagnosticSeverity.Error)
+ .ToList();
+
+ compilationErrors.Should().BeEmpty(
+ "generated partial should compile correctly with the other partial files");
+ }
+
+ [Fact]
+ public void PartialClass_HandlersInBothFiles_GeneratesAllHandlers()
+ {
+ // File 1: Partial with one handler
+ var file1 = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleFromFile1(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ // File 2: Another partial with another handler
+ var file2 = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor
+ {
+ [MessageHandler]
+ private void HandleFromFile2(int message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(file1, file2);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+ result.RunResult.Diagnostics.Should().BeEmpty();
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+
+ // Both handlers from different files should be registered
+ generated.Should().Contain(".AddHandler(this.HandleFromFile1)");
+ generated.Should().Contain(".AddHandler(this.HandleFromFile2)");
+ }
+
+ #endregion
+
+ #region Diagnostic Tests
+
+ [Fact]
+ public void NonPartialClass_ProducesDiagnosticAndNoSource()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ // Should produce MAFGENWF003 diagnostic
+ result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF003");
+
+ // Should NOT generate any source (to avoid CS0260)
+ result.RunResult.GeneratedTrees.Should().BeEmpty(
+ "non-partial classes should not have source generated to avoid CS0260 compiler error");
+ }
+
+ [Fact]
+ public void NonExecutorClass_ProducesDiagnostic()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class NotAnExecutor
+ {
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF004");
+ }
+
+ [Fact]
+ public void StaticHandler_ProducesDiagnostic()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private static void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF007");
+ }
+
+ [Fact]
+ public void MissingWorkflowContext_ProducesDiagnostic()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF005");
+ }
+
+ [Fact]
+ public void WrongSecondParameter_ProducesDiagnostic()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ [MessageHandler]
+ private void HandleMessage(string message, string notContext) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF001");
+ }
+
+ #endregion
+
+ #region No Generation Tests
+
+ [Fact]
+ public void ClassWithManualConfigureRoutes_DoesNotGenerate()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder;
+ }
+
+ [MessageHandler]
+ private void HandleMessage(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ // Should produce diagnostic but not generate code
+ result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF006");
+ result.RunResult.GeneratedTrees.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ClassWithNoMessageHandlers_DoesNotGenerate()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ private void SomeOtherMethod(string message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().BeEmpty();
+ }
+
+ #endregion
+
+ #region Generic Executor Tests
+
+ [Fact]
+ public void GenericExecutor_GeneratesCorrectly()
+ {
+ var source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public partial class GenericExecutor : Executor where T : class
+ {
+ public GenericExecutor() : base("generic") { }
+
+ [MessageHandler]
+ private void HandleMessage(T message, IWorkflowContext context) { }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain("partial class GenericExecutor");
+ generated.Should().Contain(".AddHandler(this.HandleMessage)");
+ }
+
+ #endregion
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs
new file mode 100644
index 0000000000..f631fc8551
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs
@@ -0,0 +1,145 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace Microsoft.Agents.AI.Workflows.Generators.UnitTests;
+
+///
+/// Helper class for testing the ExecutorRouteGenerator.
+///
+public static class GeneratorTestHelper
+{
+ ///
+ /// Runs the ExecutorRouteGenerator on the provided source code and returns the result.
+ ///
+ public static GeneratorRunResult RunGenerator(string source) => RunGenerator([source]);
+
+ ///
+ /// Runs the ExecutorRouteGenerator on multiple source files and returns the result.
+ /// Use this to test scenarios with partial classes split across files.
+ ///
+ public static GeneratorRunResult RunGenerator(params string[] sources)
+ {
+ var syntaxTrees = sources.Select(s => CSharpSyntaxTree.ParseText(s)).ToArray();
+
+ var references = GetMetadataReferences();
+
+ var compilation = CSharpCompilation.Create(
+ assemblyName: "TestAssembly",
+ syntaxTrees: syntaxTrees,
+ references: references,
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ var generator = new ExecutorRouteGenerator();
+
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics);
+
+ var runResult = driver.GetRunResult();
+
+ return new GeneratorRunResult(
+ runResult,
+ outputCompilation,
+ diagnostics);
+ }
+
+ ///
+ /// Runs the generator and asserts that it produces exactly one generated file with the expected content.
+ ///
+ public static void AssertGeneratesSource(string source, string expectedGeneratedSource)
+ {
+ var result = RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1, "expected exactly one generated file");
+
+ var generatedSource = result.RunResult.GeneratedTrees[0].ToString();
+ generatedSource.Should().Contain(expectedGeneratedSource);
+ }
+
+ ///
+ /// Runs the generator and asserts that no source is generated.
+ ///
+ public static void AssertGeneratesNoSource(string source)
+ {
+ var result = RunGenerator(source);
+ result.RunResult.GeneratedTrees.Should().BeEmpty("expected no generated files");
+ }
+
+ ///
+ /// Runs the generator and asserts that a specific diagnostic is produced.
+ ///
+ public static void AssertProducesDiagnostic(string source, string diagnosticId)
+ {
+ var result = RunGenerator(source);
+
+ var generatorDiagnostics = result.RunResult.Diagnostics;
+ generatorDiagnostics.Should().Contain(d => d.Id == diagnosticId,
+ $"expected diagnostic {diagnosticId} to be produced");
+ }
+
+ ///
+ /// Runs the generator and asserts that compilation succeeds with no errors.
+ ///
+ public static void AssertCompilationSucceeds(string source)
+ {
+ var result = RunGenerator(source);
+
+ var errors = result.OutputCompilation.GetDiagnostics()
+ .Where(d => d.Severity == DiagnosticSeverity.Error)
+ .ToList();
+
+ errors.Should().BeEmpty("compilation should succeed without errors");
+ }
+
+ private static ImmutableArray GetMetadataReferences()
+ {
+ var assemblies = new[]
+ {
+ typeof(object).Assembly, // System.Runtime
+ typeof(Attribute).Assembly, // System.Runtime
+ typeof(ValueTask).Assembly, // System.Threading.Tasks.Extensions
+ typeof(CancellationToken).Assembly, // System.Threading
+ typeof(ISet<>).Assembly, // System.Collections
+ typeof(Executor).Assembly, // Microsoft.Agents.AI.Workflows
+ };
+
+ var references = new List();
+
+ foreach (var assembly in assemblies)
+ {
+ references.Add(MetadataReference.CreateFromFile(assembly.Location));
+ }
+
+ // Add netstandard reference
+ var netstandardAssembly = Assembly.Load("netstandard, Version=2.0.0.0");
+ references.Add(MetadataReference.CreateFromFile(netstandardAssembly.Location));
+
+ // Add System.Runtime reference for core types
+ var runtimeAssemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
+ var systemRuntimePath = Path.Combine(runtimeAssemblyPath, "System.Runtime.dll");
+ if (File.Exists(systemRuntimePath))
+ {
+ references.Add(MetadataReference.CreateFromFile(systemRuntimePath));
+ }
+
+ return [.. references.Distinct()];
+ }
+}
+
+///
+/// Contains the results of running the generator.
+///
+public record GeneratorRunResult(
+ GeneratorDriverRunResult RunResult,
+ Compilation OutputCompilation,
+ ImmutableArray Diagnostics);
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj
new file mode 100644
index 0000000000..81b91bf17d
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+ net10.0
+
+ $(NoWarn);RCS1118
+
+
+
+
+
+
+
+
+
+
+
+
+
+