diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index c7e53ea256..6cb92c9604 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..2f7fda9057
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs
@@ -0,0 +1,693 @@
+// 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 YieldsOutputAttributeName = "Microsoft.Agents.AI.Workflows.YieldsOutputAttribute";
+
+ ///
+ /// 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 AnalyzeHandlerMethod(
+ GeneratorAttributeSyntaxContext context,
+ CancellationToken cancellationToken)
+ {
+ // The target should be a method
+ if (context.TargetSymbol is not IMethodSymbol methodSymbol)
+ {
+ return MethodAnalysisResult.Empty;
+ }
+
+ // Get the containing class
+ INamedTypeSymbol? classSymbol = methodSymbol.ContainingType;
+ if (classSymbol is null)
+ {
+ return MethodAnalysisResult.Empty;
+ }
+
+ // Get the method syntax for location info
+ MethodDeclarationSyntax? methodSyntax = context.TargetNode as MethodDeclarationSyntax;
+
+ // Extract class-level info (raw facts, no validation here)
+ string classKey = GetClassKey(classSymbol);
+ bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
+ bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
+ bool hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol);
+
+ // Extract class metadata
+ string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
+ ? null
+ : classSymbol.ContainingNamespace?.ToDisplayString();
+ string className = classSymbol.Name;
+ string? genericParameters = GetGenericParameters(classSymbol);
+ bool isNested = classSymbol.ContainingType != null;
+ string containingTypeChain = GetContainingTypeChain(classSymbol);
+ bool baseHasConfigureRoutes = BaseHasConfigureRoutes(classSymbol);
+ ImmutableEquatableArray classSendTypes = GetClassLevelTypes(classSymbol, SendsMessageAttributeName);
+ ImmutableEquatableArray classYieldTypes = GetClassLevelTypes(classSymbol, YieldsOutputAttributeName);
+
+ // Get class location for class-level diagnostics
+ DiagnosticLocationInfo? 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 CombineHandlerMethodResults(IEnumerable methodResults)
+ {
+ List methods = methodResults.ToList();
+ if (methods.Count == 0)
+ {
+ return AnalysisResult.Empty;
+ }
+
+ // All methods should have same class info - take from first
+ MethodAnalysisResult first = methods[0];
+ Location 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
+ ImmutableArray handlers = methods
+ .Where(m => m.Handler is not null)
+ .Select(m => m.Handler!)
+ .ToImmutableArray();
+
+ if (handlers.Length == 0)
+ {
+ return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
+ }
+
+ ExecutorInfo executorInfo = new(
+ 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);
+ }
+
+ ///
+ /// Analyzes a class with [SendsMessage] or [YieldsOutput] attribute found by ForAttributeWithMetadataName.
+ /// Returns ClassProtocolInfo entries for each attribute instance (handles multiple attributes of same type).
+ ///
+ /// The generator attribute syntax context.
+ /// Whether this is a Send or Yield attribute.
+ /// Cancellation token.
+ /// The analysis results for the class protocol attributes.
+ public static ImmutableArray AnalyzeClassProtocolAttribute(
+ GeneratorAttributeSyntaxContext context,
+ ProtocolAttributeKind attributeKind,
+ CancellationToken cancellationToken)
+ {
+ // The target should be a class
+ if (context.TargetSymbol is not INamedTypeSymbol classSymbol)
+ {
+ return ImmutableArray.Empty;
+ }
+
+ // Extract class-level info (same for all attributes)
+ string classKey = GetClassKey(classSymbol);
+ bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
+ bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
+ bool hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol);
+
+ string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
+ ? null
+ : classSymbol.ContainingNamespace?.ToDisplayString();
+ string className = classSymbol.Name;
+ string? genericParameters = GetGenericParameters(classSymbol);
+ bool isNested = classSymbol.ContainingType != null;
+ string containingTypeChain = GetContainingTypeChain(classSymbol);
+ DiagnosticLocationInfo? classLocation = GetClassLocation(classSymbol, cancellationToken);
+
+ // Extract a ClassProtocolInfo for each attribute instance
+ ImmutableArray.Builder results = ImmutableArray.CreateBuilder();
+
+ foreach (AttributeData attr in context.Attributes)
+ {
+ if (attr.ConstructorArguments.Length > 0 &&
+ attr.ConstructorArguments[0].Value is INamedTypeSymbol typeSymbol)
+ {
+ string typeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ results.Add(new ClassProtocolInfo(
+ classKey,
+ @namespace,
+ className,
+ genericParameters,
+ isNested,
+ containingTypeChain,
+ isPartialClass,
+ derivesFromExecutor,
+ hasManualConfigureRoutes,
+ classLocation,
+ typeName,
+ attributeKind));
+ }
+ }
+
+ return results.ToImmutable();
+ }
+
+ ///
+ /// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have protocol attributes
+ /// (no [MessageHandler] methods). This generates only ConfigureSentTypes/ConfigureYieldTypes overrides.
+ ///
+ /// The protocol info entries for the class.
+ /// The combined analysis result.
+ public static AnalysisResult CombineProtocolOnlyResults(IEnumerable protocolInfos)
+ {
+ List protocols = protocolInfos.ToList();
+ if (protocols.Count == 0)
+ {
+ return AnalysisResult.Empty;
+ }
+
+ // All entries should have same class info - take from first
+ ClassProtocolInfo first = protocols[0];
+ Location classLocation = first.ClassLocation?.ToRoslynLocation() ?? Location.None;
+
+ ImmutableArray.Builder allDiagnostics = ImmutableArray.CreateBuilder();
+
+ // Class-level validation
+ 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());
+ }
+
+ // Collect send and yield types
+ ImmutableArray.Builder sendTypes = ImmutableArray.CreateBuilder();
+ ImmutableArray.Builder yieldTypes = ImmutableArray.CreateBuilder();
+
+ foreach (ClassProtocolInfo protocol in protocols)
+ {
+ if (protocol.AttributeKind == ProtocolAttributeKind.Send)
+ {
+ sendTypes.Add(protocol.TypeName);
+ }
+ else
+ {
+ yieldTypes.Add(protocol.TypeName);
+ }
+ }
+
+ // Sort to ensure consistent ordering for incremental generator caching
+ sendTypes.Sort(StringComparer.Ordinal);
+ yieldTypes.Sort(StringComparer.Ordinal);
+
+ // Create ExecutorInfo with no handlers but with protocol types
+ ExecutorInfo executorInfo = new(
+ first.Namespace,
+ first.ClassName,
+ first.GenericParameters,
+ first.IsNested,
+ first.ContainingTypeChain,
+ BaseHasConfigureRoutes: false, // Not relevant for protocol-only
+ Handlers: ImmutableEquatableArray.Empty,
+ ClassSendTypes: new ImmutableEquatableArray(sendTypes.ToImmutable()),
+ ClassYieldTypes: new ImmutableEquatableArray(yieldTypes.ToImmutable()));
+
+ if (allDiagnostics.Count > 0)
+ {
+ return AnalysisResult.WithInfoAndDiagnostics(executorInfo, allDiagnostics.ToImmutable());
+ }
+
+ return AnalysisResult.Success(executorInfo);
+ }
+
+ ///
+ /// Gets the source location of the class identifier for diagnostic reporting.
+ ///
+ private static DiagnosticLocationInfo? GetClassLocation(INamedTypeSymbol classSymbol, CancellationToken cancellationToken)
+ {
+ foreach (SyntaxReference syntaxRef in classSymbol.DeclaringSyntaxReferences)
+ {
+ SyntaxNode 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 (SyntaxReference syntaxRef in classSymbol.DeclaringSyntaxReferences)
+ {
+ SyntaxNode 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)
+ {
+ INamedTypeSymbol? current = classSymbol.BaseType;
+ while (current != null)
+ {
+ string 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)
+ {
+ INamedTypeSymbol? baseType = classSymbol.BaseType;
+ while (baseType != null)
+ {
+ string 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)
+ {
+ Location 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
+ IParameterSymbol 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
+ bool hasCancellationToken = methodSymbol.Parameters.Length >= 3 &&
+ methodSymbol.Parameters[2].Type.ToDisplayString() == CancellationTokenTypeName;
+
+ // Analyze return type
+ ITypeSymbol returnType = methodSymbol.ReturnType;
+ HandlerSignatureKind? signatureKind = GetSignatureKind(returnType);
+ if (signatureKind == null)
+ {
+ diagnostics.Add(DiagnosticInfo.Create("MAFGENWF002", location, methodSymbol.Name));
+ return null;
+ }
+
+ // Get input type
+ ITypeSymbol inputType = methodSymbol.Parameters[0].Type;
+ string 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
+ (ImmutableEquatableArray yieldTypes, ImmutableEquatableArray 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)
+ {
+ string 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.
+ ///
+ ///
+ /// Results are sorted to ensure consistent ordering for incremental generator caching.
+ ///
+ private static ImmutableArray ExtractTypeArray(TypedConstant typedConstant)
+ {
+ if (typedConstant.Kind != TypedConstantKind.Array)
+ {
+ return ImmutableArray.Empty;
+ }
+
+ ImmutableArray.Builder builder = ImmutableArray.CreateBuilder();
+ foreach (TypedConstant value in typedConstant.Values)
+ {
+ if (value.Value is INamedTypeSymbol typeSymbol)
+ {
+ builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
+ }
+ }
+
+ // Sort to ensure consistent ordering for incremental generator caching
+ builder.Sort(StringComparer.Ordinal);
+
+ return builder.ToImmutable();
+ }
+
+ ///
+ /// Collects types from [SendsMessage] or [YieldsOutput] attributes applied to the class.
+ ///
+ ///
+ /// Results are sorted to ensure consistent ordering for incremental generator caching,
+ /// since GetAttributes() order is not guaranteed across partial class declarations.
+ ///
+ ///
+ /// [SendsMessage(typeof(Request))]
+ /// [YieldsOutput(typeof(Response))]
+ /// public partial class MyExecutor : Executor { }
+ ///
+ private static ImmutableEquatableArray GetClassLevelTypes(INamedTypeSymbol classSymbol, string attributeName)
+ {
+ ImmutableArray.Builder builder = ImmutableArray.CreateBuilder();
+
+ foreach (AttributeData 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));
+ }
+ }
+
+ // Sort to ensure consistent ordering for incremental generator caching
+ builder.Sort(StringComparer.Ordinal);
+
+ 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)
+ {
+ List chain = new();
+ INamedTypeSymbol? 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;
+ }
+
+ string 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..324fe6e2b4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs
@@ -0,0 +1,161 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+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, and/or ConfigureSentTypes/ConfigureYieldTypes
+/// overrides for classes with [SendsMessage]/[YieldsOutput] attributes.
+///
+[Generator]
+public sealed class ExecutorRouteGenerator : IIncrementalGenerator
+{
+ private const string MessageHandlerAttributeFullName = "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute";
+ private const string SendsMessageAttributeFullName = "Microsoft.Agents.AI.Workflows.SendsMessageAttribute";
+ private const string YieldsOutputAttributeFullName = "Microsoft.Agents.AI.Workflows.YieldsOutputAttribute";
+
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Pipeline 1: Methods with [MessageHandler] attribute
+ IncrementalValuesProvider methodAnalysisResults = context.SyntaxProvider
+ .ForAttributeWithMetadataName(
+ fullyQualifiedMetadataName: MessageHandlerAttributeFullName,
+ predicate: static (node, _) => node is MethodDeclarationSyntax,
+ transform: static (ctx, ct) => SemanticAnalyzer.AnalyzeHandlerMethod(ctx, ct))
+ .Where(static result => !string.IsNullOrEmpty(result.ClassKey));
+
+ // Pipeline 2: Classes with [SendsMessage] attribute
+ IncrementalValuesProvider sendProtocolResults = context.SyntaxProvider
+ .ForAttributeWithMetadataName(
+ fullyQualifiedMetadataName: SendsMessageAttributeFullName,
+ predicate: static (node, _) => node is ClassDeclarationSyntax,
+ transform: static (ctx, ct) => SemanticAnalyzer.AnalyzeClassProtocolAttribute(ctx, ProtocolAttributeKind.Send, ct))
+ .SelectMany(static (results, _) => results);
+
+ // Pipeline 3: Classes with [YieldsOutput] attribute
+ IncrementalValuesProvider yieldProtocolResults = context.SyntaxProvider
+ .ForAttributeWithMetadataName(
+ fullyQualifiedMetadataName: YieldsOutputAttributeFullName,
+ predicate: static (node, _) => node is ClassDeclarationSyntax,
+ transform: static (ctx, ct) => SemanticAnalyzer.AnalyzeClassProtocolAttribute(ctx, ProtocolAttributeKind.Yield, ct))
+ .SelectMany(static (results, _) => results);
+
+ // Combine all protocol results (Send + Yield)
+ IncrementalValuesProvider allProtocolResults = sendProtocolResults
+ .Collect()
+ .Combine(yieldProtocolResults.Collect())
+ .SelectMany(static (tuple, _) => tuple.Left.AddRange(tuple.Right));
+
+ // Combine all pipelines and produce AnalysisResults grouped by class
+ IncrementalValuesProvider combinedResults = methodAnalysisResults
+ .Collect()
+ .Combine(allProtocolResults.Collect())
+ .SelectMany(static (tuple, _) => CombineAllResults(tuple.Left, tuple.Right));
+
+ // Generate source for valid executors
+ context.RegisterSourceOutput(
+ combinedResults.Where(static r => r.ExecutorInfo is not null),
+ static (ctx, result) =>
+ {
+ string source = SourceBuilder.Generate(result.ExecutorInfo!);
+ string hintName = GetHintName(result.ExecutorInfo!);
+ ctx.AddSource(hintName, SourceText.From(source, Encoding.UTF8));
+ });
+
+ // Report diagnostics
+ context.RegisterSourceOutput(
+ combinedResults.Where(static r => !r.Diagnostics.IsEmpty),
+ static (ctx, result) =>
+ {
+ foreach (Diagnostic diagnostic in result.Diagnostics)
+ {
+ ctx.ReportDiagnostic(diagnostic);
+ }
+ });
+ }
+
+ ///
+ /// Combines method analysis results with class protocol results, grouping by class key.
+ /// Classes with [MessageHandler] methods get full generation; classes with only protocol
+ /// attributes get protocol-only generation.
+ ///
+ private static IEnumerable CombineAllResults(
+ ImmutableArray methodResults,
+ ImmutableArray protocolResults)
+ {
+ // Group method results by class
+ Dictionary> methodsByClass = methodResults
+ .GroupBy(r => r.ClassKey)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ // Group protocol results by class
+ Dictionary> protocolsByClass = protocolResults
+ .GroupBy(r => r.ClassKey)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ // Track which classes we've processed
+ HashSet processedClasses = new();
+
+ // Process classes that have [MessageHandler] methods
+ foreach (KeyValuePair> kvp in methodsByClass)
+ {
+ processedClasses.Add(kvp.Key);
+ yield return SemanticAnalyzer.CombineHandlerMethodResults(kvp.Value);
+ }
+
+ // Process classes that only have protocol attributes (no [MessageHandler] methods)
+ foreach (KeyValuePair> kvp in protocolsByClass)
+ {
+ if (!processedClasses.Contains(kvp.Key))
+ {
+ yield return SemanticAnalyzer.CombineProtocolOnlyResults(kvp.Value);
+ }
+ }
+ }
+
+ ///
+ /// 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..6a3a5377ba
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs
@@ -0,0 +1,253 @@
+// 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 [YieldsOutput] 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 { ... } } }
+ string indent = "";
+ if (info.IsNested)
+ {
+ foreach (string 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}{{");
+
+ string memberIndent = indent + " ";
+ bool hasContent = false;
+
+ // Only generate ConfigureRoutes if there are handlers
+ if (info.Handlers.Count > 0)
+ {
+ GenerateConfigureRoutes(sb, info, memberIndent);
+ hasContent = true;
+ }
+
+ // Only generate protocol overrides if [SendsMessage] or [YieldsOutput] attributes are present.
+ // Without these attributes, we rely on the base class defaults.
+ if (info.ShouldGenerateProtocolOverrides)
+ {
+ if (hasContent)
+ {
+ sb.AppendLine();
+ }
+
+ GenerateConfigureSentTypes(sb, info, memberIndent);
+ sb.AppendLine();
+ GenerateConfigureYieldTypes(sb, info, memberIndent);
+ }
+
+ // Close class
+ sb.AppendLine($"{indent}}}");
+
+ // Close nested classes
+ if (info.IsNested)
+ {
+ string[] 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}{{");
+
+ string 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)
+ {
+ HandlerInfo 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++)
+ {
+ HandlerInfo handler = info.Handlers[i];
+
+ sb.Append($"{bodyIndent} .AddHandler");
+ AppendHandlerGenericArgs(sb, handler);
+ sb.Append($"(this.{handler.MethodName})");
+ sb.AppendLine();
+ }
+
+ // Remove last newline without using that System.Environment which is banned from use in analyzers
+ var newLineLength = new StringBuilder().AppendLine().Length;
+ sb.Remove(sb.Length - newLineLength, newLineLength);
+ 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}{{");
+
+ string 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 [YieldsOutput] 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}{{");
+
+ string 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/ClassProtocolInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs
new file mode 100644
index 0000000000..df9205cc5f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Models;
+
+///
+/// Represents protocol type information extracted from class-level [SendsMessage] or [YieldsOutput] attributes.
+/// Used by the incremental generator pipeline to capture classes that declare protocol types
+/// but may not have [MessageHandler] methods (e.g., when ConfigureRoutes is manually implemented).
+///
+/// Unique identifier for the class (fully qualified name).
+/// The namespace of the class.
+/// The name of the class.
+/// The generic type parameters (e.g., "<T>"), or null if not generic.
+/// Whether the class is nested inside another class.
+/// The chain of containing types for nested classes. Empty if not nested.
+/// Whether the class is declared as partial.
+/// Whether the class derives from Executor.
+/// Whether the class has a manually defined ConfigureRoutes method.
+/// Location info for diagnostics.
+/// The fully qualified type name from the attribute.
+/// Whether this is from a SendsMessage or YieldsOutput attribute.
+internal sealed record ClassProtocolInfo(
+ string ClassKey,
+ string? Namespace,
+ string ClassName,
+ string? GenericParameters,
+ bool IsNested,
+ string ContainingTypeChain,
+ bool IsPartialClass,
+ bool DerivesFromExecutor,
+ bool HasManualConfigureRoutes,
+ DiagnosticLocationInfo? ClassLocation,
+ string TypeName,
+ ProtocolAttributeKind AttributeKind)
+{
+ ///
+ /// Gets an empty result for invalid targets.
+ ///
+ public static ClassProtocolInfo Empty { get; } = new(
+ string.Empty, null, string.Empty, null, false, string.Empty,
+ false, false, false, null, string.Empty, ProtocolAttributeKind.Send);
+}
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..89f299ceca
--- /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)
+ {
+ FileLinePositionSpan 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)
+ {
+ DiagnosticDescriptor? descriptor = DiagnosticDescriptors.GetById(this.DiagnosticId);
+ if (descriptor is null)
+ {
+ // Fallback - should not happen
+ object[] 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;
+ }
+
+ object[] 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..fc7e04871c
--- /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;
+ }
+
+ FileLinePositionSpan 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..507927d875
--- /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 [YieldsOutput] 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..f9493c5d93
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs
@@ -0,0 +1,51 @@
+// 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)
+{
+ ///
+ /// Gets an empty result for invalid targets (e.g., attribute on non-method).
+ ///
+ public static MethodAnalysisResult Empty { get; } = new(
+ string.Empty, null, string.Empty, null, false, string.Empty,
+ false, ImmutableEquatableArray.Empty, ImmutableEquatableArray.Empty,
+ false, false, false,
+ null, null, ImmutableEquatableArray.Empty);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ProtocolAttributeKind.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ProtocolAttributeKind.cs
new file mode 100644
index 0000000000..68d4e75469
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ProtocolAttributeKind.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Workflows.Generators.Models;
+
+///
+/// Identifies the kind of protocol attribute.
+///
+internal enum ProtocolAttributeKind
+{
+ ///
+ /// The [SendsMessage] attribute.
+ ///
+ Send,
+
+ ///
+ /// The [YieldsOutput] attribute.
+ ///
+ Yield
+}
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/YieldsOutputAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsOutputAttribute.cs
new file mode 100644
index 0000000000..5aad434b1d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsOutputAttribute.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.
+///
+///
+///
+///
+/// [YieldsOutput(typeof(FinalResult))]
+/// [YieldsOutput(typeof(StreamChunk))]
+/// public partial class MyExecutor : Executor
+/// {
+/// // ...
+/// }
+///
+///
+[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
+public sealed class YieldsOutputAttribute : 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 YieldsOutputAttribute(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..c48ba9ffdf
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs
@@ -0,0 +1,1287 @@
+// 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_YieldsOutputAttribute_GeneratesConfigureYieldTypes()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class YieldedMessage { }
+
+ [YieldsOutput(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)");
+ }
+
+ [Fact]
+ public void PartialClass_SendsYieldsInBothFiles_GeneratesAlOverrides()
+ {
+ // File 1: Partial with one handler
+ var file1 = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ [YieldsOutput(typeof(string))]
+ [SendsMessage(typeof(int))]
+ 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;
+
+ [YieldsOutput(typeof(int))]
+ [SendsMessage(typeof(string))]
+ 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();
+
+ // Verify ConfigureSentTypes override
+ var sendsStart = generated.IndexOf("protected override ISet ConfigureSentTypes()", StringComparison.Ordinal);
+ sendsStart.Should().NotBe(-1, "should generate ConfigureSentTypes override");
+
+ var sendsEnd = generated.IndexOf("}", sendsStart, StringComparison.Ordinal);
+ sendsEnd.Should().NotBe(-1, "should close ConfigureSentTypes override");
+
+ generated.Substring(sendsStart, sendsEnd - sendsStart).Should().ContainAll(
+ "types.Add(typeof(string));",
+ "types.Add(typeof(int));");
+
+ // Verify ConfigureYieldTypes override
+ var yieldsStart = generated.IndexOf("protected override ISet ConfigureYieldTypes()", StringComparison.Ordinal);
+ yieldsStart.Should().NotBe(-1, "should generate ConfigureYieldTypes override");
+
+ var yieldsEnd = generated.IndexOf("}", yieldsStart, StringComparison.Ordinal);
+ yieldsEnd.Should().NotBe(-1, "should close ConfigureYieldTypes override");
+
+ generated.Substring(yieldsStart, yieldsEnd - yieldsStart).Should().ContainAll(
+ "types.Add(typeof(string));",
+ "types.Add(typeof(int));");
+ }
+
+ #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 Protocol-Only Generation Tests
+
+ [Fact]
+ public void ProtocolOnly_SendsMessage_WithManualRoutes_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") { }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder;
+ }
+ }
+ """;
+
+ 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 generate ConfigureRoutes (user has manual implementation)
+ generated.Should().NotContain("protected override RouteBuilder ConfigureRoutes");
+
+ // Should generate ConfigureSentTypes
+ generated.Should().Contain("protected override ISet ConfigureSentTypes()");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.BroadcastMessage))");
+ }
+
+ [Fact]
+ public void ProtocolOnly_YieldsOutput_WithManualRoutes_GeneratesConfigureYieldTypes()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class OutputMessage { }
+
+ [YieldsOutput(typeof(OutputMessage))]
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder;
+ }
+ }
+ """;
+
+ 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 generate ConfigureRoutes (user has manual implementation)
+ generated.Should().NotContain("protected override RouteBuilder ConfigureRoutes");
+
+ // Should generate ConfigureYieldTypes
+ generated.Should().Contain("protected override ISet ConfigureYieldTypes()");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.OutputMessage))");
+ }
+
+ [Fact]
+ public void ProtocolOnly_BothAttributes_WithManualRoutes_GeneratesBothOverrides()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class SendMessage { }
+ public class YieldMessage { }
+
+ [SendsMessage(typeof(SendMessage))]
+ [YieldsOutput(typeof(YieldMessage))]
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder;
+ }
+ }
+ """;
+
+ 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 generate ConfigureRoutes
+ generated.Should().NotContain("protected override RouteBuilder ConfigureRoutes");
+
+ // Should generate both protocol overrides
+ generated.Should().Contain("protected override ISet ConfigureSentTypes()");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.SendMessage))");
+ generated.Should().Contain("protected override ISet ConfigureYieldTypes()");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.YieldMessage))");
+ }
+
+ [Fact]
+ public void ProtocolOnly_MultipleSendsMessageAttributes_GeneratesAllTypes()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class MessageA { }
+ public class MessageB { }
+ public class MessageC { }
+
+ [SendsMessage(typeof(MessageA))]
+ [SendsMessage(typeof(MessageB))]
+ [SendsMessage(typeof(MessageC))]
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder;
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ result.RunResult.GeneratedTrees.Should().HaveCount(1);
+
+ var generated = result.RunResult.GeneratedTrees[0].ToString();
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.MessageA))");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.MessageB))");
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.MessageC))");
+ }
+
+ [Fact]
+ public void ProtocolOnly_NonPartialClass_ProducesDiagnostic()
+ {
+ 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 class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder;
+ }
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ // Should produce MAFGENWF003 diagnostic (class must be partial)
+ result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF003");
+ result.RunResult.GeneratedTrees.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ProtocolOnly_NonExecutorClass_ProducesDiagnostic()
+ {
+ 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 NotAnExecutor
+ {
+ }
+ """;
+
+ var result = GeneratorTestHelper.RunGenerator(source);
+
+ // Should produce MAFGENWF004 diagnostic (must derive from Executor)
+ result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF004");
+ result.RunResult.GeneratedTrees.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ProtocolOnly_NestedClass_GeneratesCorrectPartialHierarchy()
+ {
+ var source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Agents.AI.Workflows;
+
+ namespace TestNamespace;
+
+ public class BroadcastMessage { }
+
+ public partial class OuterClass
+ {
+ [SendsMessage(typeof(BroadcastMessage))]
+ public partial class TestExecutor : Executor
+ {
+ public TestExecutor() : base("test") { }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder;
+ }
+ }
+ }
+ """;
+
+ 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 protocol types are generated
+ generated.Should().Contain("types.Add(typeof(global::TestNamespace.BroadcastMessage))");
+ }
+
+ [Fact]
+ public void ProtocolOnly_GenericExecutor_GeneratesCorrectly()
+ {
+ 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 GenericExecutor : Executor where T : class
+ {
+ public GenericExecutor() : base("generic") { }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder;
+ }
+ }
+ """;
+
+ 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("types.Add(typeof(global::TestNamespace.BroadcastMessage))");
+ }
+
+ #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
+
+
+
+
+
+
+
+
+
+
+
+
+
+