-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Expand file tree
/
Copy pathPolymorphicOutputTests.cs
More file actions
276 lines (231 loc) · 10.5 KB
/
PolymorphicOutputTests.cs
File metadata and controls
276 lines (231 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Regression tests for polymorphic output type handling in workflows.
/// Verifies that executors can return derived types when the declared output type is a base class.
/// </summary>
/// <remarks>
/// This addresses GitHub issue #4134: InvalidOperationException when returning derived type as workflow output.
/// </remarks>
public partial class PolymorphicOutputTests
{
#region Test Type Hierarchy
/// <summary>
/// Base class used as declared output type.
/// </summary>
public class BaseOutput
{
public virtual string Name => "BaseOutput";
}
/// <summary>
/// Derived class returned at runtime.
/// </summary>
public class DerivedOutput : BaseOutput
{
public override string Name => "DerivedOutput";
}
/// <summary>
/// Second-level derived class for testing multiple inheritance levels.
/// </summary>
public class GrandchildOutput : DerivedOutput
{
public override string Name => "GrandchildOutput";
}
/// <summary>
/// Unrelated class that should NOT be accepted as output.
/// </summary>
public class UnrelatedOutput
{
public string Name => "UnrelatedOutput";
}
#endregion
#region Test Executors
/// <summary>
/// Executor that declares BaseOutput as yield type but returns DerivedOutput.
/// </summary>
internal sealed class DerivedOutputExecutor() : Executor(nameof(DerivedOutputExecutor))
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string, BaseOutput>(this.HandleAsync));
}
private async ValueTask<BaseOutput> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
{
await Task.Delay(10, cancellationToken);
// Arrange: Return a derived type where the method signature declares the base type
return new DerivedOutput();
}
}
/// <summary>
/// Executor that declares BaseOutput as yield type but returns GrandchildOutput (two levels deep).
/// </summary>
internal sealed class GrandchildOutputExecutor() : Executor(nameof(GrandchildOutputExecutor))
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string, BaseOutput>(this.HandleAsync));
}
private async ValueTask<BaseOutput> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
{
await Task.Delay(10, cancellationToken);
// Arrange: Return a grandchild type (two inheritance levels)
return new GrandchildOutput();
}
}
/// <summary>
/// Executor that attempts to return an unrelated type - should fail validation.
/// This executor intentionally bypasses type safety to test runtime validation.
/// </summary>
internal sealed class UnrelatedOutputExecutor() : Executor(nameof(UnrelatedOutputExecutor))
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string, BaseOutput>(this.HandleAsync));
}
private async ValueTask<BaseOutput> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
{
// Arrange: Attempt to yield an unrelated type - should throw
UnrelatedOutput unrelated = new();
await context.YieldOutputAsync(unrelated, cancellationToken).ConfigureAwait(false);
// This line should not be reached
return new BaseOutput();
}
}
/// <summary>
/// Executor that returns the exact declared type (baseline test).
/// </summary>
internal sealed class ExactTypeExecutor() : Executor(nameof(ExactTypeExecutor))
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string, BaseOutput>(this.HandleAsync));
}
private ValueTask<BaseOutput> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
{
BaseOutput result = new();
return new ValueTask<BaseOutput>(result);
}
}
#endregion
#region Tests
/// <summary>
/// Verifies that returning a derived type when the declared output type is a base class succeeds.
/// This is the main regression test for GitHub issue #4134.
/// </summary>
[Fact]
public async Task ReturningDerivedType_WhenBaseTypeIsDeclared_ShouldSucceedAsync()
{
// Arrange
DerivedOutputExecutor executor = new();
WorkflowBuilder builder = new WorkflowBuilder(executor).WithOutputFrom(executor);
Workflow workflow = builder.Build();
// Act
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "test input");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert
events.Should().NotBeEmpty("workflow should produce events");
List<WorkflowOutputEvent> outputEvents = events.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().ContainSingle("workflow should produce exactly one output event");
WorkflowOutputEvent outputEvent = outputEvents.Single();
outputEvent.Data.Should().BeOfType<DerivedOutput>("output should be the derived type");
((DerivedOutput)outputEvent.Data!).Name.Should().Be("DerivedOutput");
// Verify no error events
List<WorkflowErrorEvent> errorEvents = events.OfType<WorkflowErrorEvent>().ToList();
errorEvents.Should().BeEmpty("workflow should not produce error events");
}
/// <summary>
/// Verifies that returning a grandchild type (multiple inheritance levels) succeeds.
/// </summary>
[Fact]
public async Task ReturningGrandchildType_WhenBaseTypeIsDeclared_ShouldSucceedAsync()
{
// Arrange
GrandchildOutputExecutor executor = new();
WorkflowBuilder builder = new WorkflowBuilder(executor).WithOutputFrom(executor);
Workflow workflow = builder.Build();
// Act
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "test input");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert
events.Should().NotBeEmpty("workflow should produce events");
List<WorkflowOutputEvent> outputEvents = events.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().ContainSingle("workflow should produce exactly one output event");
WorkflowOutputEvent outputEvent = outputEvents.Single();
outputEvent.Data.Should().BeOfType<GrandchildOutput>("output should be the grandchild type");
((GrandchildOutput)outputEvent.Data!).Name.Should().Be("GrandchildOutput");
// Verify no error events
List<WorkflowErrorEvent> errorEvents = events.OfType<WorkflowErrorEvent>().ToList();
errorEvents.Should().BeEmpty("workflow should not produce error events");
}
/// <summary>
/// Verifies that returning an unrelated type still throws InvalidOperationException.
/// This ensures the fix doesn't break the existing validation for truly incompatible types.
/// </summary>
[Fact]
public async Task ReturningUnrelatedType_WhenBaseTypeIsDeclared_ShouldFailAsync()
{
// Arrange
UnrelatedOutputExecutor executor = new();
WorkflowBuilder builder = new WorkflowBuilder(executor).WithOutputFrom(executor);
Workflow workflow = builder.Build();
// Act
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "test input");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert: Should have an error event with InvalidOperationException message
List<WorkflowErrorEvent> errorEvents = events.OfType<WorkflowErrorEvent>().ToList();
errorEvents.Should().ContainSingle("workflow should produce exactly one error event");
WorkflowErrorEvent errorEvent = errorEvents.Single();
string errorMessage = errorEvent.Data?.ToString() ?? string.Empty;
errorMessage.Should().Contain("Cannot output object of type UnrelatedOutput");
errorMessage.Should().Contain("BaseOutput");
}
/// <summary>
/// Verifies that returning the exact declared type still works (baseline test).
/// </summary>
[Fact]
public async Task ReturningExactType_WhenSameTypeIsDeclared_ShouldSucceedAsync()
{
// Arrange: Create an executor that returns the exact declared type
ExactTypeExecutor executor = new();
WorkflowBuilder builder = new WorkflowBuilder(executor).WithOutputFrom(executor);
Workflow workflow = builder.Build();
// Act
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "test input");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert
events.Should().NotBeEmpty("workflow should produce events");
List<WorkflowOutputEvent> outputEvents = events.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().ContainSingle("workflow should produce exactly one output event");
WorkflowOutputEvent outputEvent = outputEvents.Single();
outputEvent.Data.Should().BeOfType<BaseOutput>("output should be the exact base type");
// Verify no error events
List<WorkflowErrorEvent> errorEvents = events.OfType<WorkflowErrorEvent>().ToList();
errorEvents.Should().BeEmpty("workflow should not produce error events");
}
#endregion
}