Compare commits

...
5 changed files with 104 additions and 13 deletions
@@ -61,6 +61,12 @@ public static class Program
{ {
Console.WriteLine($"{outputEvent}"); Console.WriteLine($"{outputEvent}");
} }
if (evt is WorkflowErrorEvent errorEvent)
{
Console.WriteLine($"Workflow error: {errorEvent.Exception?.Message}");
Console.WriteLine($"Details: {errorEvent.Exception}");
}
} }
} }
} }
@@ -175,7 +181,9 @@ internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEve
/// <summary> /// <summary>
/// A custom executor that uses an AI agent to provide feedback on a slogan. /// A custom executor that uses an AI agent to provide feedback on a slogan.
/// </summary> /// </summary>
internal sealed class FeedbackExecutor : Executor<SloganResult> [SendsMessage(typeof(FeedbackResult))]
[YieldsOutput(typeof(string))]
internal sealed partial class FeedbackExecutor : Executor<SloganResult>
{ {
private readonly AIAgent _agent; private readonly AIAgent _agent;
private AgentSession? _session; private AgentSession? _session;
@@ -68,7 +68,7 @@ internal static class SemanticAnalyzer
string classKey = GetClassKey(classSymbol); string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken); bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol); bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool configureProtocol = HasConfigureProtocolDefined(classSymbol); bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
// Extract class metadata // Extract class metadata
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
@@ -97,7 +97,7 @@ internal static class SemanticAnalyzer
return new MethodAnalysisResult( return new MethodAnalysisResult(
classKey, @namespace, className, genericParameters, isNested, containingTypeChain, classKey, @namespace, className, genericParameters, isNested, containingTypeChain,
baseHasConfigureProtocol, classSendTypes, classYieldTypes, baseHasConfigureProtocol, classSendTypes, classYieldTypes,
isPartialClass, derivesFromExecutor, configureProtocol, isPartialClass, derivesFromExecutor, hasManualConfigureProtocol,
classLocation, classLocation,
handler, handler,
Diagnostics: new ImmutableEquatableArray<DiagnosticInfo>(methodDiagnostics.ToImmutable())); Diagnostics: new ImmutableEquatableArray<DiagnosticInfo>(methodDiagnostics.ToImmutable()));
@@ -149,7 +149,7 @@ internal static class SemanticAnalyzer
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
} }
if (first.HasManualConfigureRoutes) if (first.HasManualConfigureProtocol)
{ {
allDiagnostics.Add(Diagnostic.Create( allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ConfigureProtocolAlreadyDefined, DiagnosticDescriptors.ConfigureProtocolAlreadyDefined,
@@ -212,6 +212,7 @@ internal static class SemanticAnalyzer
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken); bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol); bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol); bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol);
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
? null ? null
@@ -241,6 +242,7 @@ internal static class SemanticAnalyzer
isPartialClass, isPartialClass,
derivesFromExecutor, derivesFromExecutor,
hasManualConfigureProtocol, hasManualConfigureProtocol,
baseHasConfigureProtocol,
classLocation, classLocation,
typeName, typeName,
attributeKind)); attributeKind));
@@ -321,7 +323,7 @@ internal static class SemanticAnalyzer
first.GenericParameters, first.GenericParameters,
first.IsNested, first.IsNested,
first.ContainingTypeChain, first.ContainingTypeChain,
BaseHasConfigureProtocol: false, // Not relevant for protocol-only first.BaseHasConfigureProtocol,
Handlers: ImmutableEquatableArray<HandlerInfo>.Empty, Handlers: ImmutableEquatableArray<HandlerInfo>.Empty,
ClassSendTypes: new ImmutableEquatableArray<string>(sendTypes.ToImmutable()), ClassSendTypes: new ImmutableEquatableArray<string>(sendTypes.ToImmutable()),
ClassYieldTypes: new ImmutableEquatableArray<string>(yieldTypes.ToImmutable())); ClassYieldTypes: new ImmutableEquatableArray<string>(yieldTypes.ToImmutable()));
@@ -5,7 +5,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary> /// <summary>
/// Represents protocol type information extracted from class-level [SendsMessage] or [YieldsOutput] attributes. /// 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 /// 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).
/// </summary> /// </summary>
/// <param name="ClassKey">Unique identifier for the class (fully qualified name).</param> /// <param name="ClassKey">Unique identifier for the class (fully qualified name).</param>
/// <param name="Namespace">The namespace of the class.</param> /// <param name="Namespace">The namespace of the class.</param>
@@ -15,7 +15,8 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <param name="ContainingTypeChain">The chain of containing types for nested classes. Empty if not nested.</param> /// <param name="ContainingTypeChain">The chain of containing types for nested classes. Empty if not nested.</param>
/// <param name="IsPartialClass">Whether the class is declared as partial.</param> /// <param name="IsPartialClass">Whether the class is declared as partial.</param>
/// <param name="DerivesFromExecutor">Whether the class derives from Executor.</param> /// <param name="DerivesFromExecutor">Whether the class derives from Executor.</param>
/// <param name="HasManualConfigureRoutes">Whether the class has a manually defined ConfigureRoutes method.</param> /// <param name="HasManualConfigureProtocol">Whether the class has a manually defined ConfigureProtocol method.</param>
/// <param name="BaseHasConfigureProtocol">Whether a base class already overrides ConfigureProtocol.</param>
/// <param name="ClassLocation">Location info for diagnostics.</param> /// <param name="ClassLocation">Location info for diagnostics.</param>
/// <param name="TypeName">The fully qualified type name from the attribute.</param> /// <param name="TypeName">The fully qualified type name from the attribute.</param>
/// <param name="AttributeKind">Whether this is from a SendsMessage or YieldsOutput attribute.</param> /// <param name="AttributeKind">Whether this is from a SendsMessage or YieldsOutput attribute.</param>
@@ -28,7 +29,8 @@ internal sealed record ClassProtocolInfo(
string ContainingTypeChain, string ContainingTypeChain,
bool IsPartialClass, bool IsPartialClass,
bool DerivesFromExecutor, bool DerivesFromExecutor,
bool HasManualConfigureRoutes, bool HasManualConfigureProtocol,
bool BaseHasConfigureProtocol,
DiagnosticLocationInfo? ClassLocation, DiagnosticLocationInfo? ClassLocation,
string TypeName, string TypeName,
ProtocolAttributeKind AttributeKind) ProtocolAttributeKind AttributeKind)
@@ -38,5 +40,5 @@ internal sealed record ClassProtocolInfo(
/// </summary> /// </summary>
public static ClassProtocolInfo Empty { get; } = new( public static ClassProtocolInfo Empty { get; } = new(
string.Empty, null, string.Empty, null, false, string.Empty, string.Empty, null, string.Empty, null, false, string.Empty,
false, false, false, null, string.Empty, ProtocolAttributeKind.Send); false, false, false, false, null, string.Empty, ProtocolAttributeKind.Send);
} }
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// Uses value-equatable types to support incremental generator caching. /// Uses value-equatable types to support incremental generator caching.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureRoutes) /// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureProtocol)
/// is extracted here but validated once per class in CombineMethodResults to avoid /// is extracted here but validated once per class in CombineMethodResults to avoid
/// redundant validation work when a class has multiple handlers. /// redundant validation work when a class has multiple handlers.
/// </remarks> /// </remarks>
@@ -29,7 +29,7 @@ internal sealed record MethodAnalysisResult(
// Class-level facts (used for validation in CombineMethodResults) // Class-level facts (used for validation in CombineMethodResults)
bool IsPartialClass, bool IsPartialClass,
bool DerivesFromExecutor, bool DerivesFromExecutor,
bool HasManualConfigureRoutes, bool HasManualConfigureProtocol,
// Class location for diagnostics (value-equatable) // Class location for diagnostics (value-equatable)
DiagnosticLocationInfo? ClassLocation, DiagnosticLocationInfo? ClassLocation,
@@ -651,7 +651,7 @@ public class ExecutorRouteGeneratorTests
} }
[Fact] [Fact]
public void PartialClass_SendsYieldsInBothFiles_GeneratesAlOverrides() public void PartialClass_SendsYieldsInBothFiles_GeneratesAllOverrides()
{ {
// File 1: Partial with one handler // File 1: Partial with one handler
var file1 = """ var file1 = """
@@ -700,7 +700,7 @@ public class ExecutorRouteGeneratorTests
generated.Should().RegisterSentMessageType("string") generated.Should().RegisterSentMessageType("string")
.And.RegisterSentMessageType("int") .And.RegisterSentMessageType("int")
.And.RegisterYieldedOutputType("string") .And.RegisterYieldedOutputType("string")
.And.RegisterYieldedOutputType("string"); .And.RegisterYieldedOutputType("int");
} }
#endregion #endregion
@@ -1046,6 +1046,85 @@ public class ExecutorRouteGeneratorTests
.And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage"); .And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage");
} }
[Fact]
public void ProtocolOnly_DerivesFromExecutorOfT_GeneratesBaseCall()
{
// A protocol-only partial executor deriving from Executor<T>
// 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<string>
{
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<T> 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<T> overrides ConfigureProtocol, so base must be called to preserve its handler registration");
generated.Should().Contain(".SendsMessage<global::TestNamespace.FeedbackResult>()");
generated.Should().Contain(".YieldsOutput<string>()");
}
[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 #endregion
#region Generic Executor Tests #region Generic Executor Tests