Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs
Jacob Alber 8ed2159c4b .NET: Workflow Outputs Overhaul: Support Tagging, Filtering Agent Outputs (#6045)
* test: reshuffle .NET Workflow tests in preparation for Outputs overhaul

Phase 1 of the .NET Workflows outputs overhaul (see
working/implementation-plan.md). Pure moves/renames in
dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests; no production code
changes, no new test cases. The split keeps each orchestration mode in
its own source file so the upcoming tag-aware and orchestration-default
test additions land on clean diffs.

Renames:
* WorkflowBuilderSmokeTests.cs -> WorkflowBuilderTests.cs (with class
  rename to match). The scope is no longer "smoke"-only once subsequent
  phases add tag-aware builder tests.
* InputWaiterAndOutputFilterTests.cs -> InputWaiterTests.cs +
  OutputFilterTests.cs. The file already declared the two test classes
  separately; this split simply gives each its own file so the
  output-filter cases have a dedicated home for tag-aware additions.

Split of AgentWorkflowBuilderTests.cs:
* AgentWorkflowBuilderTests.cs is now the outer
  `public static partial class AgentWorkflowBuilderTests` holding the
  shared test helpers (DoubleEchoAgent + session + WithBarrier variant,
  WorkflowRunResult, RunWorkflow* methods) bumped from `private` to
  `internal` so the new top-level GroupChatWorkflowBuilderTests in the
  same assembly can reach them.
* AgentWorkflowBuilder.SequentialTests.cs (nested SequentialTests):
  BuildSequential_InvalidArguments_Throws,
  BuildSequential_AgentsRunInOrderAsync.
* AgentWorkflowBuilder.ConcurrentTests.cs (nested ConcurrentTests):
  BuildConcurrent_InvalidArguments_Throws,
  BuildConcurrent_AgentsRunInParallelAsync.

Sequential and Concurrent are kept as nested classes because they're
modes of the same `AgentWorkflowBuilder` static factory and do not
produce dedicated builder types.

New file:
* GroupChatWorkflowBuilderTests.cs (top-level): the existing
  BuildGroupChat_* and GroupChatManager_* cases moved out of the old
  AgentWorkflowBuilderTests file. They exercise the
  `GroupChatWorkflowBuilder` type (returned by
  `AgentWorkflowBuilder.CreateGroupChatBuilderWith`), so a dedicated
  top-level test class - matching the convention reserved by the plan
  for HandoffWorkflowBuilderTests / MagenticWorkflowBuilderTests - is
  the right home. Cross-class helper references qualify with
  `AgentWorkflowBuilderTests.DoubleEchoAgent` and
  `AgentWorkflowBuilderTests.RunWorkflowAsync`.

The outer partial class is `static` (and nested classes carry the
instance test methods) because the outer holds only static helpers;
this satisfies CA1052 without suppressions and is invisible to xUnit
discovery, which finds tests on the nested classes as
`AgentWorkflowBuilderTests.SequentialTests.*` etc.

Validation: `dotnet build` clean on both target frameworks; all 547
tests in Microsoft.Agents.AI.Workflows.UnitTests pass on net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: introduce OutputTag, Futures, and tag-aware WorkflowBuilder API

Phase 2 of the .NET Workflows outputs overhaul. Additive code change
only - no observable runtime behavior change. The runner still uses the
legacy bypass for AgentResponse / AgentResponseUpdate payloads, and the
new `Futures.EnableAgentResponseOutputTaggingAndFiltering` flag defaults
to false. Phase 3 will wire the flag into the runner; this commit only
introduces the types and the builder API.

New public surface:
* `OutputTag` (readonly struct): wraps a string Value with ordinal
  equality (IEquatable, GetHashCode, == / !=) so it can participate as a
  HashSet element. Internal ctor closes the set. One public singleton:
  `OutputTag.Intermediate`. Terminal / regular outputs carry no tag
  (empty Tags set). JSON-serialized as a bare string via
  [JsonConverter(typeof(OutputTagJsonConverter))], with the converter
  rehydrating to the well-known singleton on read.
* `Futures` (static class): hosts opt-in pre-GA behavior switches.
  First flag is `EnableAgentResponseOutputTaggingAndFiltering`; XML doc
  captures the v2.0.0 obsoletion / v3.0.0 removal lifecycle.
* `WorkflowOutputEvent.Tags`: `HashSet<OutputTag>` exposed directly
  (concrete collection, matches the JSON-serialization convention used
  for `WorkflowInfo.OutputExecutorIds`). Never null; empty for legacy /
  terminal events. New ctors take a single `OutputTag` or
  `IEnumerable<OutputTag>?`; the existing (data, executorId) ctor
  remains and produces an untagged event. `HasTag(OutputTag)` helper.
  `AgentResponseEvent` and `AgentResponseUpdateEvent` gain matching
  tag-accepting ctors forwarding to the base.
* `WorkflowOutputEventExtensions.IsIntermediate(this WorkflowOutputEvent)`:
  extension method returning `evt.HasTag(OutputTag.Intermediate)`. The
  preferred way to ask "is this an intermediate output?" without
  reaching into the Tags set.
* `WorkflowBuilder.WithOutputFrom(IEnumerable<ExecutorBinding>, OutputTag)`
  and `WorkflowBuilder.WithOutputFrom(ExecutorBinding, OutputTag)`:
  forward-looking tagged overloads. The IEnumerable form is the primary
  tagged surface; the single-executor form is a convenience for the
  common one-executor case. Currently usable for the
  `OutputTag.Intermediate` singleton; will become the primary surface
  once the `OutputTag` constructor is opened to user-defined tags in
  a future release. Callers in this release should prefer the
  intent-specific `WithIntermediateOutputFrom` extension for the
  intermediate case. Tags accumulate across repeated calls; same tag
  repeated dedupes via the HashSet.
* `WorkflowBuilderExtensions.WithIntermediateOutputFrom(this WorkflowBuilder, IEnumerable<ExecutorBinding>)`:
  helper that forwards to `WithOutputFrom(executors, OutputTag.Intermediate)`.
  Takes an IEnumerable (matching the tagged WithOutputFrom shape) -
  callers pass collection literals: `builder.WithIntermediateOutputFrom([a, b])`.
  XML doc remarks call out the Futures-flag interaction and the
  AIAgent-payload forwarding contract.

Internal shape changes:
* `WorkflowBuilder._outputExecutors`: HashSet<string> -> Dictionary<
  string, HashSet<OutputTag>>. The value set is empty for executors
  designated only via the untagged WithOutputFrom; contains Intermediate
  (and possibly future tags) otherwise.
* `Workflow.OutputExecutors`: HashSet<string> -> Dictionary<string,
  HashSet<OutputTag>>.
* `OutputFilter.CanOutput`: `Contains(id)` -> `ContainsKey(id)`.
* `WorkflowInfo.OutputExecutorIds`: HashSet<string> -> Dictionary<
  string, HashSet<OutputTag>>, with a custom JsonConverter that reads
  both the new map shape (`{id: ["intermediate", ...]}`) and the legacy
  array shape (`[id1, id2]`, where each id is treated as an untagged
  output). Always writes the map shape. IsMatch updated to compare
  per-id tag sets.

Tests landing in this commit (per the test-with-feature principle):
* `OutputTagTests.cs` (6 tests): KnownValues, EqualityIsOrdinalOnValue,
  DefaultStructValueIsDistinct (default(OutputTag) does not collide
  with the Intermediate singleton in a HashSet),
  GetHashCodeMatchesEquals, JsonConverter_RoundtripsValueAsString,
  ConstructorIsInternal (reflection-based assertion that the (string)
  ctor is `internal`).
* `WorkflowBuilderTests.cs` adds 7 new tests pinning the builder
  API contract: RegistersWithEmptyTagSet, AddsIntermediateTag,
  MultipleExecutorsAllUntagged, ThenIntermediate_AccumulatesTags,
  RepeatedDedupes, OnlyRegistersWithoutPriorWithOutputFrom,
  TracksExecutorBinding.
* `BackwardsCompatibility/JsonCheckpointSerializationTests.cs`
  (new folder + file, 5 tests): event-level ctor contract tests
  (single-tag, no-tag, multi-tag — the last with a custom tag);
  IsIntermediate() asserted; load-bearing JSON BC tests for
  `WorkflowInfo.OutputExecutorIds` -
  `WorkflowOutputExecutorsReadsLegacyArrayShape` (legacy ids map to
  empty tag sets) and `WorkflowOutputExecutorsWritesMapShape`.

The plan's three JSON round-trip tests for `WorkflowOutputEvent.Tags`
were dropped: `WorkflowEvent` is not currently a serialized checkpoint
shape (see the comment in WorkflowsJsonUtilities.cs about events not
being persisted), so there is no real back-compat surface to pin
through JSON. They are substituted with in-process ctor/property
round-trip tests that exercise the `Tags` / `HasTag` / `IsIntermediate`
contract.

Validation: full `Microsoft.Agents.AI.Workflows.UnitTests` suite runs
green on net10.0 (565 passing, 0 failing). Core library builds clean
on net472, netstandard2.0, net8.0, net9.0, and net10.0. Test project
builds clean on net472 + net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: route AgentResponse(Update) through the output filter under a Futures flag

`InProcessRunnerContext.YieldOutputAsync` historically special-cased AgentResponse and
AgentResponseUpdate payloads: it built the typed event subclass and emitted it directly,
bypassing the output filter. Rewrites the method so that:

- When `Futures.EnableAgentResponseOutputTaggingAndFiltering` is `false` (the current
  default), AgentResponse(Update) keep the legacy bypass — emitted as
  AgentResponseEvent / AgentResponseUpdateEvent with no tags. Existing callers see no
  behavior change.
- When the flag is `true`, AIAgent payloads flow through the output filter just like
  every other payload type: undesignated sources are dropped, and the emitted event
  carries the source's tag set (empty for terminal `WithOutputFrom`, `{Intermediate}`
  for `WithIntermediateOutputFrom`, the set union when both designations apply).

