diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs index 99d2e29f50..4bf2a12500 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; + namespace Microsoft.Agents.AI.Workflows.Declarative.Kit; /// @@ -25,6 +27,11 @@ public sealed record class ActionExecutorResult internal static ActionExecutorResult ThrowIfNot(object? message) { + if (message is PortableValue portableValue && portableValue.IsType(out ActionExecutorResult? unwrapped)) + { + return unwrapped; + } + if (message is not ActionExecutorResult executorMessage) { throw new DeclarativeActionException($"Unexpected message type: {message?.GetType().Name ?? "(null)"} (Expected: {nameof(ActionExecutorResult)})"); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 24653af0f2..86efa6ec43 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -27,9 +27,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA public static string Resume(string id) => $"{id}_{nameof(Resume)}"; } - public static bool RequiresInput(object? message) => message is ExternalInputRequest; + public static bool RequiresInput(object? message) => + message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _)); - public static bool RequiresNothing(object? message) => message is ActionExecutorResult; + public static bool RequiresNothing(object? message) => + message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _)); private AzureAgentUsage AgentUsage => Throw.IfNull(this.Model.Agent, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}"); private AzureAgentInput? AgentInput => this.Model.Input; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index b1d9a44269..7540556f64 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -46,12 +46,14 @@ internal sealed class InvokeMcpToolExecutor( /// /// Determines if the message indicates external input is required. /// - public static bool RequiresInput(object? message) => message is ExternalInputRequest; + public static bool RequiresInput(object? message) => + message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _)); /// /// Determines if the message indicates no external input is required. /// - public static bool RequiresNothing(object? message) => message is ActionExecutorResult; + public static bool RequiresNothing(object? message) => + message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _)); /// protected override bool EmitResultEvent => false; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs new file mode 100644 index 0000000000..4ed50afb5a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit; + +/// +/// Tests that edge predicates correctly handle PortableValue-wrapped messages, +/// which occur after checkpoint restore (JSON round-trip). +/// +public sealed class PortableValuePredicateTests +{ + #region ActionExecutorResult.ThrowIfNot + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithDirectActionExecutorResult_ReturnsResult() + { + // Arrange + ActionExecutorResult result = new("test-executor"); + + // Act + ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(result); + + // Assert + actual.Should().BeSameAs(result); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedActionExecutorResult_Unwraps() + { + // Arrange + ActionExecutorResult result = new("test-executor"); + PortableValue wrapped = new(result); + + // Act + ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(wrapped); + + // Assert + actual.ExecutorId.Should().Be("test-executor"); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithNonActionExecutorResult_Throws() + { + // Arrange + object message = "not an ActionExecutorResult"; + + // Act & Assert + Assert.Throws(() => ActionExecutorResult.ThrowIfNot(message)); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithNull_Throws() + { + // Act & Assert + Assert.Throws(() => ActionExecutorResult.ThrowIfNot(null)); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedNonResult_Throws() + { + // Arrange + PortableValue wrapped = new("not an ActionExecutorResult"); + + // Act & Assert + Assert.Throws(() => ActionExecutorResult.ThrowIfNot(wrapped)); + } + + #endregion + + #region InvokeAzureAgentExecutor Predicates + + [Fact] + public void InvokeAzureAgentExecutor_RequiresInput_WithDirectExternalInputRequest_ReturnsTrue() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresInput(request).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + PortableValue wrapped = new(request); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresInput(wrapped).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresInput_WithActionExecutorResult_ReturnsFalse() + { + // Arrange + ActionExecutorResult result = new("test"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresInput(result).Should().BeFalse(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresNothing_WithDirectActionExecutorResult_ReturnsTrue() + { + // Arrange + ActionExecutorResult result = new("test"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresNothing(result).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue() + { + // Arrange + ActionExecutorResult result = new("test"); + PortableValue wrapped = new(result); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresNothing(wrapped).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresNothing_WithExternalInputRequest_ReturnsFalse() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresNothing(request).Should().BeFalse(); + } + + #endregion + + #region InvokeMcpToolExecutor Predicates + + [Fact] + public void InvokeMcpToolExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + PortableValue wrapped = new(request); + + // Act & Assert + InvokeMcpToolExecutor.RequiresInput(wrapped).Should().BeTrue(); + } + + [Fact] + public void InvokeMcpToolExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue() + { + // Arrange + ActionExecutorResult result = new("test"); + PortableValue wrapped = new(result); + + // Act & Assert + InvokeMcpToolExecutor.RequiresNothing(wrapped).Should().BeTrue(); + } + + #endregion + + #region QuestionExecutor.IsComplete + + [Fact] + public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NullResult_ReturnsTrue() + { + // Arrange - result with null Result property means "complete" + ActionExecutorResult result = new("test", result: null); + PortableValue wrapped = new(result); + + // Act & Assert + QuestionExecutor.IsComplete(wrapped).Should().BeTrue(); + } + + [Fact] + public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NonNullResult_ReturnsFalse() + { + // Arrange - result with non-null Result property means "not complete" + ActionExecutorResult result = new("test", result: true); + PortableValue wrapped = new(result); + + // Act & Assert + QuestionExecutor.IsComplete(wrapped).Should().BeFalse(); + } + + #endregion +}