Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,58 @@ async ValueTask RunAndValidateAsync(int step)
);
}
}

/// <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>
Expand Down
Loading