diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs index c116587507..66b67bffdf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs @@ -68,7 +68,7 @@ internal static class SemanticAnalyzer string classKey = GetClassKey(classSymbol); bool isPartialClass = IsPartialClass(classSymbol, cancellationToken); bool derivesFromExecutor = DerivesFromExecutor(classSymbol); - bool configureProtocol = HasConfigureProtocolDefined(classSymbol); + bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol); // Extract class metadata string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true @@ -97,7 +97,7 @@ internal static class SemanticAnalyzer return new MethodAnalysisResult( classKey, @namespace, className, genericParameters, isNested, containingTypeChain, baseHasConfigureProtocol, classSendTypes, classYieldTypes, - isPartialClass, derivesFromExecutor, configureProtocol, + isPartialClass, derivesFromExecutor, hasManualConfigureProtocol, classLocation, handler, Diagnostics: new ImmutableEquatableArray(methodDiagnostics.ToImmutable())); @@ -149,7 +149,7 @@ internal static class SemanticAnalyzer return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } - if (first.HasManualConfigureRoutes) + if (first.HasManualConfigureProtocol) { allDiagnostics.Add(Diagnostic.Create( DiagnosticDescriptors.ConfigureProtocolAlreadyDefined, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs index 90e5762890..1039855ea5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs @@ -5,7 +5,7 @@ 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). +/// but may not have [MessageHandler] methods (e.g., when ConfigureProtocol is manually implemented). /// /// Unique identifier for the class (fully qualified name). /// The namespace of the class. @@ -15,7 +15,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// 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. +/// Whether the class has a manually defined ConfigureProtocol method. /// Whether a base class already overrides ConfigureProtocol. /// Location info for diagnostics. /// The fully qualified type name from the attribute. @@ -29,7 +29,7 @@ internal sealed record ClassProtocolInfo( string ContainingTypeChain, bool IsPartialClass, bool DerivesFromExecutor, - bool HasManualConfigureRoutes, + bool HasManualConfigureProtocol, bool BaseHasConfigureProtocol, DiagnosticLocationInfo? ClassLocation, string TypeName, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs index fb3fafc6c2..4b6df3f7a5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// Uses value-equatable types to support incremental generator caching. /// /// -/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureRoutes) +/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureProtocol) /// is extracted here but validated once per class in CombineMethodResults to avoid /// redundant validation work when a class has multiple handlers. /// @@ -29,7 +29,7 @@ internal sealed record MethodAnalysisResult( // Class-level facts (used for validation in CombineMethodResults) bool IsPartialClass, bool DerivesFromExecutor, - bool HasManualConfigureRoutes, + bool HasManualConfigureProtocol, // Class location for diagnostics (value-equatable) DiagnosticLocationInfo? ClassLocation, diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs index d2160486cc..8433dd5e3e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs @@ -651,7 +651,7 @@ public class ExecutorRouteGeneratorTests } [Fact] - public void PartialClass_SendsYieldsInBothFiles_GeneratesAlOverrides() + public void PartialClass_SendsYieldsInBothFiles_GeneratesAllOverrides() { // File 1: Partial with one handler var file1 = """ @@ -700,7 +700,7 @@ public class ExecutorRouteGeneratorTests generated.Should().RegisterSentMessageType("string") .And.RegisterSentMessageType("int") .And.RegisterYieldedOutputType("string") - .And.RegisterYieldedOutputType("string"); + .And.RegisterYieldedOutputType("int"); } #endregion @@ -1046,6 +1046,85 @@ public class ExecutorRouteGeneratorTests .And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage"); } + [Fact] + public void ProtocolOnly_DerivesFromExecutorOfT_GeneratesBaseCall() + { + // A protocol-only partial executor deriving from Executor + // has a base class that already overrides ConfigureProtocol. The generator must emit + // "return base.ConfigureProtocol(protocolBuilder)" so inherited handler registrations + // are preserved — not "return protocolBuilder" which silently drops them. + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public class FeedbackResult { } + + [SendsMessage(typeof(FeedbackResult))] + [YieldsOutput(typeof(string))] + public partial class FeedbackExecutor : Executor + { + public FeedbackExecutor() : base("feedback") { } + + public override System.Threading.Tasks.ValueTask HandleAsync(string message, IWorkflowContext context, System.Threading.CancellationToken cancellationToken = default) + => default; + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + result.RunResult.Diagnostics.Should().BeEmpty(); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + + // Base class Executor overrides ConfigureProtocol, so the generated override + // must chain to base to preserve the inherited handler registration. + generated.Should().Contain("return base.ConfigureProtocol(protocolBuilder)", + because: "Executor overrides ConfigureProtocol, so base must be called to preserve its handler registration"); + generated.Should().Contain(".SendsMessage()"); + generated.Should().Contain(".YieldsOutput()"); + } + + [Fact] + public void ProtocolOnly_DerivesDirectlyFromExecutor_DoesNotGenerateBaseCall() + { + // A protocol-only partial executor deriving directly from Executor (abstract base + // with no non-abstract ConfigureProtocol override) should generate "return protocolBuilder" + // rather than "return base.ConfigureProtocol(protocolBuilder)". + 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 BroadcastExecutor : Executor + { + public BroadcastExecutor() : base("broadcast") { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + result.RunResult.Diagnostics.Should().BeEmpty(); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + + // Executor's ConfigureProtocol is abstract — no base call needed. + generated.Should().Contain("return protocolBuilder", + because: "Executor base class has no non-abstract ConfigureProtocol, so no base call is needed"); + generated.Should().NotContain("base.ConfigureProtocol"); + } + #endregion #region Generic Executor Tests