diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 9d3b86535c..eb73146202 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -94,6 +94,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index e0425bfbec..108dca7682 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -219,6 +219,22 @@ internal static class FormulaValueExtensions elementType switch { null => FormulaValue.NewTable(RecordType.EmptySealed(), []), + _ when elementType == typeof(string) => + FormulaValue.NewSingleColumnTable([.. value.OfType().Select(element => FormulaValue.New(element))]), + _ when elementType == typeof(bool) => + FormulaValue.NewSingleColumnTable([.. value.OfType().Select(element => FormulaValue.New(element))]), + _ when elementType == typeof(int) => + FormulaValue.NewSingleColumnTable([.. value.OfType().Select(element => FormulaValue.New(element))]), + _ when elementType == typeof(long) => + FormulaValue.NewSingleColumnTable([.. value.OfType().Select(element => FormulaValue.New(element))]), + _ when elementType == typeof(decimal) => + FormulaValue.NewSingleColumnTable([.. value.OfType().Select(element => FormulaValue.New(element))]), + _ when elementType == typeof(float) => + FormulaValue.NewSingleColumnTable([.. value.OfType().Select(element => FormulaValue.New(element))]), + _ when elementType == typeof(DateTime) => + FormulaValue.NewSingleColumnTable([.. value.OfType().Select(element => FormulaValue.New(element))]), + _ when elementType == typeof(TimeSpan) => + FormulaValue.NewSingleColumnTable([.. value.OfType().Select(element => FormulaValue.New(element))]), _ when elementType == typeof(ExpandoObject) => FormulaValue.NewTable( value.ToTableType().ToRecord(), diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs index af1931d6d2..d3a4ef9cbc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs @@ -1,99 +1,277 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Frozen; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json; using Microsoft.Agents.AI.Workflows.Declarative.Kit; -using Microsoft.Bot.ObjectModel; -using Microsoft.PowerFx.Types; namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; internal static class JsonDocumentExtensions { - public static FrozenDictionary ParseRecord(this JsonDocument jsonDocument, VariableType recordType) => jsonDocument.RootElement.ParseRecord(recordType); - - public static RecordValue ParseRecord(this JsonDocument jsonDocument, RecordDataType recordType) => jsonDocument.RootElement.ParseRecord(recordType); - - private static FrozenDictionary ParseRecord(this JsonElement currentElement, VariableType recordType) + public static List ParseList(this JsonDocument jsonDocument, VariableType targetType) { - if (!recordType.IsRecord || recordType.Schema is null) + return + jsonDocument.RootElement.ValueKind switch + { + JsonValueKind.Array => jsonDocument.RootElement.ParseTable(targetType), + JsonValueKind.Object when targetType.HasSchema => [jsonDocument.RootElement.ParseRecord(targetType)], + JsonValueKind.Null => [], + _ => [jsonDocument.RootElement.ParseValue(targetType)], + }; + } + + public static Dictionary ParseRecord(this JsonDocument jsonDocument, VariableType targetType) + { + if (!targetType.IsRecord) { - throw new DeclarativeActionException($"Unable to parse JSON element as {recordType.Type.Name}."); + throw new DeclarativeActionException($"Unable to convert JSON to object with requested type {targetType.Type.Name}."); } - return ParseValues().ToFrozenDictionary(kvp => kvp.Key, kvp => kvp.Value); + return + jsonDocument.RootElement.ValueKind switch + { + JsonValueKind.Array when targetType.HasSchema => + ((Dictionary?)jsonDocument.RootElement.ParseTable(targetType).Single()) ?? [], + JsonValueKind.Object => jsonDocument.RootElement.ParseRecord(targetType), + JsonValueKind.Null => [], + _ => throw new DeclarativeActionException($"Unable to convert JSON to object with requested type {targetType.Type.Name}."), + }; + } + + private static Dictionary ParseRecord(this JsonElement currentElement, VariableType targetType) + { + if (targetType.Schema is null) + { + throw new DeclarativeActionException($"Object schema not defined for. {targetType.Type.Name}."); + } + + return ParseValues().ToDictionary(kvp => kvp.Key, kvp => kvp.Value); IEnumerable> ParseValues() { - foreach (KeyValuePair property in recordType.Schema) + foreach (KeyValuePair property in targetType.Schema) { - JsonElement propertyElement = currentElement.GetProperty(property.Key); - object? parsedValue = - property.Value?.Type switch + object? parsedValue = null; + if (!currentElement.TryGetProperty(property.Key, out JsonElement propertyElement)) + { + if (!property.Value.Type.IsNullable()) { - null => null, - _ when property.Value.Type == typeof(string) => propertyElement.GetString(), - _ when property.Value.Type == typeof(int) => propertyElement.GetInt32(), - _ when property.Value.Type == typeof(long) => propertyElement.GetInt64(), - _ when property.Value.Type == typeof(decimal) => propertyElement.GetDecimal(), - _ when property.Value.Type == typeof(double) => propertyElement.GetDouble(), - _ when property.Value.Type == typeof(bool) => propertyElement.GetBoolean(), - _ when property.Value.Type == typeof(DateTime) => propertyElement.GetDateTime(), - _ when property.Value.Type == typeof(TimeSpan) => propertyElement.GetDateTimeOffset().TimeOfDay, - _ when property.Value.IsRecord => propertyElement.ParseRecord(property.Value), - //TableDataType tableType => ParseTable(tableType, propertyElement), - _ => throw new InvalidOperationException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"), - }; + throw new DeclarativeActionException($"Property '{property.Key}' undefined and not nullable."); + } + } + else if (!propertyElement.TryParseValue(property.Value, out parsedValue)) + { + throw new DeclarativeActionException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"); + } + yield return new KeyValuePair(property.Key, parsedValue); } - - //static TableValue ParseTable(TableDataType tableType, JsonElement propertyElement) - //{ - // RecordDataType recordType = tableType.ToRecord(); - // return - // FormulaValue.NewTable( - // recordType.ToRecordType(), - // propertyElement.EnumerateArray().Select(tableElement => tableElement.ParseRecord(recordType))); - //} } } - private static RecordValue ParseRecord(this JsonElement currentElement, RecordDataType recordType) + private static List ParseTable(this JsonElement currentElement, VariableType targetType) { - return FormulaValue.NewRecordFromFields(ParseValues()); - - IEnumerable ParseValues() + if (!targetType.IsList) { - foreach (KeyValuePair property in recordType.Properties) + throw new DeclarativeActionException($"Unable to convert JSON to list as requested type {targetType.Type.Name}."); + } + + VariableType listType = DetermineElementType(); + + return + currentElement + .EnumerateArray() + .Select(element => element.ParseValue(listType)) + .ToList(); + + VariableType DetermineElementType() + { + Type? targetElementType = targetType.Type.GetElementType(); + VariableType? elementType = targetElementType is not null ? new(targetElementType) : null; + if (elementType is null) { - JsonElement propertyElement = currentElement.GetProperty(property.Key); - FormulaValue? parsedValue = - property.Value.Type switch + foreach (JsonElement element in currentElement.EnumerateArray()) + { + VariableType? currentType = + element.ValueKind switch + { + JsonValueKind.Object => VariableType.Record(targetType.Schema?.Select(kvp => (kvp.Key, kvp.Value)) ?? []), + JsonValueKind.String => typeof(string), + JsonValueKind.True => typeof(bool), + JsonValueKind.False => typeof(bool), + JsonValueKind.Number => typeof(decimal), + _ => null, + }; + + if (elementType is not null && currentType is not null && !elementType.Equals(currentType)) { - StringDataType => FormulaValue.New(propertyElement.GetString()), - NumberDataType => FormulaValue.New(propertyElement.GetDecimal()), - BooleanDataType => FormulaValue.New(propertyElement.GetBoolean()), - DateTimeDataType => FormulaValue.New(propertyElement.GetDateTime()), - DateDataType => FormulaValue.New(propertyElement.GetDateTime()), - TimeDataType => FormulaValue.New(propertyElement.GetDateTimeOffset().TimeOfDay), - RecordDataType recordType => propertyElement.ParseRecord(recordType), - TableDataType tableType => ParseTable(tableType, propertyElement), - _ => throw new InvalidOperationException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"), - }; - yield return new NamedValue(property.Key, parsedValue); + throw new DeclarativeActionException("Inconsistent element types in list."); + } + + elementType ??= currentType; + } } - static TableValue ParseTable(TableDataType tableType, JsonElement propertyElement) + return + elementType ?? + throw new DeclarativeActionException("Unable to determine element type for list."); + } + } + + private static object? ParseValue(this JsonElement propertyElement, VariableType targetType) + { + if (!propertyElement.TryParseValue(targetType, out object? value)) + { + throw new DeclarativeActionException($"Unable to parse {propertyElement.ValueKind} as '{targetType.Type.Name}'"); + } + + return value; + } + + private static bool TryParseValue(this JsonElement propertyElement, VariableType targetType, out object? value) => + propertyElement.ValueKind switch + { + JsonValueKind.String => TryParseString(propertyElement, targetType.Type, out value), + JsonValueKind.Number => TryParseNumber(propertyElement, targetType.Type, out value), + JsonValueKind.True or JsonValueKind.False => TryParseBoolean(propertyElement, out value), + JsonValueKind.Object => TryParseObject(propertyElement, targetType, out value), + JsonValueKind.Array => TryParseList(propertyElement, targetType, out value), + JsonValueKind.Null => TryParseNull(targetType.Type, out value), + _ => throw new DeclarativeActionException($"JSON element of type {propertyElement.ValueKind} is not supported."), + }; + + private static bool TryParseNull(Type valueType, out object? value) + { + // If the target type is not nullable, we cannot assign null to it + if (!valueType.IsNullable()) + { + value = null; + return false; + } + + value = null; + return true; + } + + private static bool TryParseBoolean(JsonElement propertyElement, out object? value) + { + try + { + value = propertyElement.GetBoolean(); + return true; + } + catch + { + value = null; + return false; + } + } + + private static bool TryParseString(JsonElement propertyElement, Type valueType, out object? value) + { + try + { + string? propertyValue = propertyElement.GetString(); + if (propertyValue is null) { - RecordDataType recordType = tableType.ToRecord(); - return - FormulaValue.NewTable( - recordType.ToRecordType(), - propertyElement.EnumerateArray().Select(tableElement => tableElement.ParseRecord(recordType))); + value = null; + return valueType.IsNullable(); // Parse fails if value is null and requested type is not. } + + switch (valueType) + { + case Type targetType when targetType == typeof(string): + value = propertyValue; + break; + case Type targetType when targetType == typeof(DateTime): + value = DateTime.Parse(propertyValue, provider: null, styles: DateTimeStyles.RoundtripKind); + break; + case Type targetType when targetType == typeof(TimeSpan): + value = TimeSpan.Parse(propertyValue); + break; + default: + value = null; + return false; + } + + return true; + } + catch + { + value = null; + return false; + } + } + + private static bool TryParseNumber(JsonElement element, Type valueType, out object? value) + { + // Try parsing as integer types first (most precise representation) + if (element.TryGetInt32(out int intValue)) + { + return ConvertToExpectedType(valueType, intValue, out value); + } + + if (element.TryGetInt64(out long longValue)) + { + return ConvertToExpectedType(valueType, longValue, out value); + } + + // Try decimal for precise decimal values + if (element.TryGetDecimal(out decimal decimalValue)) + { + return ConvertToExpectedType(valueType, decimalValue, out value); + } + + // Fall back to double for other numeric values + if (element.TryGetDouble(out double doubleValue)) + { + return ConvertToExpectedType(valueType, doubleValue, out value); + } + + value = null; + return false; + + static bool ConvertToExpectedType(Type valueType, object sourceValue, out object? value) + { + try + { + value = Convert.ChangeType(sourceValue, valueType); + return true; + } + catch + { + value = null; + return false; + } + } + } + + private static bool TryParseObject(JsonElement propertyElement, VariableType targetType, out object? value) + { + if (!targetType.HasSchema) + { + value = null; + return false; + } + + value = propertyElement.ParseRecord(targetType); + return true; + } + + private static bool TryParseList(JsonElement propertyElement, VariableType targetType, out object? value) + { + try + { + value = ParseTable(propertyElement, targetType); + return true; + } + catch + { + value = null; + return false; } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs index 0ce1e2a28e..4633c9cc4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs @@ -104,7 +104,17 @@ internal static class ObjectExtensions throw new DeclarativeActionException($"Unsupported type: '{targetType.Type.Name}'."); } - if (sourceValue != null && targetType.Type.IsAssignableFrom(sourceValue.GetType())) + if (sourceValue is null) + { + return null; + } + + Type sourceType = sourceValue.GetType(); + + // Converting string to list requires explicit conversion. + // Avoid short-circuit based on string is IEnumerable + if ((sourceType != typeof(string) || !targetType.IsList) && + targetType.Type.IsAssignableFrom(sourceType)) { return sourceValue; } @@ -226,7 +236,7 @@ internal static class ObjectExtensions sourceValue switch { null => null, - //string jsonText => JsonDocument.Parse(jsonText.TrimJsonDelimiter()).ParseRecord(targetType), + string jsonText => JsonDocument.Parse(jsonText.TrimJsonDelimiter()).ParseList(targetType), _ => throw new DeclarativeActionException($"Cannot convert '{sourceValue?.GetType().Name}' to 'Record' (expected JSON string)."), }; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/TypeExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/TypeExtensions.cs new file mode 100644 index 0000000000..1447b497dc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/TypeExtensions.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; + +internal static class TypeExtensions +{ + public static bool IsNullable(this Type type) + { + if (!type.IsValueType) + { + return true; // Reference types are nullable + } + + return Nullable.GetUnderlyingType(type) != null; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/VariableType.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/VariableType.cs index 310d96f7a3..4be5849c89 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/VariableType.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/VariableType.cs @@ -4,6 +4,7 @@ using System; using System.Collections; using System.Collections.Frozen; using System.Collections.Generic; +using System.Linq; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; @@ -13,10 +14,11 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Kit; /// Describes an allowed declarative variable/type used in workflow configuration (primitives, lists, or record-like objects). /// A record is modeled as IDictionary<string, VariableType?> along with an immutable schema for its fields. /// -public sealed class VariableType +public sealed class VariableType : IEquatable { // Canonical CLR type used to mark a "record" (object with named fields and per-field types). internal static readonly Type RecordType = typeof(IDictionary); + // Any list of primitive values or records. internal static readonly Type ListType = typeof(IEnumerable); @@ -50,13 +52,26 @@ public sealed class VariableType /// /// Returns true if the provided CLR is one of the supported root types. /// - public static bool IsValid(Type type) => s_supportedTypes.Contains(type); + public static bool IsValid(Type type) => + s_supportedTypes.Contains(type) || + ListType.IsAssignableFrom(type) || + RecordType.IsAssignableFrom(type); + + /// + /// Creates a list (object) variable type with the supplied schema. + /// Each tuple's Key is the field name; Type is the declared VariableType (nullable to allow "unknown"/late binding). + /// + public static VariableType List(params IEnumerable<(string Key, VariableType Type)> fields) => + new(typeof(IEnumerable)) + { + Schema = fields.ToFrozenDictionary(kv => kv.Key, kv => kv.Type), + }; /// /// Creates a record (object) variable type with the supplied schema. /// Each tuple's Key is the field name; Type is the declared VariableType (nullable to allow "unknown"/late binding). /// - public static VariableType Record(params IEnumerable<(string Key, VariableType? Type)> fields) => + public static VariableType Record(params IEnumerable<(string Key, VariableType Type)> fields) => new(typeof(IDictionary)) { Schema = fields.ToFrozenDictionary(kv => kv.Key, kv => kv.Type), @@ -78,9 +93,9 @@ public sealed class VariableType this.Schema = CreateSchema(tableDataType.Properties); } - static FrozenDictionary CreateSchema(IEnumerable> properties) + static FrozenDictionary CreateSchema(IEnumerable> properties) { - Dictionary schema = []; + Dictionary schema = []; foreach (KeyValuePair field in properties) { @@ -104,7 +119,7 @@ public sealed class VariableType } /// - /// The underlying CLR type that categorizes this variable (primitive, list, or record sentinel type). + /// The underlying CLR type that categorizes this variable (primitive, list, or record type). /// public Type Type { get; } @@ -112,15 +127,20 @@ public sealed class VariableType /// Schema for record types: immutable mapping of field name to field VariableType (null means unspecified). /// Null for non-record VariableTypes. /// - public FrozenDictionary? Schema { get; init; } + public FrozenDictionary? Schema { get; init; } /// /// True if this instance represents a record/object with a field schema. /// - public bool IsList => ListType.IsAssignableFrom(this.Type); + public bool HasSchema => (this.Schema?.Count ?? 0) > 0; /// - /// True if this instance represents a record/object with a field schema. + /// True if this instance represents a list + /// + public bool IsList => !this.IsRecord && ListType.IsAssignableFrom(this.Type); + + /// + /// True if this instance represents a record/object /// public bool IsRecord => RecordType.IsAssignableFrom(this.Type); @@ -128,4 +148,28 @@ public sealed class VariableType /// Instance convenience wrapper for on this VariableType's underlying CLR type. /// public bool IsValid() => IsValid(this.Type); + + /// + public override bool Equals(object? obj) => + obj switch + { + null => false, + Type type => this.Type == type, + VariableType other => this.Equals(other), + _ => false, + }; + + /// + public override int GetHashCode() => HashCode.Combine(this.Type.GetHashCode(), this.Schema?.GetHashCode() ?? 0); + + /// + public bool Equals(VariableType? other) => + other is not null && + this.Type == other.Type && + this.Schema switch + { + null => other.Schema is null, + _ when other.Schema is null => false, + _ => this.Schema.Count == other.Schema.Count && this.Schema.Union(other.Schema).Count() == this.Schema.Count, + }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 7d7aee5418..ae650352c5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -150,12 +150,14 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow [InlineData("EditTable.yaml", 2, "edit_var")] [InlineData("EditTableV2.yaml", 2, "edit_var")] [InlineData("ParseValue.yaml", 2, "parse_var")] + [InlineData("ParseValueList.yaml", 2, "parse_var")] [InlineData("SendActivity.yaml", 2, "activity_input")] [InlineData("SetVariable.yaml", 1, "set_var")] [InlineData("SetTextVariable.yaml", 1, "set_text")] [InlineData("ClearAllVariables.yaml", 1, "clear_all")] [InlineData("ResetVariable.yaml", 2, "clear_var")] [InlineData("MixedScopes.yaml", 2, "activity_input")] + [InlineData("CaseInsensitive.yaml", 6, "end_when_match")] public async Task ExecuteActionAsync(string workflowFile, int expectedCount, string expectedId) { await this.RunWorkflowAsync(workflowFile); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs new file mode 100644 index 0000000000..8c27bf6c8c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions; + +public sealed class JsonDocumentExtensionsTests +{ + [Fact] + public void ParseRecord_Object_PrimitiveFields_Succeeds() + { + // Arrange + VariableType recordType = + VariableType.Record( + [ + ("text", typeof(string)), + ("numberInt", typeof(int)), + ("numberLong", typeof(long)), + ("numberDecimal", typeof(decimal)), + ("numberDouble", typeof(double)), + ("flag", typeof(bool)), + ("date", typeof(DateTime)), + ("time", typeof(TimeSpan)) + ]); + + DateTime expectedDateTime = new(2024, 10, 01, 12, 34, 56, DateTimeKind.Utc); + TimeSpan expectedTimeSpan = new(12, 34, 56); + + JsonDocument document = JsonDocument.Parse( + """ + { + "text": "hello", + "numberInt": 7, + "numberLong": 9223372036854775807, + "numberDecimal": 12.5, + "numberDouble": 3.99E99, + "flag": true, + "date": "2024-10-01T12:34:56Z", + "time": "12:34:56" + } + """); + + // Act + Dictionary result = document.ParseRecord(recordType); + + // Assert + Assert.Equal("hello", result["text"]); + Assert.Equal(7, result["numberInt"]); + Assert.Equal(9223372036854775807L, result["numberLong"]); + Assert.Equal(12.5m, result["numberDecimal"]); + Assert.Equal(3.99E99, result["numberDouble"]); + Assert.Equal(true, result["flag"]); + Assert.Equal(expectedDateTime, result["date"]); + Assert.Equal(expectedTimeSpan, result["time"]); + } + + [Fact] + public void ParseRecord_Object_NestedRecord_Succeeds() + { + // Arrange + VariableType innerRecord = + VariableType.Record( + [ + ("innerText", typeof(string)), + ("innerNumber", typeof(int)) + ]); + + VariableType outerRecord = + VariableType.Record( + [ + ("outerText", typeof(string)), + ("nested", innerRecord) + ]); + + JsonDocument document = JsonDocument.Parse( + """ + { + "outerText": "outer", + "nested": { + "innerText": "inner", + "innerNumber": 42 + } + } + """); + + // Act + Dictionary result = document.ParseRecord(outerRecord); + + // Assert + Assert.Equal("outer", result["outerText"]); + Dictionary nested = (Dictionary)result["nested"]!; + Assert.NotNull(nested); + Assert.True(nested.ContainsKey("innerText")); + Assert.Equal("inner", nested["innerText"]); + Assert.Equal(42, nested["innerNumber"]); + } + + [Fact] + public void ParseRecord_NullRoot_ReturnsEmpty() + { + // Arrange + VariableType recordType = + VariableType.Record( + [ + ("text", typeof(string)) + ]); + + JsonDocument document = JsonDocument.Parse("null"); + + // Act + Dictionary result = document.ParseRecord(recordType); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void ParseRecord_ArrayWithSingleRecord_Succeeds() + { + // Arrange + VariableType listType = + VariableType.List( + [ + ("name", typeof(string)), + ("value", typeof(int)) + ]); + + JsonDocument document = JsonDocument.Parse( + """ + [ + { + "name": "item", + "value": 5 + } + ] + """); + + // Act + List result = document.ParseList(listType); + + // Assert + Assert.Single(result); + Dictionary element = Assert.IsType>(result[0]); + Assert.Equal("item", element["name"]); + Assert.Equal(5, element["value"]); + } + + [Fact] + public void ParseRecord_ArrayWithMultipleRecords_Throws() + { + // Arrange + VariableType recordType = + VariableType.Record( + [ + ("id", typeof(int)) + ]); + + JsonDocument document = JsonDocument.Parse( + """ + [ + { "id": 1 }, + { "id": 2 } + ] + """); + + // Act / Assert + Assert.Throws(() => document.ParseRecord(recordType)); + } + + [Fact] + public void ParseRecord_InvalidTargetType_Throws() + { + // Arrange + VariableType notARecord = typeof(string); + JsonDocument document = JsonDocument.Parse( + """ + { "x": 1 } + """); + + // Act / Assert + Assert.Throws(() => document.ParseRecord(notARecord)); + } + + [Fact] + public void ParseRecord_InvalidRootKind_Throws() + { + // Arrange + VariableType recordType = + VariableType.Record( + [ + ("text", typeof(string)) + ]); + + JsonDocument document = JsonDocument.Parse(@"""not-an-object"""); + + // Act / Assert + Assert.Throws(() => document.ParseRecord(recordType)); + } + + [Fact] + public void ParseRecord_UnsupportedPropertyType_Throws() + { + // Arrange + VariableType recordType = + VariableType.Record( + [ + ("unsupported", typeof(Guid)) + ]); + + JsonDocument document = JsonDocument.Parse( + """ + { "unsupported": "C2556C11-210E-4BB6-BF18-4A8968CB45A8" } + """); + + // Act / Assert + Assert.Throws(() => document.ParseRecord(recordType)); + } + + [Fact] + public void ParseRecord_MissingRequiredProperty_Throws() + { + // Arrange + VariableType recordType = + VariableType.Record( + [ + ("required", typeof(bool)) + ]); + + JsonDocument document = JsonDocument.Parse("{}"); + + // Act / Assert + Assert.Throws(() => document.ParseRecord(recordType)); + } + + [Fact] + public void ParseRecord_MissingNullableProperty_Succeeds() + { + // Arrange + VariableType recordType = + VariableType.Record( + [ + ("required", typeof(string)) + ]); + + JsonDocument document = JsonDocument.Parse("{}"); + + // Act + Dictionary result = document.ParseRecord(recordType); + + // Assert + Assert.Single(result); + Dictionary element = Assert.IsType>(result); + Assert.Null(element["required"]); + } + + [Fact] + public void ParseList_NullRoot_ReturnsEmpty() + { + // Arrange + JsonDocument document = JsonDocument.Parse("null"); + + // Act + List result = document.ParseList(typeof(int[])); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void ParseList_Array_Primitives_Succeeds() + { + // Arrange + JsonDocument document = JsonDocument.Parse("[1,2,3]"); + + // Act + List result = document.ParseList(typeof(int[])); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal(1, result[0]); + Assert.Equal(2, result[1]); + Assert.Equal(3, result[2]); + } + + [Fact] + public void ParseList_PrimitiveRoot_WrappedAsSingleElement_Succeeds() + { + // Arrange + JsonDocument document = JsonDocument.Parse("7"); + + // Act + List result = document.ParseList(typeof(int)); + + // Assert + Assert.Single(result); + Assert.Equal(7, result[0]); + } + + [Fact] + public void ParseList_Array_Records_Succeeds() + { + // Arrange + VariableType listType = + VariableType.List( + [ + ("id", typeof(int)), + ("name", typeof(string)) + ]); + JsonDocument document = JsonDocument.Parse( + """ + [ + { "id": 1, "name": "a" }, + { "id": 2, "name": "b" } + ] + """); + + // Act + List result = document.ParseList(listType); + + // Assert + Assert.Equal(2, result.Count); + Dictionary first = (Dictionary)result[0]!; + Dictionary second = (Dictionary)result[1]!; + Assert.NotNull(first); + Assert.Equal(1, first["id"]); + Assert.Equal("a", first["name"]); + Assert.NotNull(second); + Assert.Equal(2, second["id"]); + Assert.Equal("b", second["name"]); + } + + [Fact] + public void ParseList_InvalidTargetType_Throws() + { + // Arrange + JsonDocument document = JsonDocument.Parse("[1,2]"); + + // Act / Assert + Assert.Throws(() => document.ParseList(typeof(int))); + } + + [Fact] + public void ParseList_Array_MixedTypes_Throws() + { + // Arrange + JsonDocument document = JsonDocument.Parse("[1,\"two\",3]"); + + // Act / Assert + Assert.Throws(() => document.ParseList(typeof(int[]))); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TypeExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TypeExtensionsTests.cs new file mode 100644 index 0000000000..a8ba35e496 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TypeExtensionsTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions; + +public sealed class TypeExtensionsTests +{ + [Fact] + public void ReferenceType() => VerifyIsNullable(typeof(string)); + + [Fact] + public void ClassType() => VerifyIsNullable(typeof(object)); + + [Fact] + public void InterfaceType() => VerifyIsNullable(typeof(IDisposable)); + + [Fact] + public void ArrayType() => VerifyIsNullable(typeof(int[])); + + [Fact] + public void NonNullableValueType() => VerifyNotNullable(typeof(int)); + + [Fact] + public void NonNullableStructType() => VerifyNotNullable(typeof(DateTime)); + + [Fact] + public void NonNullableEnumType() => VerifyNotNullable(typeof(DayOfWeek)); + + [Fact] + public void NullableInt() => VerifyIsNullable(typeof(int?)); + + [Fact] + public void NullableDateTime() => VerifyIsNullable(typeof(DateTime?)); + + [Fact] + public void NullableEnum() => VerifyIsNullable(typeof(DayOfWeek?)); + + [Fact] + public void NullableCustomStruct() => VerifyIsNullable(typeof(TestStruct?)); + + private static void VerifyNotNullable(Type targetType) + { + // Act + bool result = targetType.IsNullable(); + + // Assert + Assert.False(result); + } + + private static void VerifyIsNullable(Type targetType) + { + // Act + bool result = targetType.IsNullable(); + + // Assert + Assert.True(result); + } + + private struct TestStruct + { + public int Value { get; set; } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/VariableTypeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/VariableTypeTests.cs new file mode 100644 index 0000000000..a26220eb2d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/VariableTypeTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit; + +public sealed class VariableTypeTests +{ + [Fact] + public void IsValidPrimitivesReturnTrue() + { + Assert.True(VariableType.IsValid()); + Assert.True(VariableType.IsValid()); + Assert.True(VariableType.IsValid()); + Assert.True(VariableType.IsValid()); + Assert.True(VariableType.IsValid()); + Assert.True(VariableType.IsValid()); + Assert.True(VariableType.IsValid()); + Assert.True(VariableType.IsValid()); + Assert.True(VariableType.IsValid()); + } + + [Fact] + public void IsValidUnsupportedTypeReturnFalse() + { + Assert.False(VariableType.IsValid()); + Assert.False(VariableType.IsValid()); + } + + [Fact] + public void IsListForListTypeReturnTrue() + { + VariableType listType = new(typeof(List)); + Assert.True(listType.IsList); + Assert.False(listType.IsRecord); + Assert.True(listType.IsValid()); + } + + [Fact] + public void IsRecordForDictionaryInterfaceReturnTrue() + { + VariableType recordType = new(typeof(IDictionary)); + Assert.True(recordType.IsRecord); + Assert.False(recordType.IsList); + Assert.True(recordType.IsValid()); + } + + [Fact] + public void RecordFactoryCreatesSchema() + { + // Assuming the intended signature supports tuple params; adjust if needed. + VariableType nameType = new(typeof(string)); + VariableType ageType = new(typeof(int)); + + // If the actual signature differs (params IEnumerable<...>), adapt test accordingly. + VariableType recordType = VariableType.Record( + [("name", nameType), ("age", ageType)] + ); + + Assert.True(recordType.IsRecord); + Assert.True(recordType.HasSchema); + Assert.NotNull(recordType.Schema); + Assert.Equal(2, recordType.Schema.Count); + Assert.True(recordType.Schema.ContainsKey("name")); + Assert.True(recordType.Schema.ContainsKey("age")); + Assert.Equal(typeof(string), recordType.Schema["name"].Type); + Assert.Equal(typeof(int), recordType.Schema["age"].Type); + } + + [Fact] + public void EqualsPrimitiveTypeEquality() + { + VariableType t1 = new(typeof(int)); + VariableType t2 = new(typeof(int)); + VariableType t3 = new(typeof(string)); + + Assert.True(t1.Equals(t2)); + Assert.True(t1.Equals(typeof(int))); + Assert.False(t1.Equals(t3)); + Assert.False(t1.Equals(typeof(string))); + } + + [Fact] + public void EqualsRecordEqualityIgnoresOrder() + { + VariableType strType = new(typeof(string)); + VariableType intType = new(typeof(int)); + + VariableType recordA = VariableType.Record( + [("first", strType), ("second", intType)] + ); + VariableType recordB = VariableType.Record( + [("second", intType), ("first", strType)] + ); + + Assert.True(recordA.Equals(recordB)); + Assert.True(recordB.Equals(recordA)); + } + + [Fact] + public void EqualsRecordInequalityDifferentSchema() + { + VariableType strType = new(typeof(string)); + VariableType intType = new(typeof(int)); + + VariableType recordA = VariableType.Record( + [("first", strType), ("second", intType)] + ); + VariableType recordB = VariableType.Record( + [("first", strType)] + ); + + Assert.False(recordA.Equals(recordB)); + Assert.False(recordB.Equals(recordA)); + } + + [Fact] + public void GetHashCodePrimitiveConsistency() + { + VariableType a = new(typeof(double)); + VariableType b = new(typeof(double)); + Assert.Equal(a, b); + Assert.Equal(a, typeof(double)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCodeRecordConsistency() + { + VariableType a = VariableType.Record(("a", typeof(string)), ("b", typeof(int))); + VariableType b = VariableType.Record(("a", typeof(string)), ("b", typeof(int))); + Assert.Equal(a, b); + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void HasSchemaFalseForNonRecord() + { + VariableType primitive = new(typeof(int)); + Assert.False(primitive.HasSchema); + } + + [Fact] + public void ImplicitOperatorFromTypeWrapsCorrectly() + { + VariableType vt = typeof(string); + Assert.Equal(typeof(string), vt.Type); + Assert.True(vt.IsValid()); + } + + [Fact] + public void EqualsNullAndDifferentTypes() + { + VariableType vt = new(typeof(int)); + VariableType? nullType = null; + object? nullObj = null; + object different = "test"; + + Assert.False(vt.Equals(nullObj)); + Assert.False(vt.Equals(nullType)); + Assert.False(vt.Equals(different)); + Assert.True(vt.Equals((object)typeof(int))); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs index 170e27e80e..e7ccdede61 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs @@ -13,6 +13,33 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; /// public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) { + [Fact] + public async Task ParseRecordAsync() + { + // Arrange + RecordDataType.Builder recordBuilder = + new() + { + Properties = + { + {"key1", new PropertyInfo.Builder() { Type = DataType.String } }, + } + }; + ParseValue model = + this.CreateModel( + this.FormatDisplayName(nameof(ParseRecordAsync)), + recordBuilder, + @"{ ""key1"": ""val1"" }"); + + // Act + ParseValueExecutor action = new(model, this.State); + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + this.VerifyState("Target", FormulaValue.NewRecordFromFields(new NamedValue("key1", FormulaValue.New("val1")))); + } + [Fact] public async Task ParseTableAsync() { @@ -28,8 +55,8 @@ public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowA ParseValue model = this.CreateModel( this.FormatDisplayName(nameof(ParseTableAsync)), - recordBuilder, - @"{ ""key1"": ""val1"" }"); + DataType.EmptyTable, + @"[""apple"",""banana"",""cat""]"); // Act ParseValueExecutor action = new(model, this.State); @@ -37,7 +64,7 @@ public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowA // Assert VerifyModel(model, action); - this.VerifyState("Target", FormulaValue.NewRecordFromFields(new NamedValue("key1", FormulaValue.New("val1")))); + this.VerifyState("Target", FormulaValue.NewSingleColumnTable(FormulaValue.New("apple"), FormulaValue.New("banana"), FormulaValue.New("cat"))); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs index 9b667b55f7..686518b562 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs @@ -10,6 +10,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; using Xunit.Abstractions; +using Xunit.Sdk; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; @@ -33,6 +34,18 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync(); Assert.Contains(events, e => e is DeclarativeActionInvokedEvent); Assert.Contains(events, e => e is DeclarativeActionCompletedEvent); + ExecutorFailedEvent[] failureEvents = events.OfType().ToArray(); + switch (failureEvents.Length) + { + case 0: + break; + case 1: + throw failureEvents[0].Data ?? new XunitException("Executor failed without exception data."); + default: + AggregateException aggregateException = new("One or more executor failures occurred.", failureEvents.Select(e => e.Data).Where(e => e is not null).Cast()); + throw aggregateException; + + } return events; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CaseInsensitive.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CaseInsensitive.yaml new file mode 100644 index 0000000000..b6b76c6957 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/CaseInsensitive.yaml @@ -0,0 +1,29 @@ +kind: WORKFLOW +trigger: + + kind: onconversationstart + id: my_workflow + actions: + + - kind: SETVARIABLE + id: set_input1 + variable: Local.TestValue1 + value: =3 + + - kind: setvariable + id: set_input2 + variable: Local.TestValue2 + value: =4 + + - kind: ConditionGroup + id: condition_test + conditions: + - id: condition_match + condition: =Local.TestValue1 + Local.TestValue2 = 7 + actions: + - kind: EndDialog + id: end_when_match + + - kind: SendActivity + id: activity_error + activity: Unexpected diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValueList.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValueList.yaml new file mode 100644 index 0000000000..b3dc75f2d0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/ParseValueList.yaml @@ -0,0 +1,17 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: my_workflow + + actions: + - kind: SetVariable + id: set_var + variable: Local.MySource + value: '["apple","banana","cat"]' + + - kind: ParseValue + id: parse_var + variable: Local.MyVar + value: =Local.MySource + valueType: Table diff --git a/workflow-samples/ParseValue.yaml b/workflow-samples/ParseValue.yaml new file mode 100644 index 0000000000..ce41ccf610 --- /dev/null +++ b/workflow-samples/ParseValue.yaml @@ -0,0 +1,39 @@ +kind: Workflow +trigger: + kind: OnConversationStart + id: workflow_demo + actions: + + - kind: SetVariable + id: set-variable-text + variable: Local.ItemsText + value: '["apple","banana","cat"]' + + + - kind: SendActivity + id: display-text + activity: |- + (1) + {Local.ItemsText} + + - kind: ParseValue + id: parse-list + variable: Local.ItemsList + value: =Local.ItemsText + valueType: Table + + - kind: SendActivity + id: display-list + activity: |- + (2) + {Local.ItemsList} + + - kind: Foreach + id: loop-list + items: =Local.ItemsList + value: Local.LoopValue + index: Local.LoopIndex + actions: + - kind: SendActivity + id: display-list-item + activity: "{Local.LoopValue} (x{Local.LoopIndex + 1})"