.NET Workflows - Fix ability of ParseValue action to process list/table types. (#1577)

* Initial plan

* Add test classes for extension methods

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Fix test issues and document bug in ExpandoObjectExtensions

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Address code review feedback - shorten Skip messages and add explanatory comments

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Replace Fields.ToDictionary with GetField calls and fix ExpandoObjectExtensions bug

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Update dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/DataValueExtensionsTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/DataValueExtensionsTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove unused using statement from DialogBaseExtensionsTests

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Add proper WrapWithBot tests using AdaptiveDialog and OnActivity

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Cleanup

* Better

* Better

* One more test

* Checkpoint

* Checkpoint

* Finally

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Chris
2025-10-20 19:45:07 -07:00
committed by GitHub
Unverified
parent e8a7d3b1b7
commit 731e3a7633
15 changed files with 1056 additions and 76 deletions
+1
View File
@@ -94,6 +94,7 @@
<File Path="../workflow-samples/HumanInLoop.yaml" />
<File Path="../workflow-samples/Marketing.yaml" />
<File Path="../workflow-samples/MathChat.yaml" />
<File Path="../workflow-samples/ParseValue.yaml" />
<File Path="../workflow-samples/README.md" />
<File Path="../workflow-samples/wttr.json" />
</Folder>
@@ -219,6 +219,22 @@ internal static class FormulaValueExtensions
elementType switch
{
null => FormulaValue.NewTable(RecordType.EmptySealed(), []),
_ when elementType == typeof(string) =>
FormulaValue.NewSingleColumnTable([.. value.OfType<string>().Select(element => FormulaValue.New(element))]),
_ when elementType == typeof(bool) =>
FormulaValue.NewSingleColumnTable([.. value.OfType<bool>().Select(element => FormulaValue.New(element))]),
_ when elementType == typeof(int) =>
FormulaValue.NewSingleColumnTable([.. value.OfType<int>().Select(element => FormulaValue.New(element))]),
_ when elementType == typeof(long) =>
FormulaValue.NewSingleColumnTable([.. value.OfType<long>().Select(element => FormulaValue.New(element))]),
_ when elementType == typeof(decimal) =>
FormulaValue.NewSingleColumnTable([.. value.OfType<decimal>().Select(element => FormulaValue.New(element))]),
_ when elementType == typeof(float) =>
FormulaValue.NewSingleColumnTable([.. value.OfType<float>().Select(element => FormulaValue.New(element))]),
_ when elementType == typeof(DateTime) =>
FormulaValue.NewSingleColumnTable([.. value.OfType<DateTime>().Select(element => FormulaValue.New(element))]),
_ when elementType == typeof(TimeSpan) =>
FormulaValue.NewSingleColumnTable([.. value.OfType<TimeSpan>().Select(element => FormulaValue.New(element))]),
_ when elementType == typeof(ExpandoObject) =>
FormulaValue.NewTable(
value.ToTableType().ToRecord(),
@@ -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<string, object?> 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<string, object?> ParseRecord(this JsonElement currentElement, VariableType recordType)
public static List<object?> 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<string, object?> 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<string, object?>?)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<string, object?> 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<KeyValuePair<string, object?>> ParseValues()
{
foreach (KeyValuePair<string, VariableType?> property in recordType.Schema)
foreach (KeyValuePair<string, VariableType> 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<string, object?>(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<object?> ParseTable(this JsonElement currentElement, VariableType targetType)
{
return FormulaValue.NewRecordFromFields(ParseValues());
IEnumerable<NamedValue> ParseValues()
if (!targetType.IsList)
{
foreach (KeyValuePair<string, PropertyInfo> 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;
}
}
}
@@ -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<char>
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)."),
};
@@ -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;
}
}
@@ -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&lt;string, VariableType?&gt; along with an immutable schema for its fields.
/// </summary>
public sealed class VariableType
public sealed class VariableType : IEquatable<VariableType>
{
// Canonical CLR type used to mark a "record" (object with named fields and per-field types).
internal static readonly Type RecordType = typeof(IDictionary<string, object?>);
// Any list of primitive values or records.
internal static readonly Type ListType = typeof(IEnumerable);
@@ -50,13 +52,26 @@ public sealed class VariableType
/// <summary>
/// Returns true if the provided CLR <paramref name="type"/> is one of the supported root types.
/// </summary>
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);
/// <summary>
/// Creates a list (object) variable type with the supplied <paramref name="fields"/> schema.
/// Each tuple's Key is the field name; Type is the declared VariableType (nullable to allow "unknown"/late binding).
/// </summary>
public static VariableType List(params IEnumerable<(string Key, VariableType Type)> fields) =>
new(typeof(IEnumerable))
{
Schema = fields.ToFrozenDictionary(kv => kv.Key, kv => kv.Type),
};
/// <summary>
/// Creates a record (object) variable type with the supplied <paramref name="fields"/> schema.
/// Each tuple's Key is the field name; Type is the declared VariableType (nullable to allow "unknown"/late binding).
/// </summary>
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<string, object?>))
{
Schema = fields.ToFrozenDictionary(kv => kv.Key, kv => kv.Type),
@@ -78,9 +93,9 @@ public sealed class VariableType
this.Schema = CreateSchema(tableDataType.Properties);
}
static FrozenDictionary<string, VariableType?> CreateSchema(IEnumerable<KeyValuePair<string, PropertyInfo>> properties)
static FrozenDictionary<string, VariableType> CreateSchema(IEnumerable<KeyValuePair<string, PropertyInfo>> properties)
{
Dictionary<string, VariableType?> schema = [];
Dictionary<string, VariableType> schema = [];
foreach (KeyValuePair<string, PropertyInfo> field in properties)
{
@@ -104,7 +119,7 @@ public sealed class VariableType
}
/// <summary>
/// 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).
/// </summary>
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.
/// </summary>
public FrozenDictionary<string, VariableType?>? Schema { get; init; }
public FrozenDictionary<string, VariableType>? Schema { get; init; }
/// <summary>
/// True if this instance represents a record/object with a field schema.
/// </summary>
public bool IsList => ListType.IsAssignableFrom(this.Type);
public bool HasSchema => (this.Schema?.Count ?? 0) > 0;
/// <summary>
/// True if this instance represents a record/object with a field schema.
/// True if this instance represents a list
/// </summary>
public bool IsList => !this.IsRecord && ListType.IsAssignableFrom(this.Type);
/// <summary>
/// True if this instance represents a record/object
/// </summary>
public bool IsRecord => RecordType.IsAssignableFrom(this.Type);
@@ -128,4 +148,28 @@ public sealed class VariableType
/// Instance convenience wrapper for <see cref="IsValid(Type)"/> on this VariableType's underlying CLR type.
/// </summary>
public bool IsValid() => IsValid(this.Type);
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj switch
{
null => false,
Type type => this.Type == type,
VariableType other => this.Equals(other),
_ => false,
};
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.Type.GetHashCode(), this.Schema?.GetHashCode() ?? 0);
/// <inheritdoc/>
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,
};
}
@@ -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);
@@ -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<string, object?> 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<string, object?> result = document.ParseRecord(outerRecord);
// Assert
Assert.Equal("outer", result["outerText"]);
Dictionary<string, object?> nested = (Dictionary<string, object?>)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<string, object?> 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<object?> result = document.ParseList(listType);
// Assert
Assert.Single(result);
Dictionary<string, object?> element = Assert.IsType<Dictionary<string, object?>>(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<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_InvalidTargetType_Throws()
{
// Arrange
VariableType notARecord = typeof(string);
JsonDocument document = JsonDocument.Parse(
"""
{ "x": 1 }
""");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => 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<DeclarativeActionException>(() => 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<DeclarativeActionException>(() => 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<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_MissingNullableProperty_Succeeds()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("required", typeof(string))
]);
JsonDocument document = JsonDocument.Parse("{}");
// Act
Dictionary<string, object?> result = document.ParseRecord(recordType);
// Assert
Assert.Single(result);
Dictionary<string, object?> element = Assert.IsType<Dictionary<string, object?>>(result);
Assert.Null(element["required"]);
}
[Fact]
public void ParseList_NullRoot_ReturnsEmpty()
{
// Arrange
JsonDocument document = JsonDocument.Parse("null");
// Act
List<object?> 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<object?> 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<object?> 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<object?> result = document.ParseList(listType);
// Assert
Assert.Equal(2, result.Count);
Dictionary<string, object?> first = (Dictionary<string, object?>)result[0]!;
Dictionary<string, object?> second = (Dictionary<string, object?>)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<DeclarativeActionException>(() => document.ParseList(typeof(int)));
}
[Fact]
public void ParseList_Array_MixedTypes_Throws()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[1,\"two\",3]");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseList(typeof(int[])));
}
}
@@ -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; }
}
}
@@ -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<bool>());
Assert.True(VariableType.IsValid<int>());
Assert.True(VariableType.IsValid<long>());
Assert.True(VariableType.IsValid<float>());
Assert.True(VariableType.IsValid<decimal>());
Assert.True(VariableType.IsValid<double>());
Assert.True(VariableType.IsValid<string>());
Assert.True(VariableType.IsValid<DateTime>());
Assert.True(VariableType.IsValid<TimeSpan>());
}
[Fact]
public void IsValidUnsupportedTypeReturnFalse()
{
Assert.False(VariableType.IsValid<Guid>());
Assert.False(VariableType.IsValid<Uri>());
}
[Fact]
public void IsListForListTypeReturnTrue()
{
VariableType listType = new(typeof(List<int>));
Assert.True(listType.IsList);
Assert.False(listType.IsRecord);
Assert.True(listType.IsValid());
}
[Fact]
public void IsRecordForDictionaryInterfaceReturnTrue()
{
VariableType recordType = new(typeof(IDictionary<string, object?>));
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)));
}
}
@@ -13,6 +13,33 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// </summary>
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]
@@ -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<ExecutorFailedEvent>().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<Exception>());
throw aggregateException;
}
return events;
}
@@ -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
@@ -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
+39
View File
@@ -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})"