diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs index 7ef09d2b850..3e883a1f6c4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs @@ -48,26 +48,33 @@ private static TableValue ToTable(this PortableValue[] values) if (formulaValues[0] is RecordValue recordValue) { - return FormulaValue.NewTable(ParseRecordType(recordValue), formulaValues.OfType()); + return FormulaValue.NewTable(ParseRecordType(recordValue), formulaValues.OfType().ToArray()); } - return - formulaValues[0] switch - { - PrimitiveValue => NewSingleColumnTable(), - PrimitiveValue => NewSingleColumnTable(), - PrimitiveValue => NewSingleColumnTable(), - PrimitiveValue => NewSingleColumnTable(), - PrimitiveValue => NewSingleColumnTable(), - PrimitiveValue => NewSingleColumnTable(), - PrimitiveValue => NewSingleColumnTable(), - PrimitiveValue => NewSingleColumnTable(), - PrimitiveValue => NewSingleColumnTable(), - _ => throw new DeclarativeModelException($"Unsupported table element type: {formulaValues[0].Type.GetType().Name}"), - }; + FormulaType elementType = formulaValues[0] switch + { + PrimitiveValue + or PrimitiveValue + or PrimitiveValue + or PrimitiveValue + or PrimitiveValue + or PrimitiveValue + or PrimitiveValue + or PrimitiveValue + or PrimitiveValue => formulaValues[0].Type, + _ => throw new DeclarativeModelException($"Unsupported table element type: {formulaValues[0].Type.GetType().Name}"), + }; - TableValue NewSingleColumnTable() => - FormulaValue.NewSingleColumnTable(formulaValues.OfType>()); + RecordType singleColumnType = RecordType.Empty().Add("Value", elementType); + RecordValue[] rows = + [ + .. formulaValues.Select( + value => + FormulaValue.NewRecordFromFields( + singleColumnType, + new NamedValue("Value", value))), + ]; + return FormulaValue.NewTable(singleColumnType, rows); } public static bool IsSystemType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) where TValue : struct diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs index aaf8cf22bf9..41fd8468e0e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableExecutor.cs @@ -32,9 +32,23 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st case TableChangeType.Add: ValueExpression addItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); EvaluationResult addResult = this.Evaluator.GetValue(addItemValue); - RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), addResult.Value.ToFormula()); - await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); + FormulaValue addValue = addResult.Value.ToFormula(); + RecordType recordType = tableValue.Type.ToRecord(); + RecordValue newRecord; + TableValue resultTable; + if (!recordType.FieldNames.Any() && !tableValue.Rows.Any()) + { + newRecord = BuildRecordFromValue(addValue); + resultTable = FormulaValue.NewTable(newRecord.Type, newRecord); + } + else + { + newRecord = BuildRecord(recordType, addValue); + await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); + resultTable = tableValue; + } + await this.AssignAsync(variablePath, resultTable, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, newRecord, context).ConfigureAwait(false); break; case TableChangeType.Remove: ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); @@ -42,19 +56,26 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st if (removeResult.Value is TableDataValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable?.Values.Select(row => row.ToRecordValue()), all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, RecordValue.Empty(), context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, RecordValue.Empty(), context).ConfigureAwait(false); } break; case TableChangeType.Clear: await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); break; case TableChangeType.TakeFirst: RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value; if (firstRow is not null) { await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, firstRow, context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, firstRow, context).ConfigureAwait(false); + } + else + { + await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); } break; case TableChangeType.TakeLast: @@ -62,13 +83,23 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st if (lastRow is not null) { await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(variablePath, lastRow, context).ConfigureAwait(false); + await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ResultVariable?.Path, lastRow, context).ConfigureAwait(false); + } + else + { + await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); } break; } return default; + static RecordValue BuildRecordFromValue(FormulaValue value) => + value is RecordValue recordValue ? + recordValue : + FormulaValue.NewRecordFromFields(new NamedValue("Value", value)); + static RecordValue BuildRecord(RecordType recordType, FormulaValue value) { return FormulaValue.NewRecordFromFields(recordType, GetValues()); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index da43a0e0ff6..79b32428a87 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -31,14 +31,26 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat { ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); EvaluationResult expressionResult = this.Evaluator.GetValue(addItemValue); - RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormula()); - await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); + FormulaValue addValue = expressionResult.Value.ToFormula(); + RecordType recordType = tableValue.Type.ToRecord(); + TableValue resultTable; + if (!recordType.FieldNames.Any() && !tableValue.Rows.Any()) + { + RecordValue newRecord = BuildRecordFromValue(addValue); + resultTable = FormulaValue.NewTable(newRecord.Type, newRecord); + } + else + { + RecordValue newRecord = BuildRecord(recordType, addValue); + await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); + resultTable = tableValue; + } + await this.AssignAsync(this.Model.ItemsVariable, resultTable, context).ConfigureAwait(false); } else if (changeType is ClearItemsOperation) { await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); } else if (changeType is RemoveItemOperation removeItemOperation) { @@ -47,30 +59,45 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat if (expressionResult.Value.ToFormula() is TableValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, FormulaValue.NewBlank(), context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); } } - else if (changeType is TakeLastItemOperation) + else if (changeType is TakeLastItemOperation takeLastOperation) { RecordValue? lastRow = tableValue.Rows.LastOrDefault()?.Value; if (lastRow is not null) { await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, lastRow, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(takeLastOperation.ResultVariable?.Path, lastRow, context).ConfigureAwait(false); + } + else + { + await this.AssignAsync(takeLastOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); } } - else if (changeType is TakeFirstItemOperation) + else if (changeType is TakeFirstItemOperation takeFirstOperation) { RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value; if (firstRow is not null) { await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); - await this.AssignAsync(this.Model.ItemsVariable, firstRow, context).ConfigureAwait(false); + await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false); + await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, firstRow, context).ConfigureAwait(false); + } + else + { + await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false); } } return default; + static RecordValue BuildRecordFromValue(FormulaValue value) => + value is RecordValue recordValue ? + recordValue : + FormulaValue.NewRecordFromFields(new NamedValue("Value", value)); + static RecordValue BuildRecord(RecordType recordType, FormulaValue value) { return FormulaValue.NewRecordFromFields(recordType, GetValues()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs index 27cc627b628..bde06ac3ea5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; @@ -84,6 +86,97 @@ public void ListComplexType() Assert.Equal("input", textValue.Value); } + [Fact] + public void TableAsPortableUsesLegacyArrayShape() + { + // Arrange + RecordType recordType = RecordType.Empty().Add("Id", FormulaType.Decimal); + RecordValue record = + FormulaValue.NewRecordFromFields( + recordType, + new NamedValue("Id", FormulaValue.New(1))); + TableValue source = FormulaValue.NewTable(recordType, record); + + // Act + object result = source.AsPortable(); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void RecordAsPortableUsesLegacyDictionaryShape() + { + // Arrange + RecordValue source = + FormulaValue.NewRecordFromFields( + new NamedValue("Id", FormulaValue.New(1))); + + // Act + object result = source.AsPortable(); + + // Assert + Assert.IsAssignableFrom>(result); + } + + [Fact] + public async Task LegacyRecordTableSupportsAppendAsync() + { + // Arrange + Dictionary[] source = [new() { ["Id"] = 1 }]; + TableValue restored = Assert.IsAssignableFrom(new PortableValue(source.AsPortable()).ToFormula()); + RecordType recordType = restored.Type.ToRecord(); + RecordValue newRecord = + FormulaValue.NewRecordFromFields( + recordType, + new NamedValue("Id", FormulaValue.New(2))); + + // Act + await restored.AppendAsync(newRecord, CancellationToken.None); + + // Assert + Assert.Equal(2, restored.Rows.Count()); + } + + [Fact] + public async Task LegacyPrimitiveTableSupportsAppendAsync() + { + // Arrange + string[] source = ["one"]; + TableValue restored = Assert.IsAssignableFrom(new PortableValue(source.AsPortable()).ToFormula()); + RecordType recordType = restored.Type.ToRecord(); + RecordValue newRecord = + FormulaValue.NewRecordFromFields( + recordType, + new NamedValue("Value", FormulaValue.New("two"))); + + // Act + await restored.AppendAsync(newRecord, CancellationToken.None); + + // Assert + string[] values = restored.Rows + .Select(row => Assert.IsType(row.Value.GetField("Value")).Value) + .ToArray(); + Assert.Equal(["one", "two"], values); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LegacyDateTablePreservesFormulaType(bool includeTime) + { + // Arrange + DateTime value = new(2026, 7, 27, includeTime ? 12 : 0, 0, 0, DateTimeKind.Utc); + + // Act + TableValue restored = Assert.IsAssignableFrom(new PortableValue(new[] { value }.AsPortable()).ToFormula()); + + // Assert + FormulaType expectedType = includeTime ? FormulaType.DateTime : FormulaType.Date; + Assert.Equal(expectedType, restored.Type.GetFieldType("Value")); + Assert.Equal(expectedType, Assert.Single(restored.Rows).Value.GetField("Value").Type); + } + [Fact] public void DictionaryType() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs index d27470d4a9f..044e30a4626 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs @@ -1,9 +1,15 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx.Types; @@ -31,7 +37,8 @@ await this.ExecuteTestAsync( displayName: nameof(AddItemToTableAsync), variableName: "MyTable", changeType: TableChangeType.Add, - value: new RecordDataValue([new("id", new NumberDataValue(7))])); + value: new RecordDataValue([new("id", new NumberDataValue(7))]), + resultVariableName: "Result"); // Verify the variable remains a table containing the added record FormulaValue resultValue = this.State.Get("MyTable"); @@ -39,6 +46,8 @@ await this.ExecuteTestAsync( Assert.Equal(2, resultTable.Rows.Count()); DecimalValue idValue = Assert.IsType(resultTable.Rows.Last().Value.GetField("id")); Assert.Equal(7, idValue.Value); + Assert.Equal(7, Assert.IsType( + Assert.IsAssignableFrom(this.State.Get("Result")).GetField("id")).Value); } [Fact] @@ -136,13 +145,14 @@ await this.ExecuteTestAsync( displayName: nameof(RemoveItemFromTableAsync), variableName: "MyTable", changeType: TableChangeType.Remove, - value: new TableDataValue([new RecordDataValue([new("id", new NumberDataValue(3))])])); + value: new TableDataValue([new RecordDataValue([new("id", new NumberDataValue(3))])]), + resultVariableName: "Result"); - // Verify the variable now contains an empty record - FormulaValue resultValue = this.State.Get("MyTable"); - RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); - // Empty record should have no fields - Assert.Empty(resultRecord.Fields); + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + DecimalValue idValue = Assert.IsType(Assert.Single(resultTable.Rows).Value.GetField("id")); + Assert.Equal(7, idValue.Value); + Assert.Empty(Assert.IsAssignableFrom(this.State.Get("Result")).Fields); } [Fact] @@ -162,11 +172,39 @@ await this.ExecuteTestAsync( new RecordDataValue([new("id", new NumberDataValue(3))]) ])); - // Verify the variable now contains an empty record - FormulaValue resultValue = this.State.Get("MyTable"); - RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); - // Empty record should have no fields - Assert.Empty(resultRecord.Fields); + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + DecimalValue idValue = Assert.IsType(Assert.Single(resultTable.Rows).Value.GetField("id")); + Assert.Equal(2, idValue.Value); + } + + [Fact] + public async Task RemoveAllThenRestoreThenAddPreservesTableAsync() + { + // Arrange + this.State.Set("MyTable", this.State.Engine.Eval("[{id: 1}, {id: 2}]")); + EditTable removeAction = this.CreateModel( + nameof(RemoveAllThenRestoreThenAddPreservesTableAsync), + "MyTable", + TableChangeType.Remove, + new TableDataValue([ + new RecordDataValue([new("id", new NumberDataValue(1))]), + new RecordDataValue([new("id", new NumberDataValue(2))]) + ])); + EditTable addAction = this.CreateModel( + nameof(RemoveAllThenRestoreThenAddPreservesTableAsync), + "MyTable", + TableChangeType.Add, + new RecordDataValue([new("id", new NumberDataValue(3))])); + + // Act + await this.ExecuteAsync(new EditTableExecutor(removeAction, this.State)); + this.State.Set("MyTable", new PortableValue(this.State.Get("MyTable").AsPortable()).ToFormula()); + await this.ExecuteAsync(new EditTableExecutor(addAction, this.State)); + + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + Assert.Equal(3, Assert.IsType(Assert.Single(resultTable.Rows).Value.GetField("id")).Value); } [Fact] @@ -181,11 +219,14 @@ await this.ExecuteTestAsync( displayName: nameof(ClearTableAsync), variableName: "MyTable", changeType: TableChangeType.Clear, - value: null); + value: null, + resultVariableName: "Result"); - // Verify table is cleared - FormulaValue resultValue = this.State.Get("MyTable"); - Assert.IsType(resultValue); + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + Assert.Empty(resultTable.Rows); + Assert.Equal(FormulaType.Decimal, resultTable.Type.GetFieldType("id")); + Assert.IsType(this.State.Get("Result")); } [Fact] @@ -205,9 +246,100 @@ await this.ExecuteTestAsync( changeType: TableChangeType.Clear, value: null); - // Verify table is blank - FormulaValue resultValue = this.State.Get("MyTable"); - Assert.IsType(resultValue); + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + Assert.Empty(resultTable.Rows); + Assert.Equal(FormulaType.Decimal, resultTable.Type.GetFieldType("id")); + } + + [Fact] + public async Task ClearThenRestoreThenAddPreservesTableAsync() + { + // Arrange + this.State.Set("MyTable", this.State.Engine.Eval("[{id: 1}, {id: 2}]")); + EditTable clearAction = this.CreateModel( + nameof(ClearThenRestoreThenAddPreservesTableAsync), + "MyTable", + TableChangeType.Clear, + value: null); + EditTable addAction = this.CreateModel( + nameof(ClearThenRestoreThenAddPreservesTableAsync), + "MyTable", + TableChangeType.Add, + new RecordDataValue([new("id", new NumberDataValue(3))])); + + // Act + await this.ExecuteAsync(new EditTableExecutor(clearAction, this.State)); + this.State.Set("MyTable", new PortableValue(this.State.Get("MyTable").AsPortable()).ToFormula()); + await this.ExecuteAsync(new EditTableExecutor(addAction, this.State)); + + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + DecimalValue idValue = Assert.IsType(Assert.Single(resultTable.Rows).Value.GetField("id")); + Assert.Equal(3, idValue.Value); + } + + [Fact] + public async Task ClearThenCheckpointResumeThenAddPreservesTableAsync() + { + // Arrange + EditTable clearModel = this.CreateModel( + nameof(ClearThenCheckpointResumeThenAddPreservesTableAsync), + "MyTable", + TableChangeType.Clear, + value: null); + EditTable addModel = this.CreateModel( + nameof(ClearThenCheckpointResumeThenAddPreservesTableAsync), + "MyTable", + TableChangeType.Add, + new RecordDataValue([new("id", new NumberDataValue(3))])); + + WorkflowFormulaState firstState = new(RecalcEngineFactory.Create()); + firstState.Set("MyTable", firstState.Engine.Eval("[{id: 1}, {id: 2}]")); + Workflow firstWorkflow = BuildWorkflow(firstState); + + InMemoryJsonStore store = new(); + CheckpointManager checkpointManager = CheckpointManager.CreateJson(store, DeclarativeWorkflowJsonOptions.Default); + List checkpoints = []; + + await using (StreamingRun run = await InProcessExecution.RunStreamingAsync(firstWorkflow, firstState, checkpointManager)) + { + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + if (evt is SuperStepCompletedEvent { CompletionInfo.Checkpoint: { } checkpoint }) + { + checkpoints.Add(checkpoint); + } + } + } + Assert.True(checkpoints.Count >= 3); + + WorkflowFormulaState resumedState = new(RecalcEngineFactory.Create()); + Workflow resumedWorkflow = BuildWorkflow(resumedState); + + // Act + await using (StreamingRun run = await InProcessExecution.ResumeStreamingAsync(resumedWorkflow, checkpoints[^2], checkpointManager)) + { + await foreach (WorkflowEvent _ in run.WatchStreamAsync()) + { + } + } + + // Assert + TableValue resultTable = Assert.IsAssignableFrom(resumedState.Get("MyTable")); + Assert.Equal(3, Assert.IsType(Assert.Single(resultTable.Rows).Value.GetField("id")).Value); + + Workflow BuildWorkflow(WorkflowFormulaState state) + { + TestWorkflowExecutor root = new(); + EditTableExecutor clearAction = new(clearModel, state); + EditTableExecutor addAction = new(addModel, state); + return + new WorkflowBuilder(root) + .AddEdge(root, clearAction) + .AddEdge(clearAction, addAction) + .Build(); + } } [Fact] @@ -217,18 +349,25 @@ public async Task TakeFirstItemAsync() FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]"); this.State.Set("MyTable", tableValue); - // Act, Assert - await this.ExecuteTestAsync( - displayName: nameof(TakeFirstItemAsync), + EditTable model = this.CreateModel( + nameof(TakeFirstItemAsync), variableName: "MyTable", changeType: TableChangeType.TakeFirst, - value: null); + value: null, + resultVariableName: "TakenItem"); - // Verify the variable now contains the first record that was taken - FormulaValue resultValue = this.State.Get("MyTable"); - RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); - DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); - Assert.Equal(10, idValue.Value); + // Act + await this.ExecuteAsync(new EditTableExecutor(model, this.State)); + + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + decimal[] ids = resultTable.Rows + .Select(row => Assert.IsType(row.Value.GetField("id")).Value) + .ToArray(); + Assert.Equal([20, 30], ids); + + RecordValue resultRecord = Assert.IsAssignableFrom(this.State.Get("TakenItem")); + Assert.Equal(10, Assert.IsType(resultRecord.GetField("id")).Value); } [Fact] @@ -240,18 +379,23 @@ public async Task TakeFirstFromEmptyTableAsync() // Clear the table to make it empty but preserve schema await table.ClearAsync(CancellationToken.None); this.State.Set("MyTable", table); + this.State.Set("TakenItem", FormulaValue.NewRecordFromFields(new NamedValue("id", FormulaValue.New(99)))); - // Act, Assert - await this.ExecuteTestAsync( - displayName: nameof(TakeFirstFromEmptyTableAsync), + EditTable model = this.CreateModel( + nameof(TakeFirstFromEmptyTableAsync), variableName: "MyTable", changeType: TableChangeType.TakeFirst, - value: null); + value: null, + resultVariableName: "TakenItem"); + + // Act + await this.ExecuteAsync(new EditTableExecutor(model, this.State)); // Verify table is still empty (nothing was taken, variable remains unchanged) FormulaValue resultValue = this.State.Get("MyTable"); TableValue resultTable = Assert.IsAssignableFrom(resultValue); Assert.Empty(resultTable.Rows); + Assert.IsType(this.State.Get("TakenItem")); } [Fact] @@ -261,18 +405,25 @@ public async Task TakeLastItemAsync() FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]"); this.State.Set("MyTable", tableValue); - // Act, Assert - await this.ExecuteTestAsync( - displayName: nameof(TakeLastItemAsync), + EditTable model = this.CreateModel( + nameof(TakeLastItemAsync), variableName: "MyTable", changeType: TableChangeType.TakeLast, - value: null); + value: null, + resultVariableName: "TakenItem"); - // Verify the variable now contains the last record that was taken - FormulaValue resultValue = this.State.Get("MyTable"); - RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); - DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); - Assert.Equal(30, idValue.Value); + // Act + await this.ExecuteAsync(new EditTableExecutor(model, this.State)); + + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + decimal[] ids = resultTable.Rows + .Select(row => Assert.IsType(row.Value.GetField("id")).Value) + .ToArray(); + Assert.Equal([10, 20], ids); + + RecordValue resultRecord = Assert.IsAssignableFrom(this.State.Get("TakenItem")); + Assert.Equal(30, Assert.IsType(resultRecord.GetField("id")).Value); } [Fact] @@ -312,11 +463,9 @@ await this.ExecuteTestAsync( changeType: TableChangeType.TakeFirst, value: null); - // Verify variable contains the record that was taken - FormulaValue resultValue = this.State.Get("MyTable"); - RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); - DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); - Assert.Equal(100, idValue.Value); + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + Assert.Empty(resultTable.Rows); } [Fact] @@ -333,11 +482,40 @@ await this.ExecuteTestAsync( changeType: TableChangeType.TakeLast, value: null); - // Verify variable contains the record that was taken - FormulaValue resultValue = this.State.Get("MyTable"); - RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); - DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); - Assert.Equal(100, idValue.Value); + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + Assert.Empty(resultTable.Rows); + } + + [Fact] + public async Task TakeFirstThenAddPreservesTableAsync() + { + // Arrange + this.State.Set("MyTable", this.State.Engine.Eval("[{id: 1}, {id: 2}]")); + EditTable takeAction = this.CreateModel( + nameof(TakeFirstThenAddPreservesTableAsync), + "MyTable", + TableChangeType.TakeFirst, + value: null, + resultVariableName: "TakenItem"); + EditTable addAction = this.CreateModel( + nameof(TakeFirstThenAddPreservesTableAsync), + "MyTable", + TableChangeType.Add, + new RecordDataValue([new("id", new NumberDataValue(3))])); + + // Act + await this.ExecuteAsync(new EditTableExecutor(takeAction, this.State)); + await this.ExecuteAsync(new EditTableExecutor(addAction, this.State)); + + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("MyTable")); + decimal[] ids = resultTable.Rows + .Select(row => Assert.IsType(row.Value.GetField("id")).Value) + .ToArray(); + Assert.Equal([2, 3], ids); + Assert.Equal(1, Assert.IsType( + Assert.IsAssignableFrom(this.State.Get("TakenItem")).GetField("id")).Value); } [Fact] @@ -417,10 +595,11 @@ private async Task ExecuteTestAsync( string displayName, string variableName, TableChangeType changeType, - DataValue? value) + DataValue? value, + string? resultVariableName = null) { // Arrange - EditTable model = this.CreateModel(displayName, variableName, changeType, value); + EditTable model = this.CreateModel(displayName, variableName, changeType, value, resultVariableName); // Act EditTableExecutor action = new(model, this.State); @@ -434,7 +613,8 @@ private EditTable CreateModel( string displayName, string variableName, TableChangeType changeType, - DataValue? value) + DataValue? value, + string? resultVariableName = null) { ValueExpression.Builder? valueExpressionBuilder = value switch { @@ -442,24 +622,26 @@ private EditTable CreateModel( _ => new ValueExpression.Builder(ValueExpression.Literal(value)) }; - return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder); + return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder, resultVariableName); } private EditTable CreateModel( string displayName, string variableName, TableChangeType changeType, - ValueExpression valueExpression) + ValueExpression valueExpression, + string? resultVariableName = null) { ValueExpression.Builder valueExpressionBuilder = new(valueExpression); - return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder); + return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder, resultVariableName); } private EditTable CreateModel( string displayName, string variableName, TableChangeType changeType, - ValueExpression.Builder? valueExpression) + ValueExpression.Builder? valueExpression, + string? resultVariableName = null) { EditTable.Builder actionBuilder = new() { @@ -469,7 +651,31 @@ private EditTable CreateModel( ChangeType = TableChangeTypeWrapper.Get(changeType), Value = valueExpression, }; + if (resultVariableName is not null) + { + actionBuilder.ResultVariable = PropertyPath.Create(FormatVariablePath(resultVariableName)); + } return AssignParent(actionBuilder); } + + private sealed class InMemoryJsonStore : JsonCheckpointStore + { + private readonly Dictionary _store = []; + + public override ValueTask CreateCheckpointAsync( + string sessionId, JsonElement value, CheckpointInfo? parent = null) + { + CheckpointInfo key = new(sessionId, Guid.NewGuid().ToString("N")); + this._store[key] = value; + return new(key); + } + + public override ValueTask RetrieveCheckpointAsync(string sessionId, CheckpointInfo key) => + new(this._store[key]); + + public override ValueTask> RetrieveIndexAsync( + string sessionId, CheckpointInfo? withParent = null) => + new(this._store.Keys.Where(key => key.SessionId == sessionId)); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableV2ExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableV2ExecutorTest.cs index a7ecc696662..b6ef3c634b1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableV2ExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableV2ExecutorTest.cs @@ -3,6 +3,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx.Types; @@ -220,10 +221,43 @@ public async Task ClearItemsOperationAsync() this.State.Set("TestTable", tableValue); // Act & Assert - await this.ExecuteTestAsync( + await this.ExecuteTestAsync( displayName: nameof(ClearItemsOperationAsync), variableName: "TestTable", - changeType: new ClearItemsOperation.Builder().Build()); + changeType: new ClearItemsOperation.Builder().Build(), + verifyAction: (_, resultTable) => + { + Assert.Empty(resultTable.Rows); + Assert.Equal(FormulaType.String, resultTable.Type.GetFieldType("Value")); + }); + } + + [Fact] + public async Task ClearThenRestoreThenAddPreservesTableAsync() + { + // Arrange + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1"))); + RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2"))); + this.State.Set("TestTable", FormulaValue.NewTable(recordType, record1, record2)); + + EditTableV2 clearAction = this.CreateModel( + nameof(ClearThenRestoreThenAddPreservesTableAsync), + "TestTable", + new ClearItemsOperation.Builder().Build()); + EditTableV2 addAction = this.CreateModel( + nameof(ClearThenRestoreThenAddPreservesTableAsync), + "TestTable", + this.CreateAddItemOperation(new StringDataValue("Item3"))); + + // Act + await this.ExecuteAsync(new EditTableV2Executor(clearAction, this.State)); + this.State.Set("TestTable", new PortableValue(this.State.Get("TestTable").AsPortable()).ToFormula()); + await this.ExecuteAsync(new EditTableV2Executor(addAction, this.State)); + + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("TestTable")); + Assert.Equal("Item3", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject()); } [Fact] @@ -237,10 +271,40 @@ public async Task RemoveItemOperationAsync() this.State.Set("TestTable", tableValue); // Act & Assert - await this.ExecuteTestAsync( + await this.ExecuteTestAsync( displayName: nameof(RemoveItemOperationAsync), variableName: "TestTable", - changeType: this.CreateRemoveItemOperation("Item1")); + changeType: this.CreateRemoveItemOperation("Item1"), + verifyAction: (_, resultTable) => + Assert.Equal("Item2", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject())); + } + + [Fact] + public async Task RemoveAllThenRestoreThenAddPreservesTableAsync() + { + // Arrange + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1"))); + RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2"))); + this.State.Set("TestTable", FormulaValue.NewTable(recordType, record1, record2)); + + EditTableV2 removeAction = this.CreateModel( + nameof(RemoveAllThenRestoreThenAddPreservesTableAsync), + "TestTable", + this.CreateRemoveItemOperation("Item1", "Item2")); + EditTableV2 addAction = this.CreateModel( + nameof(RemoveAllThenRestoreThenAddPreservesTableAsync), + "TestTable", + this.CreateAddItemOperation(new StringDataValue("Item3"))); + + // Act + await this.ExecuteAsync(new EditTableV2Executor(removeAction, this.State)); + this.State.Set("TestTable", new PortableValue(this.State.Get("TestTable").AsPortable()).ToFormula()); + await this.ExecuteAsync(new EditTableV2Executor(addAction, this.State)); + + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("TestTable")); + Assert.Equal("Item3", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject()); } [Fact] @@ -254,14 +318,25 @@ public async Task TakeLastItemOperationWithItemsAsync() TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3); this.State.Set("TestTable", tableValue); - // Arrange, Act, Assert - await this.ExecuteTestAsync( + // Act + await this.ExecuteTestAsync( displayName: nameof(TakeLastItemOperationWithItemsAsync), variableName: "TestTable", - changeType: new TakeLastItemOperation.Builder().Build(), - verifyAction: (variableName, recordValue) => - Assert.Equal("Item3", recordValue.GetField("Value").ToObject()) - ); + changeType: new TakeLastItemOperation.Builder + { + ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem")) + }.Build(), + verifyAction: (_, resultTable) => + { + string[] values = resultTable.Rows + .Select(row => Assert.IsType(row.Value.GetField("Value")).Value) + .ToArray(); + Assert.Equal(["Item1", "Item2"], values); + }); + + // Assert + RecordValue resultRecord = Assert.IsAssignableFrom(this.State.Get("TakenItem")); + Assert.Equal("Item3", resultRecord.GetField("Value").ToObject()); } [Fact] @@ -271,12 +346,19 @@ public async Task TakeLastItemOperationEmptyTableAsync() RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); TableValue tableValue = FormulaValue.NewTable(recordType); this.State.Set("TestTable", tableValue); + this.State.Set("TakenItem", FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("stale")))); - // Arrange, Act, Assert + // Act await this.ExecuteTestAsync( displayName: nameof(TakeLastItemOperationEmptyTableAsync), variableName: "TestTable", - changeType: new TakeLastItemOperation.Builder().Build()); + changeType: new TakeLastItemOperation.Builder + { + ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem")) + }.Build()); + + // Assert + Assert.IsType(this.State.Get("TakenItem")); } [Fact] @@ -290,14 +372,77 @@ public async Task TakeFirstItemOperationWithItemsAsync() TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3); this.State.Set("TestTable", tableValue); - // Act & Assert - await this.ExecuteTestAsync( + // Act + await this.ExecuteTestAsync( displayName: nameof(TakeFirstItemOperationWithItemsAsync), variableName: "TestTable", + changeType: new TakeFirstItemOperation.Builder + { + ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem")) + }.Build(), + verifyAction: (_, resultTable) => + { + string[] values = resultTable.Rows + .Select(row => Assert.IsType(row.Value.GetField("Value")).Value) + .ToArray(); + Assert.Equal(["Item2", "Item3"], values); + }); + + // Assert + RecordValue resultRecord = Assert.IsAssignableFrom(this.State.Get("TakenItem")); + Assert.Equal("Item1", resultRecord.GetField("Value").ToObject()); + } + + [Fact] + public async Task TakeFirstItemOperationWithoutResultPreservesTableAsync() + { + // Arrange + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1"))); + RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2"))); + this.State.Set("TestTable", FormulaValue.NewTable(recordType, record1, record2)); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeFirstItemOperationWithoutResultPreservesTableAsync), + variableName: "TestTable", changeType: new TakeFirstItemOperation.Builder().Build(), - verifyAction: (variableName, recordValue) => - Assert.Equal("Item1", recordValue.GetField("Value").ToObject()) - ); + verifyAction: (_, resultTable) => + Assert.Equal("Item2", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject())); + } + + [Fact] + public async Task TakeLastThenAddPreservesTableAsync() + { + // Arrange + RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); + RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1"))); + RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2"))); + this.State.Set("TestTable", FormulaValue.NewTable(recordType, record1, record2)); + + EditTableV2 takeAction = this.CreateModel( + nameof(TakeLastThenAddPreservesTableAsync), + "TestTable", + new TakeLastItemOperation.Builder + { + ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem")) + }.Build()); + EditTableV2 addAction = this.CreateModel( + nameof(TakeLastThenAddPreservesTableAsync), + "TestTable", + this.CreateAddItemOperation(new StringDataValue("Item3"))); + + // Act + await this.ExecuteAsync(new EditTableV2Executor(takeAction, this.State)); + await this.ExecuteAsync(new EditTableV2Executor(addAction, this.State)); + + // Assert + TableValue resultTable = Assert.IsAssignableFrom(this.State.Get("TestTable")); + string[] values = resultTable.Rows + .Select(row => Assert.IsType(row.Value.GetField("Value")).Value) + .ToArray(); + Assert.Equal(["Item1", "Item3"], values); + Assert.Equal("Item2", Assert.IsAssignableFrom(this.State.Get("TakenItem")).GetField("Value").ToObject()); } [Fact] @@ -307,12 +452,19 @@ public async Task TakeFirstItemOperationEmptyTableAsync() RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); TableValue tableValue = FormulaValue.NewTable(recordType); this.State.Set("TestTable", tableValue); + this.State.Set("TakenItem", FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("stale")))); - // Act & Assert + // Act await this.ExecuteTestAsync( displayName: nameof(TakeFirstItemOperationEmptyTableAsync), variableName: "TestTable", - changeType: new TakeFirstItemOperation.Builder().Build()); + changeType: new TakeFirstItemOperation.Builder + { + ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem")) + }.Build()); + + // Assert + Assert.IsType(this.State.Get("TakenItem")); } private async Task ExecuteTestAsync( @@ -357,12 +509,18 @@ private AddItemOperation CreateAddItemOperation(DataValue value) }.Build(); } - private RemoveItemOperation CreateRemoveItemOperation(string itemValue) + private RemoveItemOperation CreateRemoveItemOperation(params string[] itemValues) { // Create a table with the item to remove RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String); - RecordValue recordToRemove = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New(itemValue))); - TableValue tableToRemove = FormulaValue.NewTable(recordType, recordToRemove); + TableValue tableToRemove = + FormulaValue.NewTable( + recordType, + itemValues.Select( + itemValue => + FormulaValue.NewRecordFromFields( + recordType, + new NamedValue("Value", FormulaValue.New(itemValue))))); // Store in state for expression evaluation this.State.Set("RemoveItems", tableToRemove);