Non-AIAgent (POCO) outputs also now carry the source's tag set on the emitted
WorkflowOutputEvent unconditionally — additive, since no existing assertion inspected
Tags. Subclass events (`AgentResponseEvent` / `AgentResponseUpdateEvent`) continue to
be emitted under both modes so `switch (evt) { case AgentResponseEvent: ... }`
consumer code keeps matching.

Adds `OutputFilter.TryGetTags` as the tag-aware lookup used by the runner.
`OutputFilter.CanOutput` is kept (still used by the existing sync tests in
`OutputFilterTests.cs`).

Tests
-----
- `Futures/Futures.AgentResponseOutputFilteringAndTaggingTests.cs` (new): the F1–F13
  matrix from the plan, covering every combination of `(flag on/off) × (designation)
  × (payload shape)`. Uses a `FuturesScope` IDisposable + a `FuturesSerial` xUnit
  collection (DisableParallelization = true) to keep the process-global flag from
  leaking across parallel tests.
- `OutputFilterTests.cs`: four new `Test_OutputFilter_…` cases for the `TryGetTags`
  surface (empty-tag-set for terminal designation, `{Intermediate}` for intermediate
  designation, union for accumulated designation, `false` for unregistered).

582/582 unit tests pass on net10.0 (565 baseline + 17 new).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: tag-aware defaults and designation API on orchestration builders

Aligns the .NET orchestration builders with Python's output / intermediate-output
distinction. Each builder either applies a Python-aligned default designation set or
replays the user's explicit `WithOutputFrom` / `WithIntermediateOutputFrom` calls,
never both.

Static `AgentWorkflowBuilder.BuildSequential` / `BuildConcurrent` apply defaults
unconditionally (no user-facing fluent surface to take control through):

- Sequential: terminal `end` + every agent designated intermediate.
- Concurrent: terminal `end` + every agent and per-agent accumulator designated
  intermediate.

The three fluent instance builders memoize agent-typed designation calls in a
`Dictionary<AIAgent, HashSet<OutputTag>>` (empty set = terminal-only, non-empty =
intermediate tag(s)) so repeated calls dedupe naturally. They replay the entries
at `Build()` time, suppressing defaults when any call has been made:

- `HandoffWorkflowBuilder` / `HandoffWorkflowBuilderCore<TBuilder>` (also picked up
  by the obsolete `HandoffsWorkflowBuilder` via inheritance).
  Default: terminal `HandoffEnd` + every handoff agent intermediate.
  (Bug fix: legacy code relied on `WithOutputFrom(end)` to bind `HandoffEnd`. The
  new explicit-designation path bypasses that, so `Build()` now calls
  `BindExecutor(end)` unconditionally to keep validation happy.)
- `GroupChatWorkflowBuilder` — default: terminal host + every participant intermediate.
- `MagenticWorkflowBuilder` — default: terminal orchestrator + every team member
  intermediate.

Designating a non-participant agent throws `InvalidOperationException`.

The bare `WorkflowBuilder` default is unchanged — only the orchestration-style
builders gain implicit defaults, matching the plan's non-goal.

Tests
-----
- `AgentWorkflowBuilder.SequentialTests` / `.ConcurrentTests`: one default-spec
  assertion each.
- `GroupChatWorkflowBuilderTests`: defaults-match-spec, explicit-replaces-defaults,
  non-participant throws.
- `HandoffWorkflowBuilderTests` (new file): same three.
- `MagenticWorkflowBuilderTests` (new file): same three.

593/593 unit tests pass on net10.0 (582 baseline + 11 new).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: WorkflowHostAgent forwards AgentResponseEvent unconditionally under Futures-on

