.NET: Add tests for subworkflow shared state behavior (#3444)

Adds tests documenting current shared state behavior in subworkflows:

- State works correctly within a subworkflow

- State is isolated across parent/subworkflow boundaries

Related to #2419
This commit is contained in:
Jacob Alber
2026-01-27 16:45:27 -05:00
committed by GitHub
Unverified
parent 787becfc9f
commit 4245561013
2 changed files with 210 additions and 0 deletions
@@ -0,0 +1,158 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Sample;
/// <summary>
/// Tests for shared state preservation across subworkflow boundaries.
/// Validates fix for issue #2419: ".NET: Shared State is not preserved in Subworkflows"
/// </summary>
internal static class Step14EntryPoint
{
public const string WordStateScope = "WordStateScope";
/// <summary>
/// Tests that shared state works WITHIN a subworkflow (internal persistence).
/// This tests whether state written by one executor in a subworkflow can be
/// read by another executor in the SAME subworkflow.
/// </summary>
public static async ValueTask<int> RunSubworkflowInternalStateAsync(string text, TextWriter writer, IWorkflowExecutionEnvironment environment)
{
// All three executors are INSIDE the subworkflow
TextReadExecutor textRead = new();
TextTrimExecutor textTrim = new();
CharCountingExecutor charCount = new();
Workflow subWorkflow = new WorkflowBuilder(textRead)
.AddEdge(textRead, textTrim)
.AddEdge(textTrim, charCount)
.WithOutputFrom(charCount)
.Build();
ExecutorBinding subWorkflowStep = subWorkflow.BindAsExecutor("internalStateSubworkflow");
// Parent workflow just wraps the subworkflow
Workflow workflow = new WorkflowBuilder(subWorkflowStep)
.WithOutputFrom(subWorkflowStep)
.Build();
await using Run run = await environment.RunAsync(workflow, text);
int? result = null;
foreach (WorkflowEvent evt in run.OutgoingEvents)
{
if (evt is WorkflowOutputEvent outputEvent)
{
result = outputEvent.As<int>();
writer.WriteLine($"Subworkflow internal state result: {result}");
}
else if (evt is WorkflowErrorEvent failedEvent)
{
writer.WriteLine($"Workflow failed: {failedEvent.Data}");
throw failedEvent.Data as Exception ?? new InvalidOperationException(failedEvent.Data?.ToString());
}
}
return result ?? throw new InvalidOperationException("No output produced");
}
/// <summary>
/// Tests cross-boundary state behavior (parent → subworkflow → parent).
/// This documents the current behavior for issue #2419: state is isolated across subworkflow boundaries.
/// </summary>
public static async ValueTask<Exception?> RunCrossBoundaryStateAsync(string text, TextWriter writer, IWorkflowExecutionEnvironment environment)
{
TextReadExecutor textRead = new();
TextTrimExecutor textTrim = new();
CharCountingExecutor charCount = new();
// Create a subworkflow containing just the trim executor
Workflow subWorkflow = new WorkflowBuilder(textTrim)
.WithOutputFrom(textTrim)
.Build();
ExecutorBinding subWorkflowStep = subWorkflow.BindAsExecutor("textTrimSubworkflow");
// Create the main workflow: parent → subworkflow → parent
Workflow workflow = new WorkflowBuilder(textRead)
.AddEdge(textRead, subWorkflowStep)
.AddEdge(subWorkflowStep, charCount)
.WithOutputFrom(charCount)
.Build();
await using Run run = await environment.RunAsync(workflow, text);
foreach (WorkflowEvent evt in run.OutgoingEvents)
{
if (evt is WorkflowOutputEvent outputEvent)
{
writer.WriteLine($"Cross-boundary state result: {outputEvent.As<int>()}");
return null; // Success - no error
}
else if (evt is WorkflowErrorEvent failedEvent)
{
writer.WriteLine($"Workflow failed: {failedEvent.Data}");
return failedEvent.Data as Exception;
}
}
return new InvalidOperationException("No output produced");
}
/// <summary>
/// Executor that reads text and stores it in shared state with a generated key.
/// </summary>
internal sealed class TextReadExecutor() : Executor("TextReadExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
=> routeBuilder.AddHandler<string, string>(this.HandleAsync);
private async ValueTask<string> HandleAsync(string text, IWorkflowContext context, CancellationToken cancellationToken = default)
{
string key = Guid.NewGuid().ToString();
await context.QueueStateUpdateAsync(key, text, scopeName: WordStateScope, cancellationToken);
return key;
}
}
/// <summary>
/// Executor that reads text from shared state, trims it, and updates the state.
/// </summary>
internal sealed class TextTrimExecutor() : Executor("TextTrimExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
=> routeBuilder.AddHandler<string, string>(this.HandleAsync);
private async ValueTask<string> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
{
string? content = await context.ReadStateAsync<string>(key, scopeName: WordStateScope, cancellationToken);
if (content is null)
{
throw new InvalidOperationException($"Word state not found for key: {key}");
}
string trimmed = content.Trim();
await context.QueueStateUpdateAsync(key, trimmed, scopeName: WordStateScope, cancellationToken);
return key;
}
}
/// <summary>
/// Executor that reads text from shared state and returns its character count.
/// </summary>
internal sealed class CharCountingExecutor() : Executor("CharCountingExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
=> routeBuilder.AddHandler<string, int>(this.HandleAsync);
private async ValueTask<int> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
{
string? content = await context.ReadStateAsync<string>(key, scopeName: WordStateScope, cancellationToken);
return content?.Length ?? 0;
}
}
}
@@ -437,6 +437,58 @@ public class SampleSmokeTest
);
}
}
/// <summary>
/// Tests that shared state works WITHIN a subworkflow (internal persistence).
/// This verifies state written by one executor in a subworkflow can be read
/// by another executor in the SAME subworkflow.
/// </summary>
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
internal async Task Test_RunSample_Step14_SharedState_WorksWithinSubworkflowAsync(ExecutionEnvironment environment)
{
// Arrange
IWorkflowExecutionEnvironment executionEnvironment = environment.ToWorkflowExecutionEnvironment();
const string Text = " Lorem ipsum dolor sit amet, consectetur adipiscing elit. ";
int expectedCharCount = Text.Trim().Length;
// Act & Assert - All executors inside the subworkflow should share state
using StringWriter writer = new();
int result = await Step14EntryPoint.RunSubworkflowInternalStateAsync(Text, writer, executionEnvironment);
result.Should().Be(expectedCharCount, "executors within subworkflow should share state correctly");
}
/// <summary>
/// Documents that shared state is currently isolated across subworkflow boundaries.
/// This is the behavior reported in issue #2419.
/// When/if cross-boundary state sharing is implemented, this test should be updated
/// to expect success instead of failure.
/// </summary>
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
internal async Task Test_RunSample_Step14a_SharedState_IsolatedAcrossSubworkflowBoundaryAsync(ExecutionEnvironment environment)
{
// Arrange
IWorkflowExecutionEnvironment executionEnvironment = environment.ToWorkflowExecutionEnvironment();
const string Text = " Lorem ipsum dolor sit amet, consectetur adipiscing elit. ";
// Act - Attempt to use shared state across parent/subworkflow boundary
using StringWriter writer = new();
Exception? error = await Step14EntryPoint.RunCrossBoundaryStateAsync(Text, writer, executionEnvironment);
// Assert - Currently, state is isolated across subworkflow boundaries (issue #2419)
// The subworkflow executor cannot see state written by the parent workflow
error.Should().NotBeNull("state written in parent workflow is not visible in subworkflow");
// The exception may be wrapped in TargetInvocationException, so check inner exception too
Exception actualError = error is System.Reflection.TargetInvocationException tie && tie.InnerException != null
? tie.InnerException
: error;
actualError.Should().BeOfType<InvalidOperationException>();
}
}
internal sealed class VerifyingPlaybackResponder<TInput, TResponse>