diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 054aa38b237..bec044cc8d7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -2,6 +2,7 @@ using System; using System.IO; +using System.Linq; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; @@ -104,6 +105,24 @@ private static AdaptiveDialog ReadWorkflow(TextReader yamlReader) throw new DeclarativeModelException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(Workflow)}."); } + // Unknown template properties are retained as extension data by the YAML reader. + // Reject them here rather than silently emitting an empty or incomplete message. + foreach (MessageActivityTemplate template in workflowElement.Descendants().OfType()) + { + if (template.ExtensionData is { Properties.Count: > 0 } extensionData) + { + BotElement? owner = template.Parent; + while (owner is not null && owner is not DialogAction) + { + owner = owner.Parent; + } + + string action = owner is DialogAction dialogAction ? $" in action #{dialogAction.Id} ({dialogAction.GetType().Name})" : string.Empty; + string properties = string.Join(", ", extensionData.Properties.Keys.OrderBy(key => key, StringComparer.Ordinal)); + throw new DeclarativeModelException($"Unknown message template properties{action}: {properties}. Check the YAML property names, such as 'text'."); + } + } + return workflowElement; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 5d052c64d3d..f23055c5c1b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -105,7 +105,7 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf catch (Exception exception) { Debug.WriteLine($"ERROR [{this.Id}] {exception.GetType().Name}\n{exception.Message}"); - throw new DeclarativeActionException($"Unhandled workflow failure - #{this.Id} ({this.Model.GetType().Name})", exception); + throw new DeclarativeActionException($"Unhandled workflow failure - #{this.Id} ({this.Model.GetType().Name}): {exception.Message}", exception); } finally { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeValidationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeValidationTests.cs new file mode 100644 index 00000000000..345f9a9b82a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeValidationTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; + +public sealed class DeclarativeValidationTests +{ + [Theory] + [InlineData("SendActivity", "activity", "")] + [InlineData("Question", "prompt", "property: Local.answer\n entity: StringPrebuiltEntity")] + public void Build_UnknownMessagePropertyReportsAction(string kind, string property, string extra) + { + // Arrange + using var reader = new StringReader($""" + kind: Workflow + trigger: + kind: OnConversationStart + id: start + actions: + - kind: {kind} + id: invalid_message + {property}: + kind: Message + test: [Hello] + {extra} + """); + var options = new DeclarativeWorkflowOptions(Mock.Of()); + + // Act + var exception = Assert.Throws(() => DeclarativeWorkflowBuilder.Build(reader, options)); + + // Assert + Assert.Contains("test", exception.Message); + Assert.Contains("invalid_message", exception.Message); + Assert.Contains(kind, exception.Message); + } + + [Fact] + public void Build_PreservesSupportedTemplatePropertiesAndActionExtensions() + { + // Arrange + using var reader = new StringReader(""" + kind: Workflow + trigger: + kind: OnConversationStart + id: start + actions: + - kind: Question + id: question + autoSend: false + property: Local.answer + entity: StringPrebuiltEntity + prompt: + kind: Message + text: + - Hello + summary: Summary + """); + + // Act + var workflow = DeclarativeWorkflowBuilder.Build(reader, new(Mock.Of())); + + // Assert + Assert.NotNull(workflow); + } + + [Theory] + [InlineData("Text", false)] + [InlineData("Test", true)] + public async Task Run_TemplateExpressionReportsUnderlyingErrorAsync(string member, bool fails) + { + // Arrange + using var reader = new StringReader($$""" + kind: Workflow + trigger: + kind: OnConversationStart + id: start + actions: + - kind: SetVariable + id: set + variable: Topic.ToolResponse + value: =System.LastMessage + - kind: SendActivity + id: output + activity: "{Local.ToolResponse.{{member}}}" + """); + var provider = new Mock(); + provider.Setup(p => p.CreateConversationAsync(It.IsAny())).ReturnsAsync("local"); + provider.Setup(p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string _, ChatMessage message, CancellationToken _) => message); + var workflow = DeclarativeWorkflowBuilder.Build(reader, new(provider.Object)); + + // Act + await using var run = await InProcessExecution.RunStreamingAsync(workflow, "Hello"); + var events = await run.WatchStreamAsync().ToArrayAsync(); + + // Assert + if (fails) + { + var failure = Assert.Single(events.OfType()); + var exception = Assert.IsType(failure.Data); + Assert.Contains("output", exception.Message); + Assert.Contains("SendActivity", exception.Message); + Assert.Contains("'Test'", exception.Message); + Assert.NotNull(exception.InnerException); + } + else + { + Assert.Empty(events.OfType()); + Assert.Equal("Hello", Assert.Single(events.OfType()).Response.Text); + } + } +}