Aligns the .NET Workflow-as-Agent surface with Python `as_agent`. Under
`Futures.EnableAgentResponseOutputTaggingAndFiltering = true`,
`WorkflowSession.InvokeStageAsync` now forwards `AgentResponseEvent`
unconditionally — joining `AgentResponseUpdateEvent` in ignoring the host's
`includeWorkflowOutputsInResponse` switch. That switch keeps governing the
generic `WorkflowOutputEvent` path for non-AIAgent payloads, where it is
further short-circuited by an `IsIntermediate()` check (tagged intermediate
outputs always surface).

Under Futures-off the legacy asymmetry is preserved: `AgentResponseUpdateEvent`
always forwarded, `AgentResponseEvent` gated by `includeWorkflowOutputsInResponse`.

Back-compat: with `Futures.EnableAgentResponseOutputTaggingAndFiltering` left at
its default `false`, observable behavior is identical to before.

`Futures` documentation gains a remark explaining the `Workflow.AsAIAgent()`
interaction in both flag states.

Runner fix
----------
`InProcessRunnerContext.YieldOutputAsync` now skips `Executor.CanOutput` for
AgentResponse-shaped payloads under both Futures branches. `AIAgentHostExecutor`
doesn't declare AgentResponse(Update) in its `Yields` set, so the historical
legacy bypass had silently skipped the check; Phase 3's Futures-on path was
running it and would reject AIAgent payloads. AIAgent-shaped payloads are now
always a valid output shape, matching the legacy bypass semantics.

Phase 4 follow-on
-----------------
Switched the three orchestration-builder designation-replay loops to iterate
`Dictionary.Keys` with a value lookup instead of constructing/destructuring
`KeyValuePair<,>`. Cleaner shape and avoids the netstandard2.0 / net472
`KeyValuePair<,>.Deconstruct` unavailability that surfaced when this branch
multi-TFM-built.

Tests
-----
`WorkflowHostSmokeTests.IntermediateForwarding` (new nested class, 6 tests):
- intermediate AgentResponse forwarded past the include-outputs gate (Futures on)
- terminal AgentResponse forwarded unconditionally (Futures on)
- terminal AgentResponse gated by include flag (Futures off, legacy)
- undesignated AIAgent executor emits no AgentResponseEvent under Futures-on
- legacy bypass still emits AgentResponseEvent under Futures-off
- intermediate tag is observable via `update.RawRepresentation`

The class joins the `FuturesSerial` xUnit collection so the process-global flag
is serialized against other Futures-toggling tests.

599/599 unit tests pass on net10.0 (593 baseline + 6 new).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: SequentialWorkflowBuilder and ConcurrentWorkflowBuilder, OrchestrationBuilderBase

Promotes the Sequential and Concurrent orchestration shapes to first-class fluent
builder classes, matching Handoff / GroupChat / Magentic. Users can call
`WithOutputFrom(agents)` / `WithIntermediateOutputFrom(agents)` to control which
agents are designated output / intermediate sources; when no designation call is
made, the Python-aligned defaults apply (terminal aggregator output + every agent
intermediate; Concurrent also tags per-agent accumulators).

`AgentWorkflowBuilder.BuildSequential(...)` and `BuildConcurrent(...)` are kept
and now delegate to the new builders; observable behavior unchanged. Five static
factories now mirror each other:

- `AgentWorkflowBuilder.CreateSequentialBuilderWith(params IEnumerable<AIAgent>)`
- `AgentWorkflowBuilder.CreateConcurrentBuilderWith(params IEnumerable<AIAgent>)`
- `AgentWorkflowBuilder.CreateHandoffBuilderWith(AIAgent)`        (already existed)
- `AgentWorkflowBuilder.CreateGroupChatBuilderWith(Func<...>)`    (already existed)
- `AgentWorkflowBuilder.CreateMagenticBuilderWith(AIAgent)`       (new)

OrchestrationBuilderBase
------------------------
New abstract `OrchestrationBuilderBase<TBuilder>` unifies the shared fluent
surface across all five orchestration builders: `WithName`, `WithDescription`,
`WithOutputFrom`, `WithIntermediateOutputFrom`, and the
`ApplyOutputDesignations(builder, agentMap, kind, applyDefaults)` helper that
either replays the user's designations or invokes the orchestration-specific
defaults.

Removes ~150 LOC of duplicated designation-management code from the four
non-Handoff builders, plus the equivalent from `HandoffWorkflowBuilderCore`.

Tests
-----
- New `SequentialWorkflowBuilderTests.cs` / `ConcurrentWorkflowBuilderTests.cs`
  (replace the old `AgentWorkflowBuilder.{Sequential,Concurrent}Tests.cs`
  nested-class files). Method names normalized to
  `Test_<BuilderType>_<Scenario>[Async]`.
- Shared helpers (`DoubleEchoAgent`, `DoubleEchoAgentWithBarrier`,
  `WorkflowRunResult`, `RunWorkflow*`) moved from the old
  `AgentWorkflowBuilderTests` partial class into a new
  `OrchestrationTestHelpers` static class in `OrchestrationTestHelpers.cs`.
  Downstream test files (Group Chat, Handoff, Sequential, Concurrent) updated
  to qualify with `OrchestrationTestHelpers.*`.
- A new `AgentWorkflowBuilderTests.cs` covers the static surface directly:
  `BuildSequential` / `BuildConcurrent` invariants and aggregator wiring, plus
  null-rejection + round-trip checks for every `Create*BuilderWith` factory.
- New AsAgent intermediate-suppression tests on a nested `AsAgentForwarding`
  class for each of Sequential and Concurrent: build with only the terminal
  agent designated via `WithOutputFrom`, run via `AsAIAgent(...)`, assert via
  `AgentResponseUpdate.AuthorName` that intermediate agents do not surface.
  Both join the `FuturesSerial` collection.
- New `Test_<Builder>_WithDescriptionPropagatesToWorkflow` smoke tests on
  Sequential and Concurrent (newly available via the base class).

625/625 unit tests pass on net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: dotnet format

* fixup: encoding

* fixup: charset

* fixup: Updates for PR feedback

* fixup: format

* fixup: merge issue

* Fix intermediate filtering on .AsAgent()

* fix filter logic

* fix: Revert logic change and add comments

---------

Co-authored-by: Jacob Alber <jalber@lokitoth.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:26:31 +00:00

