Files
agent-framework/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/InProcessStateTests.cs
T
Jacob Alber 39e071c430 .NET: Update Workflow Input/Output Redesign (#881)
* feat: Make Executor id field mandatory

When checkpointing is involved, it is critical to keep executor ids consistent between runs, even when recreating a new object tree for the workflow.

The default id-setting mechanism generated a guid for part of the id, making it not work when restoring from a checkpoint.

This change prevents this situation from arising.

* feat: Enable running untyped Workflows

With the change to enable delay-instantiation of executors and support for async Executor factory methods, we must instantiate the starting executor to know what are the valid input types for the workflow.

To avoid forcing instantiation every time, and to better support workflows with multiple input types, we enable support for build and interacting with the base Workflow type without type annotations, and remove the requirement to know a valid input type when initiating a run.

* feat: Support Output from any executor and multiple outputs.
2025-09-25 02:03:22 +00:00

163 lines
5.5 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.Workflows.UnitTests;
public class InProcessStateTests
{
private sealed class TurnToken
{
public int Count { get; }
public TurnToken() : this(0)
{ }
private TurnToken(int count)
{
this.Count = count;
}
public TurnToken Next => new(this.Count + 1);
}
private sealed class StateTestExecutor<TState> : TestingExecutor<TurnToken, TurnToken>
{
private static Func<TurnToken, IWorkflowContext, CancellationToken, ValueTask<TurnToken>>[] WrapActions(ScopeKey stateKey, Func<TState?, TState?>[] stateActions)
{
Func<TurnToken, IWorkflowContext, CancellationToken, ValueTask<TurnToken>>[] result
= new Func<TurnToken, IWorkflowContext, CancellationToken, ValueTask<TurnToken>>[stateActions.Length];
for (int i = 0; i < stateActions.Length; i++)
{
result[i] = CreateWrapper(stateActions[i]);
}
return result;
Func<TurnToken, IWorkflowContext, CancellationToken, ValueTask<TurnToken>> CreateWrapper(Func<TState?, TState?> action)
{
return
async (turn, context, cancellation) =>
{
TState? state = await context.ReadStateAsync<TState>(stateKey.Key, stateKey.ScopeId.ScopeName)
.ConfigureAwait(false);
state = action(state);
await context.QueueStateUpdateAsync(stateKey.Key, state, stateKey.ScopeId.ScopeName);
return turn.Next;
};
}
}
public ScopeKey StateKey { get; }
public StateTestExecutor(ScopeKey stateKey, bool loop = false, params Func<TState?, TState?>[] stateActions)
: base(stateKey.ScopeId.ExecutorId, loop, WrapActions(stateKey, stateActions))
{
this.StateKey = stateKey;
}
}
private static Func<int?, int?> CreateOrIncrement(int defaultValue = default)
=> currState => currState.HasValue ? currState + 1 : defaultValue;
private static Func<int?, int?> ValidateState(int expectedValue, string? because = null, params object[] becauseArgs)
=> currState =>
{
currState.Should().Be(expectedValue, because, becauseArgs);
return currState;
};
private static Func<object?, bool> MaxTurns(int maxTurns)
=> maybeTurn => maybeTurn is not TurnToken turn || turn.Count < maxTurns;
[Fact]
public async Task InProcessRun_StateShouldPersist_NotCheckpointedAsync()
{
StateTestExecutor<int?> writer = new(
new ScopeKey("Writer", "TestScope", "TestKey"),
loop: false,
CreateOrIncrement(),
CreateOrIncrement()
);
StateTestExecutor<int?> validator = new(
new ScopeKey("Validator", "TestScope", "TestKey"),
loop: false,
ValidateState(0),
ValidateState(1)
);
Workflow workflow =
new WorkflowBuilder(writer)
.AddEdge(writer, validator, MaxTurns(4))
.AddEdge(validator, writer, MaxTurns(4)).Build();
Run run = await InProcessExecution.RunAsync<TurnToken>(workflow, new());
run.Status.Should().Be(RunStatus.Idle);
}
[Fact]
public async Task InProcessRun_StateShouldPersist_CheckpointedAsync()
{
StateTestExecutor<int?> writer = new(
new ScopeKey("Writer", "TestScope", "TestKey"),
loop: false,
CreateOrIncrement(),
CreateOrIncrement()
);
StateTestExecutor<int?> validator = new(
new ScopeKey("Validator", "TestScope", "TestKey"),
loop: false,
ValidateState(0),
ValidateState(1)
);
Workflow workflow =
new WorkflowBuilder(writer)
.AddEdge(writer, validator, MaxTurns(4))
.AddEdge(validator, writer, MaxTurns(4)).Build();
Checkpointed<Run> checkpointed = await InProcessExecution.RunAsync<TurnToken>(workflow, new(), CheckpointManager.Default);
checkpointed.Checkpoints.Should().HaveCount(6);
checkpointed.Run.Status.Should().Be(RunStatus.Idle);
}
[Fact]
public async Task InProcessRun_StateShouldError_TwoExecutorsAsync()
{
ForwardMessageExecutor<TurnToken> forward = new(nameof(ForwardMessageExecutor<TurnToken>));
using StateTestExecutor<int?> testExecutor = new(
new ScopeKey("StateTestExecutor", "TestScope", "TestKey"),
loop: false,
CreateOrIncrement()
);
using StateTestExecutor<int?> testExecutor2 = new(
new ScopeKey("StateTestExecutor2", "TestScope", "TestKey"),
loop: false,
CreateOrIncrement()
);
Workflow workflow =
new WorkflowBuilder(forward)
.AddFanOutEdge(forward, targets: [testExecutor, testExecutor2])
.Build();
var act = async () => await InProcessExecution.RunAsync(workflow, new TurnToken());
var result = await act.Should()
.ThrowAsync("multiple writers to the same shared scope key");
}
}