Skip to content

Commit 750bbc9

Browse files
committed
.NET: Add tests for subworkflow shared state behavior
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 a3a9147 commit 750bbc9

File tree

2 files changed

+246
-0
lines changed

2 files changed

+246
-0
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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+
/// Runs a single workflow (no subworkflows) that uses shared state.
65+
/// This should work correctly as a baseline.
66+
/// </summary>
67+
public static async ValueTask<int> RunSingleWorkflowAsync(string text, TextWriter writer, IWorkflowExecutionEnvironment environment)
68+
{
69+
TextReadExecutor textRead = new();
70+
TextTrimExecutor textTrim = new();
71+
CharCountingExecutor charCount = new();
72+
73+
Workflow workflow = new WorkflowBuilder(textRead)
74+
.AddEdge(textRead, textTrim)
75+
.AddEdge(textTrim, charCount)
76+
.WithOutputFrom(charCount)
77+
.Build();
78+
79+
await using Run run = await environment.RunAsync(workflow, text);
80+
81+
int? result = null;
82+
foreach (WorkflowEvent evt in run.OutgoingEvents)
83+
{
84+
if (evt is WorkflowOutputEvent outputEvent)
85+
{
86+
result = outputEvent.As<int>();
87+
writer.WriteLine($"Single workflow result: {result}");
88+
}
89+
else if (evt is WorkflowErrorEvent failedEvent)
90+
{
91+
writer.WriteLine($"Workflow failed: {failedEvent.Data}");
92+
throw failedEvent.Data as Exception ?? new InvalidOperationException(failedEvent.Data?.ToString());
93+
}
94+
}
95+
96+
return result ?? throw new InvalidOperationException("No output produced");
97+
}
98+
99+
/// <summary>
100+
/// Tests cross-boundary state behavior (parent → subworkflow → parent).
101+
/// This documents the current behavior for issue #2419: state is isolated across subworkflow boundaries.
102+
/// </summary>
103+
public static async ValueTask<Exception?> RunCrossBoundaryStateAsync(string text, TextWriter writer, IWorkflowExecutionEnvironment environment)
104+
{
105+
TextReadExecutor textRead = new();
106+
TextTrimExecutor textTrim = new();
107+
CharCountingExecutor charCount = new();
108+
109+
// Create a subworkflow containing just the trim executor
110+
Workflow subWorkflow = new WorkflowBuilder(textTrim)
111+
.WithOutputFrom(textTrim)
112+
.Build();
113+
114+
ExecutorBinding subWorkflowStep = subWorkflow.BindAsExecutor("textTrimSubworkflow");
115+
116+
// Create the main workflow: parent → subworkflow → parent
117+
Workflow workflow = new WorkflowBuilder(textRead)
118+
.AddEdge(textRead, subWorkflowStep)
119+
.AddEdge(subWorkflowStep, charCount)
120+
.WithOutputFrom(charCount)
121+
.Build();
122+
123+
await using Run run = await environment.RunAsync(workflow, text);
124+
125+
foreach (WorkflowEvent evt in run.OutgoingEvents)
126+
{
127+
if (evt is WorkflowOutputEvent outputEvent)
128+
{
129+
writer.WriteLine($"Cross-boundary state result: {outputEvent.As<int>()}");
130+
return null; // Success - no error
131+
}
132+
else if (evt is WorkflowErrorEvent failedEvent)
133+
{
134+
writer.WriteLine($"Workflow failed: {failedEvent.Data}");
135+
return failedEvent.Data as Exception;
136+
}
137+
}
138+
139+
return new InvalidOperationException("No output produced");
140+
}
141+
142+
/// <summary>
143+
/// Executor that reads text and stores it in shared state with a generated key.
144+
/// </summary>
145+
internal sealed class TextReadExecutor() : Executor("TextReadExecutor")
146+
{
147+
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
148+
=> routeBuilder.AddHandler<string, string>(this.HandleAsync);
149+
150+
private async ValueTask<string> HandleAsync(string text, IWorkflowContext context, CancellationToken cancellationToken = default)
151+
{
152+
string key = Guid.NewGuid().ToString();
153+
await context.QueueStateUpdateAsync(key, text, scopeName: WordStateScope, cancellationToken);
154+
return key;
155+
}
156+
}
157+
158+
/// <summary>
159+
/// Executor that reads text from shared state, trims it, and updates the state.
160+
/// </summary>
161+
internal sealed class TextTrimExecutor() : Executor("TextTrimExecutor")
162+
{
163+
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
164+
=> routeBuilder.AddHandler<string, string>(this.HandleAsync);
165+
166+
private async ValueTask<string> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
167+
{
168+
string? content = await context.ReadStateAsync<string>(key, scopeName: WordStateScope, cancellationToken);
169+
if (content is null)
170+
{
171+
throw new InvalidOperationException($"Word state not found for key: {key}");
172+
}
173+
174+
string trimmed = content.Trim();
175+
await context.QueueStateUpdateAsync(key, trimmed, scopeName: WordStateScope, cancellationToken);
176+
return key;
177+
}
178+
}
179+
180+
/// <summary>
181+
/// Executor that reads text from shared state and returns its character count.
182+
/// </summary>
183+
internal sealed class CharCountingExecutor() : Executor("CharCountingExecutor")
184+
{
185+
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
186+
=> routeBuilder.AddHandler<string, int>(this.HandleAsync);
187+
188+
private async ValueTask<int> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
189+
{
190+
string? content = await context.ReadStateAsync<string>(key, scopeName: WordStateScope, cancellationToken);
191+
return content?.Length ?? 0;
192+
}
193+
}
194+
}

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)