-
Notifications
You must be signed in to change notification settings - Fork 1.1k
.NET: Add tests for subworkflow shared state behavior #3444
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
lokitoth
merged 1 commit into
main
from
dev/dotnet_workflow/add_subworkflow_state_tests
Jan 27, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
158 changes: 158 additions & 0 deletions
158
dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/14_Subworkflow_SharedState.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.