mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
787becfc9f
commit
4245561013
+158
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user