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