Skip to content

Commit 4245561

Browse files
authored
.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
1 parent 787becf commit 4245561

File tree

2 files changed

+210
-0
lines changed

2 files changed

+210
-0
lines changed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.IO;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
8+
namespace Microsoft.Agents.AI.Workflows.Sample;
9+
10+
/// <summary>
11+
/// Tests for shared state preservation across subworkflow boundaries.
12+
/// Validates fix for issue #2419: ".NET: Shared State is not preserved in Subworkflows"
13+
/// </summary>
14+
internal static class Step14EntryPoint
15+
{
16+
public const string WordStateScope = "WordStateScope";
17+
18+
/// <summary>
19+
/// Tests that shared state works WITHIN a subworkflow (internal persistence).
20+
/// This tests whether state written by one executor in a subworkflow can be
21+
/// read by another executor in the SAME subworkflow.
22+
/// </summary>
23+
public static async ValueTask<int> RunSubworkflowInternalStateAsync(string text, TextWriter writer, IWorkflowExecutionEnvironment environment)
24+
{
25+
// All three executors are INSIDE the subworkflow
26+
TextReadExecutor textRead = new();
27+
TextTrimExecutor textTrim = new();
28+
CharCountingExecutor charCount = new();
29+
30+
Workflow subWorkflow = new WorkflowBuilder(textRead)
31+
.AddEdge(textRead, textTrim)
32+
.AddEdge(textTrim, charCount)
33+
.WithOutputFrom(charCount)
34+
.Build();
35+
36+
ExecutorBinding subWorkflowStep = subWorkflow.BindAsExecutor("internalStateSubworkflow");
37+
38+
// Parent workflow just wraps the subworkflow
39+
Workflow workflow = new WorkflowBuilder(subWorkflowStep)
40+
.WithOutputFrom(subWorkflowStep)
41+
.Build();
42+
43+
await using Run run = await environment.RunAsync(workflow, text);
44+
45+
int? result = null;
46+
foreach (WorkflowEvent evt in run.OutgoingEvents)
47+
{
48+
if (evt is WorkflowOutputEvent outputEvent)
49+
{
50+
result = outputEvent.As<int>();
51+
writer.WriteLine($"Subworkflow internal state result: {result}");
52+
}
53+
else if (evt is WorkflowErrorEvent failedEvent)
54+
{
55+
writer.WriteLine($"Workflow failed: {failedEvent.Data}");
56+
throw failedEvent.Data as Exception ?? new InvalidOperationException(failedEvent.Data?.ToString());
57+
}
58+
}
59+
60+
return result ?? throw new InvalidOperationException("No output produced");
61+
}
62+
63+
/// <summary>
64+
/// Tests cross-boundary state behavior (parent → subworkflow → parent).
65+
/// This documents the current behavior for issue #2419: state is isolated across subworkflow boundaries.
66+
/// </summary>
67+
public static async ValueTask<Exception?> RunCrossBoundaryStateAsync(string text, TextWriter writer, IWorkflowExecutionEnvironment environment)
68+
{
69+
TextReadExecutor textRead = new();
70+
TextTrimExecutor textTrim = new();
71+
CharCountingExecutor charCount = new();
72+
73+
// Create a subworkflow containing just the trim executor
74+
Workflow subWorkflow = new WorkflowBuilder(textTrim)
75+
.WithOutputFrom(textTrim)
76+
.Build();
77+
78+
ExecutorBinding subWorkflowStep = subWorkflow.BindAsExecutor("textTrimSubworkflow");
79+
80+
// Create the main workflow: parent → subworkflow → parent
81+
Workflow workflow = new WorkflowBuilder(textRead)
82+
.AddEdge(textRead, subWorkflowStep)
83+
.AddEdge(subWorkflowStep, charCount)
84+
.WithOutputFrom(charCount)
85+
.Build();
86+
87+
await using Run run = await environment.RunAsync(workflow, text);
88+
89+
foreach (WorkflowEvent evt in run.OutgoingEvents)
90+
{
91+
if (evt is WorkflowOutputEvent outputEvent)
92+
{
93+
writer.WriteLine($"Cross-boundary state result: {outputEvent.As<int>()}");
94+
return null; // Success - no error
95+
}
96+
else if (evt is WorkflowErrorEvent failedEvent)
97+
{
98+
writer.WriteLine($"Workflow failed: {failedEvent.Data}");
99+
return failedEvent.Data as Exception;
100+
}
101+
}
102+
103+
return new InvalidOperationException("No output produced");
104+
}
105+
106+
/// <summary>
107+
/// Executor that reads text and stores it in shared state with a generated key.
108+
/// </summary>
109+
internal sealed class TextReadExecutor() : Executor("TextReadExecutor")
110+
{
111+
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
112+
=> routeBuilder.AddHandler<string, string>(this.HandleAsync);
113+
114+
private async ValueTask<string> HandleAsync(string text, IWorkflowContext context, CancellationToken cancellationToken = default)
115+
{
116+
string key = Guid.NewGuid().ToString();
117+
await context.QueueStateUpdateAsync(key, text, scopeName: WordStateScope, cancellationToken);
118+
return key;
119+
}
120+
}
121+
122+
/// <summary>
123+
/// Executor that reads text from shared state, trims it, and updates the state.
124+
/// </summary>
125+
internal sealed class TextTrimExecutor() : Executor("TextTrimExecutor")
126+
{
127+
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
128+
=> routeBuilder.AddHandler<string, string>(this.HandleAsync);
129+
130+
private async ValueTask<string> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
131+
{
132+
string? content = await context.ReadStateAsync<string>(key, scopeName: WordStateScope, cancellationToken);
133+
if (content is null)
134+
{
135+
throw new InvalidOperationException($"Word state not found for key: {key}");
136+
}
137+
138+
string trimmed = content.Trim();
139+
await context.QueueStateUpdateAsync(key, trimmed, scopeName: WordStateScope, cancellationToken);
140+
return key;
141+
}
142+
}
143+
144+
/// <summary>
145+
/// Executor that reads text from shared state and returns its character count.
146+
/// </summary>
147+
internal sealed class CharCountingExecutor() : Executor("CharCountingExecutor")
148+
{
149+
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
150+
=> routeBuilder.AddHandler<string, int>(this.HandleAsync);
151+
152+
private async ValueTask<int> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
153+
{
154+
string? content = await context.ReadStateAsync<string>(key, scopeName: WordStateScope, cancellationToken);
155+
return content?.Length ?? 0;
156+
}
157+
}
158+
}

dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,58 @@ async ValueTask RunAndValidateAsync(int step)
437437
);
438438
}
439439
}
440+
441+
/// <summary>
442+
/// Tests that shared state works WITHIN a subworkflow (internal persistence).
443+
/// This verifies state written by one executor in a subworkflow can be read
444+
/// by another executor in the SAME subworkflow.
445+
/// </summary>
446+
[Theory]
447+
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
448+
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
449+
internal async Task Test_RunSample_Step14_SharedState_WorksWithinSubworkflowAsync(ExecutionEnvironment environment)
450+
{
451+
// Arrange
452+
IWorkflowExecutionEnvironment executionEnvironment = environment.ToWorkflowExecutionEnvironment();
453+
const string Text = " Lorem ipsum dolor sit amet, consectetur adipiscing elit. ";
454+
int expectedCharCount = Text.Trim().Length;
455+
456+
// Act & Assert - All executors inside the subworkflow should share state
457+
using StringWriter writer = new();
458+
int result = await Step14EntryPoint.RunSubworkflowInternalStateAsync(Text, writer, executionEnvironment);
459+
result.Should().Be(expectedCharCount, "executors within subworkflow should share state correctly");
460+
}
461+
462+
/// <summary>
463+
/// Documents that shared state is currently isolated across subworkflow boundaries.
464+
/// This is the behavior reported in issue #2419.
465+
/// When/if cross-boundary state sharing is implemented, this test should be updated
466+
/// to expect success instead of failure.
467+
/// </summary>
468+
[Theory]
469+
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
470+
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
471+
internal async Task Test_RunSample_Step14a_SharedState_IsolatedAcrossSubworkflowBoundaryAsync(ExecutionEnvironment environment)
472+
{
473+
// Arrange
474+
IWorkflowExecutionEnvironment executionEnvironment = environment.ToWorkflowExecutionEnvironment();
475+
const string Text = " Lorem ipsum dolor sit amet, consectetur adipiscing elit. ";
476+
477+
// Act - Attempt to use shared state across parent/subworkflow boundary
478+
using StringWriter writer = new();
479+
Exception? error = await Step14EntryPoint.RunCrossBoundaryStateAsync(Text, writer, executionEnvironment);
480+
481+
// Assert - Currently, state is isolated across subworkflow boundaries (issue #2419)
482+
// The subworkflow executor cannot see state written by the parent workflow
483+
error.Should().NotBeNull("state written in parent workflow is not visible in subworkflow");
484+
485+
// The exception may be wrapped in TargetInvocationException, so check inner exception too
486+
Exception actualError = error is System.Reflection.TargetInvocationException tie && tie.InnerException != null
487+
? tie.InnerException
488+
: error;
489+
490+
actualError.Should().BeOfType<InvalidOperationException>();
491+
}
440492
}
441493

442494
internal sealed class VerifyingPlaybackResponder<TInput, TResponse>

0 commit comments

Comments
 (0)