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 7fcbdb18cac..f2942bf4a09 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs @@ -280,9 +280,8 @@ public static AnalysisResult CombineOutputOnlyResults(IEnumerable + /// MAFGENWF008: Executor with protocol attributes must be partial. + /// + public static readonly DiagnosticDescriptor ProtocolClassMustBePartial = Register(new( + id: "MAFGENWF008", + title: "Executor with protocol attributes must be partial", + messageFormat: "Class '{0}' uses [SendsMessage] or [YieldsOutput] but is not declared as partial", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true)); + + /// + /// MAFGENWF009: Protocol attributes on non-Executor class. + /// + public static readonly DiagnosticDescriptor ProtocolClassNotAnExecutor = Register(new( + id: "MAFGENWF009", + title: "Protocol attributes on non-Executor class", + messageFormat: "Class '{0}' uses [SendsMessage] or [YieldsOutput] but does not derive from Executor", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true)); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs index 93829be21ec..5274ead0b20 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs @@ -18,6 +18,10 @@ namespace Microsoft.Agents.AI.Workflows; /// 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. /// +/// +/// When this attribute is applied to an executor class and the workflows source generator is referenced, +/// the class must be declared partial so the generator can add its protocol configuration. +/// /// /// /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsOutputAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsOutputAttribute.cs index 11093645b21..bb2b2464b28 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsOutputAttribute.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsOutputAttribute.cs @@ -18,6 +18,10 @@ namespace Microsoft.Agents.AI.Workflows; /// 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. /// +/// +/// When this attribute is applied to an executor class and the workflows source generator is referenced, +/// the class must be declared partial so the generator can add its protocol configuration. +/// /// /// /// 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 8433dd5e3e6..15fb2429c67 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs @@ -927,10 +927,12 @@ public TestExecutor() : base("test") { } .And.RegisterSentMessageType("global::TestNamespace.MessageC"); } - [Fact] - public void ProtocolOnly_NonPartialClass_ProducesDiagnostic() + [Theory] + [InlineData("SendsMessage")] + [InlineData("YieldsOutput")] + public void ProtocolOnly_NonPartialClass_ProducesProtocolDiagnostic(string attributeName) { - var source = """ + var source = $$""" using System; using System.Threading; using System.Threading.Tasks; @@ -940,7 +942,7 @@ namespace TestNamespace; public class BroadcastMessage { } - [SendsMessage(typeof(BroadcastMessage))] + [{{attributeName}}(typeof(BroadcastMessage))] public class TestExecutor : Executor { public TestExecutor() : base("test") { } @@ -949,15 +951,57 @@ public TestExecutor() : base("test") { } var result = GeneratorTestHelper.RunGenerator(source); - // Should produce MAFGENWF003 diagnostic (class must be partial) - result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF003"); + result.RunResult.Diagnostics.Should().ContainSingle(); + var diagnostic = result.RunResult.Diagnostics.Single(); + diagnostic.Id.Should().Be("MAFGENWF008"); + diagnostic.GetMessage().Should().Be( + "Class 'TestExecutor' uses [SendsMessage] or [YieldsOutput] but is not declared as partial"); result.RunResult.GeneratedTrees.Should().BeEmpty(); } [Fact] - public void ProtocolOnly_NonExecutorClass_ProducesDiagnostic() + public void ProtocolOnly_NonPartialExecutorOfT_ProducesProtocolDiagnostic() { var source = """ + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + [YieldsOutput(typeof(List))] + internal sealed class CompletionExecutor(string id) : Executor>(id) + { + public override async ValueTask HandleAsync( + List message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + List filePaths = message.ConvertAll(result => result.FilePath); + await context.YieldOutputAsync(filePaths, cancellationToken); + } + } + + internal sealed record ReduceComplete(string FilePath); + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.Diagnostics.Should().ContainSingle(); + var diagnostic = result.RunResult.Diagnostics.Single(); + diagnostic.Id.Should().Be("MAFGENWF008"); + diagnostic.GetMessage().Should().Be( + "Class 'CompletionExecutor' uses [SendsMessage] or [YieldsOutput] but is not declared as partial"); + result.RunResult.GeneratedTrees.Should().BeEmpty(); + } + + [Theory] + [InlineData("SendsMessage")] + [InlineData("YieldsOutput")] + public void ProtocolOnly_NonExecutorClass_ProducesProtocolDiagnostic(string attributeName) + { + var source = $$""" using System; using System.Threading; using System.Threading.Tasks; @@ -967,7 +1011,7 @@ namespace TestNamespace; public class BroadcastMessage { } - [SendsMessage(typeof(BroadcastMessage))] + [{{attributeName}}(typeof(BroadcastMessage))] public partial class NotAnExecutor { } @@ -975,8 +1019,11 @@ public partial class NotAnExecutor var result = GeneratorTestHelper.RunGenerator(source); - // Should produce MAFGENWF004 diagnostic (must derive from Executor) - result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF004"); + result.RunResult.Diagnostics.Should().ContainSingle(); + var diagnostic = result.RunResult.Diagnostics.Single(); + diagnostic.Id.Should().Be("MAFGENWF009"); + diagnostic.GetMessage().Should().Be( + "Class 'NotAnExecutor' uses [SendsMessage] or [YieldsOutput] but does not derive from Executor"); result.RunResult.GeneratedTrees.Should().BeEmpty(); }