923 lines
36 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class JsonSerializationTests
{
private static JsonSerializerOptions TestCustomSerializedJsonOptions
{
get
{
JsonSerializerOptions options = new(TestJsonContext.Default.Options);
options.MakeReadOnly();
return options;
}
}
private static int s_nextEdgeId;
private static EdgeId TakeEdgeId() => new(Interlocked.Increment(ref s_nextEdgeId));
internal static T RunJsonRoundtrip<T>(T value, JsonSerializerOptions? externalOptions = null, Expression<Func<T, bool>>? predicate = null)
{
JsonMarshaller marshaller = new(externalOptions);
JsonElement element = marshaller.Marshal(value);
T deserialized = marshaller.Marshal<T>(element);
if (deserialized is not null)
{
if (predicate is not null)
{
deserialized.Should().Match(predicate);
}
return deserialized;
}
Debug.Fail($"Could not roundtrip type '{typeof(T).Name}'. JSON = '{element}'.");
throw new NotSupportedException($"Could not roundtrip type '{typeof(T).Name}'.");
}
[Fact]
public void Test_EdgeConnection_JsonRoundtrip()
{
EdgeConnection connection = new(["Source1", "Source2"], ["Sink1", "Sink2"]);
RunJsonRoundtrip(connection, predicate: connection.CreateValidator());
}
[Fact]
public void Test_TypeId_JsonRoundtrip()
{
TypeId type = new(typeof(Type));
RunJsonRoundtrip(type, predicate: CreateValidator());
Expression<Func<TypeId, bool>> CreateValidator()
{
return deserialized => deserialized.AssemblyName == type.AssemblyName &&
deserialized.TypeName == type.TypeName &&
deserialized.IsMatch<Type>();
}
}
[Fact]
public void Test_ExecutorInfo_JsonRoundtrip()
{
ExecutorInfo executorInfo = new(new(typeof(ForwardMessageExecutor<string>)), "ForwardString");
RunJsonRoundtrip(executorInfo, predicate: CreateValidator());
Expression<Func<ExecutorInfo, bool>> CreateValidator()
{
return deserialized => deserialized.ExecutorId == executorInfo.ExecutorId &&
// Rely on the TypeId test to probe TypeId serialization - just validate that we got a functional TypeId
deserialized.ExecutorType.IsMatch<ForwardMessageExecutor<string>>();
}
}
private static RequestPort TestPort => RequestPort.Create<string, int>("StringToInt");
private static RequestPortInfo TestPortInfo => TestPort.ToPortInfo();
[Fact]
public void Test_RequestPortInfo_JsonRoundtrip()
{
RunJsonRoundtrip(TestPortInfo, predicate: TestPort.CreatePortInfoValidator());
}
private static DirectEdgeInfo TestDirectEdgeInfo_NoCondition => new(new("SourceExecutor", "TargetExecutor", TakeEdgeId(), condition: null));
private static DirectEdgeInfo TestDirectEdgeInfo_Condition => new(new("SourceExecutor", "TargetExecutor", TakeEdgeId(), condition: msg => msg is not null));
[Fact]
public void Test_DirectEdgeInfo_JsonRoundtrip()
{
RunJsonRoundtrip(TestDirectEdgeInfo_NoCondition, predicate: TestDirectEdgeInfo_NoCondition.CreateValidator());
RunJsonRoundtrip(TestDirectEdgeInfo_Condition, predicate: TestDirectEdgeInfo_Condition.CreateValidator());
}
private static FanOutEdgeInfo TestFanOutEdgeInfo_NoAssigner => new(new("SourceExecutor", ["TargetExecutor1", "TargetExecutor2"], TakeEdgeId(), assigner: null));
private static FanOutEdgeInfo TestFanOutEdgeInfo_Assigner => new(new("SourceExecutor", ["TargetExecutor1", "TargetExecutor2"], TakeEdgeId(), assigner: (msg, count) => []));
[Fact]
public void Test_FanOutEdgeInfo_JsonRoundtrip()
{
RunJsonRoundtrip(TestFanOutEdgeInfo_NoAssigner, predicate: TestFanOutEdgeInfo_NoAssigner.CreateValidator());
RunJsonRoundtrip(TestFanOutEdgeInfo_Assigner, predicate: TestFanOutEdgeInfo_Assigner.CreateValidator());
}
private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId(), null);
private static FanInEdgeInfo TestFanInEdgeInfo => new(TestFanInEdgeData);
[Fact]
public void Test_FanInEdgeInfo_JsonRoundtrip()
{
RunJsonRoundtrip(TestFanInEdgeInfo, predicate: TestFanInEdgeInfo.CreateValidator());
}
private static EdgeInfo TestEdgeInfo_DirectNoCondition { get; } = TestDirectEdgeInfo_NoCondition;
private static EdgeInfo TestEdgeInfo_DirectCondition { get; } = TestDirectEdgeInfo_Condition;
private static EdgeInfo TestEdgeInfo_FanOutNoAssigner { get; } = TestFanOutEdgeInfo_NoAssigner;
private static EdgeInfo TestEdgeInfo_FanOutAssigner { get; } = TestFanOutEdgeInfo_Assigner;
private static EdgeInfo TestEdgeInfo_FanIn { get; } = TestFanInEdgeInfo;
[Fact]
public void Test_EdgeInfoPolymorphism_JsonRoundtrip()
{
RunJsonRoundtrip(TestEdgeInfo_DirectNoCondition, predicate: TestEdgeInfo_DirectNoCondition.CreatePolyValidator());
RunJsonRoundtrip(TestEdgeInfo_DirectCondition, predicate: TestEdgeInfo_DirectCondition.CreatePolyValidator());
RunJsonRoundtrip(TestEdgeInfo_FanOutNoAssigner, predicate: TestEdgeInfo_FanOutNoAssigner.CreatePolyValidator());
RunJsonRoundtrip(TestEdgeInfo_FanOutAssigner, predicate: TestEdgeInfo_FanOutAssigner.CreatePolyValidator());
RunJsonRoundtrip(TestEdgeInfo_FanIn, predicate: TestEdgeInfo_FanIn.CreatePolyValidator());
}
private const string ForwardStringId = nameof(s_forwardString);
private const string ForwardIntId = nameof(s_forwardInt);
private static readonly ExecutorIdentity s_forwardString = new() { Id = ForwardStringId };
private static readonly ExecutorIdentity s_forwardInt = new() { Id = ForwardIntId };
private const string IntToStringId = nameof(IntToString);
private const string StringToIntId = nameof(StringToInt);
private static RequestPortInfo IntToString => RequestPort.Create<int, string>(IntToStringId).ToPortInfo();
private static RequestPortInfo StringToInt => RequestPort.Create<string, int>(StringToIntId).ToPortInfo();
private static Workflow CreateTestWorkflow()
{
ForwardMessageExecutor<string> forwardString = new(ForwardStringId);
ForwardMessageExecutor<int> forwardInt = new(ForwardIntId);
RequestPort stringToInt = RequestPort.Create<string, int>(StringToIntId);
RequestPort intToString = RequestPort.Create<int, string>(IntToStringId);
WorkflowBuilder builder = new(forwardString);
builder.AddEdge(forwardString, stringToInt)
.AddEdge(stringToInt, forwardInt)
.AddEdge(forwardInt, intToString)
.AddEdge(intToString, StreamingAggregators.Last<int>().BindAsExecutor("Aggregate"));
return builder.Build();
}
internal static WorkflowInfo CreateTestWorkflowInfo()
{
Workflow testWorkflow = CreateTestWorkflow();
return testWorkflow.ToWorkflowInfo();
}
private static void ValidateWorkflowInfo(WorkflowInfo actual, WorkflowInfo prototype)
{
ValidateExecutorDictionary(prototype.Executors, prototype.Edges, actual.Executors, actual.Edges);
ValidateRequestPorts(prototype.RequestPorts, actual.RequestPorts);
actual.InputType.Should().Match(prototype.InputType.CreateValidator());
actual.StartExecutorId.Should().Be(prototype.StartExecutorId);
actual.OutputExecutorIds.Should().HaveCount(prototype.OutputExecutorIds.Count);
foreach (KeyValuePair<string, HashSet<OutputTag>> kvp in prototype.OutputExecutorIds)
{
actual.OutputExecutorIds.Should().ContainKey(kvp.Key);
actual.OutputExecutorIds[kvp.Key].Should().BeEquivalentTo(kvp.Value);
}
void ValidateExecutorDictionary(Dictionary<string, ExecutorInfo> expected,
Dictionary<string, List<EdgeInfo>> expectedEdges,
Dictionary<string, ExecutorInfo> actual,
Dictionary<string, List<EdgeInfo>> actualEdges)
{
actual.Should().HaveCount(expected.Count);
actualEdges.Should().HaveCount(expectedEdges.Count);
foreach (string key in expected.Keys)
{
actual.Should().ContainKey(key);
ExecutorInfo actualValue = actual[key];
ExecutorInfo expectedValue = expected[key];
actualValue.Should().Match(expectedValue.CreateValidator());
if (expectedEdges.TryGetValue(key, out List<EdgeInfo>? expectedEdgeList))
{
List<EdgeInfo>? actualEdgeList = actualEdges.Should().ContainKey(key).WhoseValue;
actualEdgeList.Should().NotBeNull();
ValidateExecutorEdges(expectedEdgeList, actualEdgeList);
}
}
}
void ValidateExecutorEdges(List<EdgeInfo> expected, List<EdgeInfo> actual)
{
actual.Should().HaveCount(expected.Count);
foreach (EdgeInfo expectedEdge in expected)
{
actual.Should().ContainSingle(edge => edge.CreatePolyValidator().Compile()(edge));
}
}
void ValidateRequestPorts(HashSet<RequestPortInfo> expected, HashSet<RequestPortInfo> actual)
=> actual.Should().HaveCount(expected.Count).And.IntersectWith(expected);
}
[Fact]
public async Task Test_WorkflowInfo_JsonRoundtripAsync()
{
WorkflowInfo prototype = CreateTestWorkflowInfo();
JsonMarshaller marshaller = new();
JsonElement jsonElement = marshaller.Marshal(prototype);
WorkflowInfo deserialized = marshaller.Marshal<WorkflowInfo>(jsonElement);
ValidateWorkflowInfo(deserialized, prototype);
}
private static ExecutorIdentity TestIdentity => new() { Id = "Executor1" };
[Fact]
public void Test_ExecutorIdentity_JsonRoundtrip()
{
RunJsonRoundtrip(TestIdentity, predicate: TestIdentity.CreateValidator());
RunJsonRoundtrip(ExecutorIdentity.None, predicate: ExecutorIdentity.None.CreateValidator());
}
private static ScopeId TestScopeId_Private => new("Executor1", null);
private static ScopeId TestScopeId_Public => new("Executor1", "Scope1");
[Fact]
public void Test_ScopeId_JsonRoundtrip()
{
RunJsonRoundtrip(TestScopeId_Private, predicate: TestScopeId_Private.CreateValidator());
RunJsonRoundtrip(TestScopeId_Public, predicate: TestScopeId_Public.CreateValidator());
}
private static ScopeKey TestScopeKey_Private => new(TestScopeId_Private, "Key1");
private static ScopeKey TestScopeKey_Public => new(TestScopeId_Public, "Key1");
[Fact]
public void Test_ScopeKey_JsonRoundtrip()
{
RunJsonRoundtrip(TestScopeKey_Private, predicate: TestScopeKey_Private.CreateValidator());
RunJsonRoundtrip(TestScopeKey_Public, predicate: TestScopeKey_Public.CreateValidator());
}
private static ExternalRequest TestExternalRequest => ExternalRequest.Create(TestPort, "Request1", "TestData");
[Fact]
public void SanityCheck_JsonTypeInfo()
{
JsonTypeInfo? info = WorkflowsJsonUtilities.JsonContext.Default.GetTypeInfo(typeof(string));
info.Should().NotBeNull();
}
[Fact]
public void Test_PortableValue_JsonRoundtrip_BuiltInType()
{
PortableValue value = new("TestString");
PortableValue result = RunJsonRoundtrip(value);
result.Should().Be(value);
// Also validate that we can extract the value as the correct type
string? extracted = result.As<string>();
extracted.Should().Be("TestString");
// And that we can't extract it as an incorrect type
result.Is<int>().Should().BeFalse();
}
[Fact]
public void Test_PortableValue_JsonRoundTrip_InternalType()
{
ChatMessage message = new(ChatRole.User, "Hello, world!");
PortableValue value = new(message);
PortableValue result = RunJsonRoundtrip(value);
result.Should().Be(value);
// Also validate that we can extract the value as the correct type
ChatMessage? chatMessage = result.As<ChatMessage>();
chatMessage.Should().NotBeNull();
chatMessage.Role.Should().Be(ChatRole.User);
chatMessage.Text.Should().Be("Hello, world!");
// And that we can't extract it as an incorrect type
result.Is<int>().Should().BeFalse();
}
[Fact]
public void Test_PortableValue_JsonRoundTrip_CustomType()
{
TestJsonSerializable test = new() { Id = 42, Name = "Test" };
PortableValue value = new(test);
PortableValue result = RunJsonRoundtrip(value, TestCustomSerializedJsonOptions);
result.Should().Be(value);
// Also validate that we can extract the value as the correct type
TestJsonSerializable? extracted = result.As<TestJsonSerializable>();
extracted.Should().NotBeNull();
extracted.Id.Should().Be(42);
extracted.Name.Should().Be("Test");
// And that we can't extract it as an incorrect type
result.Is<int>().Should().BeFalse();
}
private static void ValidateExternalRequest(ExternalRequest actual, ExternalRequest expected)
{
bool isIdEqual = actual.RequestId == expected.RequestId;
bool isPortEqual = actual.PortInfo == expected.PortInfo;
bool isDataEqual = actual.Data == expected.Data;
isIdEqual.Should().BeTrue();
isPortEqual.Should().BeTrue();
isDataEqual.Should().BeTrue();
}
[Fact]
public void Test_ExternalRequest_JsonRoundtrip()
{
ExternalRequest result = RunJsonRoundtrip(TestExternalRequest);
ValidateExternalRequest(result, TestExternalRequest);
}
private static ExternalResponse TestExternalResponse => TestExternalRequest.CreateResponse(123);
[Fact]
public void Test_ExternalResponse_JsonRoundtrip()
{
ExternalResponse result = RunJsonRoundtrip(TestExternalResponse);
bool isIdEqual = result.RequestId == TestExternalResponse.RequestId;
bool isPortEqual = result.PortInfo == TestExternalResponse.PortInfo;
bool isDataEqual = result.Data == TestExternalResponse.Data;
isIdEqual.Should().BeTrue();
isPortEqual.Should().BeTrue();
isDataEqual.Should().BeTrue();
}
[Fact]
public void Test_PortableMessageEnvelope_JsonRoundtrip_BuiltInType()
{
const string Message = "TestMessage";
MessageEnvelope envelope = new(Message, "Source1", new TypeId(typeof(object)), targetId: "Target1");
PortableMessageEnvelope value = new(envelope);
PortableMessageEnvelope result = RunJsonRoundtrip(value);
bool isTypeEqual = result.MessageType == value.MessageType;
bool isTargetEqual = result.TargetId == value.TargetId;
bool isMessageEqual = result.Message == value.Message;
isTypeEqual.Should().BeTrue();
isTargetEqual.Should().BeTrue();
isMessageEqual.Should().BeTrue();
MessageEnvelope reconstructed = result.ToMessageEnvelope();
reconstructed.MessageType.Should().Be(envelope.MessageType);
reconstructed.TargetId.Should().Be(envelope.TargetId);
reconstructed.Message.Should().Be(envelope.Message);
}
[Fact]
public void Test_PortableMessageEnvelope_JsonRoundtrip_InternalType()
{
ChatMessage message = new(ChatRole.User, "Hello, world!");
MessageEnvelope envelope = new(message, "Source1", new TypeId(typeof(object)), targetId: "Target1");
PortableMessageEnvelope value = new(envelope);
PortableMessageEnvelope result = RunJsonRoundtrip(value);
bool isTypeEqual = result.MessageType == value.MessageType;
bool isTargetEqual = result.TargetId == value.TargetId;
bool isMessageEqual = result.Message == value.Message;
isTypeEqual.Should().BeTrue();
isTargetEqual.Should().BeTrue();
isMessageEqual.Should().BeTrue();
MessageEnvelope reconstructed = result.ToMessageEnvelope();
reconstructed.MessageType.Should().Be(envelope.MessageType);
reconstructed.TargetId.Should().Be(envelope.TargetId);
// Unfortunately, ChatMessage does not contain an "equality" comparer, so we need to explicitly pull it out
// Simulate what PortableValue does in .Equals()
Type expectedType = envelope.Message.GetType();
object? maybeReconstructedMessage = ((PortableValue)reconstructed.Message)!.AsType(expectedType);
maybeReconstructedMessage.Should().NotBeNull()
.And.BeOfType<ChatMessage>()
.And.Match(message.CreateValidatorCheckingText());
}
[Fact]
public void Test_PortableMessageEnvelope_JsonRoundtrip_CustomType()
{
TestJsonSerializable message = new() { Id = 42, Name = "Test" };
MessageEnvelope envelope = new(message, "Source1", new TypeId(typeof(object)), targetId: "Target1");
PortableMessageEnvelope value = new(envelope);
PortableMessageEnvelope result = RunJsonRoundtrip(value, TestCustomSerializedJsonOptions);
bool isTypeEqual = result.MessageType == value.MessageType;
bool isTargetEqual = result.TargetId == value.TargetId;
bool isMessageEqual = result.Message == value.Message;
isTypeEqual.Should().BeTrue();
isTargetEqual.Should().BeTrue();
isMessageEqual.Should().BeTrue();
MessageEnvelope reconstructed = result.ToMessageEnvelope();
reconstructed.MessageType.Should().Be(envelope.MessageType);
reconstructed.TargetId.Should().Be(envelope.TargetId);
reconstructed.Message.Should().Be(envelope.Message);
}
private static RunnerStateData TestRunnerStateData
{
get
{
return new(
[ForwardStringId, ForwardIntId],
CreateQueuedMessages(),
outstandingRequests: [TestExternalRequest]
);
static Dictionary<string, List<PortableMessageEnvelope>> CreateQueuedMessages()
{
Dictionary<string, List<PortableMessageEnvelope>> result = [];
MessageEnvelope internalEnvelope = new("InternalMessage", "TestExecutor1");
result.Add("TestExecutor2", [new(internalEnvelope)]);
return result;
}
}
}
private static void ValidateRunnerStateData(RunnerStateData result, RunnerStateData prototype)
{
Assert.Collection(result.InstantiatedExecutors,
prototype.InstantiatedExecutors.Select(
prototype =>
(Action<string>)(actual => actual.Should().Be(prototype))).ToArray());
result.QueuedMessages.Should().HaveCount(prototype.QueuedMessages.Count);
foreach (string key in prototype.QueuedMessages.Keys)
{
result.QueuedMessages.Should().ContainKey(key);
List<PortableMessageEnvelope> actualList = result.QueuedMessages[key];
List<PortableMessageEnvelope> expectedList = prototype.QueuedMessages[key];
actualList.Should().HaveCount(expectedList.Count);
for (int i = 0; i < expectedList.Count; i++)
{
PortableMessageEnvelope actual = actualList[i];
PortableMessageEnvelope expected = expectedList[i];
actual.MessageType.Should().Be(expected.MessageType);
actual.TargetId.Should().Be(expected.TargetId);
actual.Message.Should().Be(expected.Message);
}
}
result.OutstandingRequests.Should().HaveCount(prototype.OutstandingRequests.Count);
Assert.Collection(result.OutstandingRequests,
prototype.OutstandingRequests.Select(
expected =>
(Action<ExternalRequest>)(actual => ValidateExternalRequest(actual, expected))).ToArray());
}
[Fact]
public void Test_RunnerStateData_JsonRoundtrip()
{
RunnerStateData prototype = TestRunnerStateData;
RunnerStateData result = RunJsonRoundtrip(prototype);
ValidateRunnerStateData(result, prototype);
}
private static FanInEdgeState TestFanInEdgeState => new(TestFanInEdgeData);
private static PortableValue CreateEdgeState<TMessage>(TMessage message) where TMessage : notnull
{
FanInEdgeState state = TestFanInEdgeState;
_ = state.ProcessMessage("SourceExecutor1", new MessageEnvelope(message, "SourceExecutor1", typeof(TMessage)));
return new(state);
}
private static TestJsonSerializable TestCustomSerializable => new() { Id = 42, Name = nameof(TestCustomSerializable) };
private static Dictionary<EdgeId, PortableValue> TestEdgeState
{
get
{
return new()
{
[TakeEdgeId()] = CreateEdgeState("Hello, world!"),
[TakeEdgeId()] = CreateEdgeState(TestExternalResponse),
[TakeEdgeId()] = CreateEdgeState(TestCustomSerializable)
};
}
}
private static void ValidateEdgeStateData(Dictionary<EdgeId, PortableValue> result, Dictionary<EdgeId, PortableValue> prototype)
{
result.Should().HaveCount(prototype.Count);
foreach (EdgeId id in prototype.Keys)
{
result.Should().ContainKey(id)
.And.Subject[id].Should().Be(prototype[id])
.And.Subject.As<PortableValue>()
.As<FanInEdgeState>().Should().NotBeNull()
.And.Match(CreateValidator(prototype[id].As<FanInEdgeState>()!));
}
Expression<Func<FanInEdgeState, bool>> CreateValidator(FanInEdgeState prototype)
{
return actual => actual.Unseen.SetEquals(prototype.Unseen) &&
actual.SourceIds.SequenceEqual(prototype.SourceIds) &&
actual.PendingMessages.Zip(prototype.PendingMessages,
(actualMessage, expectedMessage) => actualMessage.MessageType == expectedMessage.MessageType &&
actualMessage.TargetId == expectedMessage.TargetId &&
actualMessage.Message.Equals(expectedMessage.Message)).All(v => v);
}
}
[Fact]
public void Test_EdgeStateData_JsonRoundtrip()
{
Dictionary<EdgeId, PortableValue> value = TestEdgeState;
Dictionary<EdgeId, PortableValue> result = RunJsonRoundtrip(value, TestCustomSerializedJsonOptions);
ValidateEdgeStateData(result, value);
}
private static ScopeKey TestScopeKey1 => new(StringToIntId, null, "Key1");
private static ScopeKey TestScopeKey2 => new(StringToIntId, "Shared", "Key2");
private static ScopeKey TestScopeKey3 => new(IntToStringId, "Shared", "Key3");
private static ChatMessage TestUserMessage => new(ChatRole.User, "Hello");
private static Dictionary<ScopeKey, PortableValue> TestStateData
{
get
{
return new()
{
[TestScopeKey1] = new("Lorem Ipsum"),
[TestScopeKey2] = new(TestUserMessage),
[TestScopeKey3] = new(TestCustomSerializable)
};
}
}
private static void ValidateStateData(Dictionary<ScopeKey, PortableValue> result, Dictionary<ScopeKey, PortableValue> prototype)
{
result.Should().HaveCount(prototype.Count);
foreach (ScopeKey key in prototype.Keys)
{
PortableValue state =
result.Should().ContainKey(key)
.And.Subject[key].Should().Be(prototype[key])
.And.Subject.As<PortableValue>();
switch (key.Key)
{
case "Key1":
state.As<string>().Should().Be("Lorem Ipsum");
break;
case "Key2":
ChatMessage? maybeMessage = state.As<ChatMessage>();
maybeMessage.Should().NotBeNull()
.And.Match(TestUserMessage.CreateValidatorCheckingText());
break;
case "Key3":
state.As<TestJsonSerializable>().Should().Be(TestCustomSerializable);
break;
default:
throw new NotImplementedException($"Missing validation for key '{key.Key}'");
}
}
}
[Fact]
public void Test_ExecutorStateData_JsonRoundTrip()
{
Dictionary<ScopeKey, PortableValue> value = TestStateData;
Dictionary<ScopeKey, PortableValue> result = RunJsonRoundtrip(value, TestCustomSerializedJsonOptions);
ValidateStateData(result, value);
}
private static readonly string s_runId = Guid.NewGuid().ToString("N");
private static readonly string s_parentCheckpointId = Guid.NewGuid().ToString("N");
private static CheckpointInfo TestParentCheckpointInfo => new(s_runId, s_parentCheckpointId);
private static void ValidateCheckpoint(Checkpoint result, Checkpoint prototype)
{
result.Should().Match((Checkpoint checkpoint) => checkpoint.StepNumber == prototype.StepNumber);
result.Parent.Should().Be(prototype.Parent);
ValidateWorkflowInfo(result.Workflow, prototype.Workflow);
ValidateRunnerStateData(result.RunnerData, prototype.RunnerData);
ValidateStateData(result.StateData, prototype.StateData);
ValidateEdgeStateData(result.EdgeStateData, prototype.EdgeStateData);
}
[Fact]
public async Task Test_Checkpoint_JsonRoundTripAsync()
{
WorkflowInfo testWorkflowInfo = CreateTestWorkflowInfo();
Checkpoint prototype = new(12, testWorkflowInfo, TestRunnerStateData, TestStateData, TestEdgeState, TestParentCheckpointInfo);
Checkpoint result = RunJsonRoundtrip(prototype, TestCustomSerializedJsonOptions);
ValidateCheckpoint(result, prototype);
}
[Fact]
public async Task Test_InMemoryCheckpointManager_JsonRoundTripAsync()
{
WorkflowInfo testWorkflowInfo = CreateTestWorkflowInfo();
Checkpoint prototype = new(12, testWorkflowInfo, TestRunnerStateData, TestStateData, TestEdgeState, TestParentCheckpointInfo);
string runId = Guid.NewGuid().ToString("N");
InMemoryCheckpointManager manager = new();
CheckpointInfo checkpointInfo = await manager.CommitCheckpointAsync(runId, prototype);
InMemoryCheckpointManager result = RunJsonRoundtrip(manager, TestCustomSerializedJsonOptions);
Checkpoint? retrievedCheckpoint = await result.LookupCheckpointAsync(runId, checkpointInfo);
ValidateCheckpoint(retrievedCheckpoint, prototype);
}
[Fact]
public void Test_SessionState_JsonRoundtrip_WithPendingRequests()
{
// Arrange
Dictionary<string, ExternalRequest> pendingRequests = new()
{
["call-1"] = TestExternalRequest,
["call-2"] = ExternalRequest.Create(TestPort, "Request2", "OtherData"),
};
WorkflowSession.SessionState prototype = new(
sessionId: "test-session-123",
lastCheckpoint: TestParentCheckpointInfo,
pendingRequests: pendingRequests);
// Act
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
// Assert
result.SessionId.Should().Be(prototype.SessionId);
result.LastCheckpoint.Should().Be(prototype.LastCheckpoint);
result.StateBag.Should().NotBeNull();
result.PendingRequests.Should().NotBeNull()
.And.HaveCount(pendingRequests.Count);
foreach (string key in pendingRequests.Keys)
{
result.PendingRequests.Should().ContainKey(key);
ValidateExternalRequest(result.PendingRequests![key], pendingRequests[key]);
}
}
[Fact]
public void Test_SessionState_JsonRoundtrip_WithoutPendingRequests()
{
// Arrange
WorkflowSession.SessionState prototype = new(
sessionId: "test-session-456",
lastCheckpoint: null);
// Act
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
// Assert
result.SessionId.Should().Be(prototype.SessionId);
result.LastCheckpoint.Should().BeNull();
result.PendingRequests.Should().BeNull();
}
[Fact]
public void Test_HandoffSharedState_JsonRoundtrip_Empty()
{
// Arrange
HandoffSharedState prototype = new();
// Act
HandoffSharedState result = RunJsonRoundtrip(prototype);
// Assert
result.PreviousAgentId.Should().Be(prototype.PreviousAgentId);
result.Conversation.CloneHistory().Should().BeEquivalentTo(prototype.Conversation.CloneHistory());
}
[Fact]
public void Test_HandoffSharedState_JsonRoundtrip_WithConversation()
{
// Arrange
HandoffSharedState prototype = new();
prototype.Conversation.AddMessage(TestUserMessage);
prototype.Conversation.AddMessage(new(ChatRole.Assistant, "Hi"));
prototype.PreviousAgentId = "agent-123";
// Act
HandoffSharedState result = RunJsonRoundtrip(prototype);
// Assert
result.PreviousAgentId.Should().Be(prototype.PreviousAgentId);
result.Conversation.CloneHistory().Should().BeEquivalentTo(prototype.Conversation.CloneHistory());
}
[Fact]
public void Test_HandoffAgentHostState_JsonRoundtrip_TakingTurn()
{
// Arrange
HandoffState handoffState = new(new TurnToken(emitEvents: true),
nameof(HandoffState.RequestedHandoffTargetAgentId),
nameof(handoffState.PreviousAgentId));
HandoffAgentHostState prototype = new(handoffState, 42);
// Act
HandoffAgentHostState result = RunJsonRoundtrip(prototype);
// Assert
result.IncomingState.Should().BeEquivalentTo(prototype.IncomingState);
result.ConversationBookmark.Should().Be(prototype.ConversationBookmark);
result.IsTakingTurn.Should().Be(prototype.IsTakingTurn);
}
[Fact]
public void Test_HandoffAgentHostState_JsonRoundtrip_NotTakingTurn()
{
// Arrange
HandoffAgentHostState prototype = new(null, 42);
// Act
HandoffAgentHostState result = RunJsonRoundtrip(prototype);
// Assert
result.IncomingState.Should().BeEquivalentTo(prototype.IncomingState);
result.ConversationBookmark.Should().Be(prototype.ConversationBookmark);
result.IsTakingTurn.Should().Be(prototype.IsTakingTurn);
}
[Fact]
public void Test_GroupChatManagerState_JsonRoundtrip()
{
// Arrange
GroupChatManagerState prototype = new(IterationCount: 7);
// Act
GroupChatManagerState result = RunJsonRoundtrip(prototype);
// Assert
result.Should().Be(prototype);
result.IterationCount.Should().Be(prototype.IterationCount);
}
[Fact]
public void Test_RoundRobinGroupChatManagerState_JsonRoundtrip()
{
// Arrange
RoundRobinGroupChatManagerState prototype = new(NextIndex: 3);
// Act
RoundRobinGroupChatManagerState result = RunJsonRoundtrip(prototype);
// Assert
result.Should().Be(prototype);
result.NextIndex.Should().Be(prototype.NextIndex);
}
/// <summary>
/// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails
/// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue.
/// See: https://github.com/microsoft/agent-framework/issues/2962
/// </summary>
[Fact]
public void Test_OutOfOrderMetadataProperties_WithoutOption_Fails()
{
// Arrange
JsonMarshaller marshaller = new();
EdgeInfo edgeInfo = TestEdgeInfo_DirectNoCondition;
// Serialize to JSON
JsonElement serialized = marshaller.Marshal(edgeInfo);
string json = serialized.GetRawText();
// Simulate PostgreSQL jsonb behavior: reorder properties so $type is not first
string reorderedJson = ReorderJsonPropertiesToMoveTypeDiscriminatorLast(json);
// Act & Assert - Without the option, deserialization should fail
JsonElement reorderedElement = JsonDocument.Parse(reorderedJson).RootElement;
Action act = () => marshaller.Marshal<EdgeInfo>(reorderedElement);
act.Should().Throw<JsonException>();
}
/// <summary>
/// Simulates PostgreSQL jsonb behavior where property order is not preserved,
/// causing $type metadata to not be the first property.
/// This test verifies that deserialization works when AllowOutOfOrderMetadataProperties is enabled.
/// See: https://github.com/microsoft/agent-framework/issues/2962
/// </summary>
[Fact]
public void Test_OutOfOrderMetadataProperties_WithOptionEnabled_Succeeds()
{
// Arrange
EdgeInfo edgeInfo = TestEdgeInfo_DirectNoCondition;
// Serialize to JSON using standard marshaller
JsonMarshaller marshaller = new();
JsonElement serialized = marshaller.Marshal(edgeInfo);
string json = serialized.GetRawText();
// Simulate PostgreSQL jsonb behavior: reorder properties so $type is not first
string reorderedJson = ReorderJsonPropertiesToMoveTypeDiscriminatorLast(json);
JsonElement reorderedElement = JsonDocument.Parse(reorderedJson).RootElement;
// Act - Deserialize with AllowOutOfOrderMetadataProperties enabled via JsonSerializerOptions
JsonSerializerOptions options = new() { AllowOutOfOrderMetadataProperties = true };
JsonMarshaller marshallerWithOption = new(options);
EdgeInfo deserialized = marshallerWithOption.Marshal<EdgeInfo>(reorderedElement);
// Assert
deserialized.Should().Match(edgeInfo.CreatePolyValidator());
}
private static string ReorderJsonPropertiesToMoveTypeDiscriminatorLast(string json)
{
// Parse JSON, extract $type, rebuild with $type at end
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
Dictionary<string, JsonElement> properties = [];
JsonElement? typeValue = null;
foreach (JsonProperty prop in root.EnumerateObject())
{
if (prop.Name == "$type")
{
typeValue = prop.Value.Clone();
}
else
{
properties[prop.Name] = prop.Value.Clone();
}
}
// Rebuild JSON with $type last
using System.IO.MemoryStream ms = new();
using (Utf8JsonWriter writer = new(ms))
{
writer.WriteStartObject();
foreach (KeyValuePair<string, JsonElement> kvp in properties)
{
writer.WritePropertyName(kvp.Key);
kvp.Value.WriteTo(writer);
}
if (typeValue.HasValue)
{
writer.WritePropertyName("$type");
typeValue.Value.WriteTo(writer);
}
writer.WriteEndObject();
}
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
}
}