Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub c8aebf8eab test: assert no subsequent tool call and exact agent invocation counts in coord/spec/coord ping-pong handoff 2026-05-28 12:18:08 +00:00
copilot-swe-agent[bot]andGitHub 53a3c6783e test: add Coord/Spec/Coord ping-pong handoff ordering test 2026-05-28 12:06:03 +00:00
copilot-swe-agent[bot]andGitHub 56b8307589 Fix multi-step handoff message ordering in non-streaming RunAsync
HandoffAgentExecutor synthesizes a 'Transferred.' tool-result update for
each handoff function call. That update was created without setting
ResponseId, so MessageMerger routed it to the global dangling bucket and
flushed all such tool results at the very end of the merged
AgentResponse, breaking per-step grouping for multi-step handoffs (see
#4544). Streaming output already preserved order because updates are
yielded directly without going through the merger.

Fix: stamp the synthesized update with the same ResponseId as the
preceding agent stream updates so it groups with the agent's other
messages in MessageMerger.

Also adds a regression test that drives the real workflow through
WorkflowHostAgent.RunAsync over a 3-agent handoff chain and asserts the
per-step message ordering of the merged response.
2026-05-28 11:48:24 +00:00
4e7a46b3f1 Reframe MessageMerger to pure emission order; drop CreatedAt sort
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/570d306e-62db-44a6-998a-915fc8fe8807

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-19 14:17:38 +00:00
73ad268407 Address PR review feedback: improve test assertions and contiguity verification
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/3eba72cf-c092-49de-b159-6100d560f630

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-13 21:04:42 +00:00
Jacob AlberandGitHub 24ff22c9b9 Merge branch 'main' into copilot/create-test-for-handoff-issue 2026-05-13 16:52:06 -04:00
637c9cbb00 Remove PR_DESCRIPTION.md - content moved to PR description
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/b4eccebe-040b-44c5-a41b-a80f36c41906

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-13 20:51:08 +00:00
a976004347 Add PR description markdown file for MessageMerger invariants work
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9e7d86e0-02d9-48c4-b8e1-a7853f7768a3

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-13 20:47:05 +00:00
Jacob AlberandGitHub 9c9fc76d15 Merge branch 'main' into copilot/create-test-for-handoff-issue 2026-05-13 15:59:25 -04:00
90585ad912 Rework MessageMerger note into ADR 0026
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9a5433df-55bf-402c-aae8-c79a6d22e677

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-13 19:49:42 +00:00
53693d7aea Add invariant tests and remove unused createdTimes collection
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/fd187fc2-3ed5-4204-8340-d4b3c391e989

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-13 19:41:17 +00:00
dbac4eab56 Add hosting executor invariants and required test coverage
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9241f85a-8657-4979-8d64-0736dc6e7ebb

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-13 19:26:08 +00:00
b684a441c0 Add MessageMerger edge cases and test coverage documentation
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/789cdb87-c90d-4681-b8e0-9b6b63762856

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-13 19:18:49 +00:00
23312ee8b1 Preserve MessageMerger insertion order without timestamps
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/4bacd1d2-5911-4fde-8cfb-375ef0808563

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-13 18:59:32 +00:00
175a9c3558 Add MessageMerger regression test skeleton
Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/43f2a9ba-4aac-4491-89ba-8de379eefc2b

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
2026-05-13 18:55:40 +00:00
5 changed files with 947 additions and 29 deletions
@@ -0,0 +1,191 @@
---
status: accepted
contact: lokitoth
date: 2026-05-13
deciders: lokitoth
consulted:
informed:
---
# MessageMerger Streaming Merge Invariants
## Context and Problem Statement
`Microsoft.Agents.AI.Workflows.MessageMerger` is the internal component that
folds a stream of `AgentResponseUpdate` items emitted by an agent (or by a
hosting executor wrapping an agent) into a single `AgentResponse` for a turn.
Multi-agent workflows (handoff, group chat, orchestration) rely on this
merger to produce a coherent transcript even when updates arrive interleaved
across responses, messages, and timestamps.
Prior to this change the contract that hosting executors and the merger
should jointly enforce was implicit. The implementation also carried a small
amount of dead state (`createdTimes`) that was collected but never consumed,
suggesting that an earlier, timestamp-driven ordering scheme had been
abandoned without documentation, and the live code still ran an unreliable
`CreatedAt`-based sort that could reorder messages across concurrent agents
inside a single workflow super-step. There were no tests pinning down the
ordering or grouping behavior, so any future refactor risked silently
regressing it.
The problem this ADR addresses is therefore: **what merge guarantees does
`MessageMerger` make to its callers, and how do we lock those guarantees in
without changing observable behavior in non-pathological cases?**
## Decision Drivers
- **A. Predictable ordering.** Developers consuming a merged `AgentResponse`
must be able to reason about the order in which messages appear without
having to know whether updates carried `CreatedAt`.
- **B. Coherent multi-agent transcripts.** When several agents stream into
one merger within a single workflow super-step, each agent's contribution
must read as a contiguous block; and a step's updates must precede the
next step's updates.
- **C. Stable hosting-executor contract.** A turn must be addressable by a
single `ResponseId`; updates without one are an exceptional, "dangling"
case rather than the norm.
- **D. Minimal behavioral change for non-pathological inputs.** This work
is intended to document and test current behavior, not to alter what
users see today in well-formed agent streams.
- **E. Discoverability for future contributors.** Known sharp edges (e.g.
cross-`ResponseId` ordering, dangling-update placement) should be written
down so they are not rediscovered as bugs.
## Considered Options
1. **Option 1 — Document invariants, pin them with tests, and use pure
emission/insertion order for both responses and the messages inside
each response.** Removes the unreliable `CreatedAt`-based sort and the
dead `createdTimes` collection. Behavioral change only for inputs that
relied on `CreatedAt` to re-order updates after the fact — which the
prior comparer could not do correctly anyway (non-transitive).
2. **Option 2 — Rewrite `MessageMerger` to group strictly by `AgentId`
(rather than `ResponseId`), and to use a stable, transitive comparer
that mixes `CreatedAt` with insertion index.** Behavioral change; would
also require updating hosting executors that currently assume
`ResponseId`-based grouping.
3. **Option 3 — Leave the code and tests as-is; capture edge cases only
in a working note.** No code or test change.
## Decision Outcome
Chosen option: **Option 1 — Document invariants, pin them with tests,
and use pure emission/insertion order; remove the dead `createdTimes`
collection.**
This option satisfies driver A (predictable ordering: emission order is
the simplest reasoning model), driver B (per-`ResponseId` grouping plus
first-seen ordering across responses gives both per-agent blocks within a
step and step-ordering across steps), driver C (single ResponseId per
turn is unchanged), and driver E (invariants and edge cases are now
written down and covered by tests).
Driver D (minimal behavioral change) is satisfied because well-formed
agent streams already emit updates in the order they want to appear; the
prior `CreatedAt`-based sort only mattered for pathological inputs (mixed
or out-of-order timestamps from concurrent agents), and on those inputs
the old comparer was non-transitive and therefore unreliable. Removing
the sort makes those inputs deterministic — they now follow emission
order — without disturbing the well-formed case.
Option 2 was rejected for this iteration because it changes what callers
observe in well-formed flows and would require a coordinated change
across hosting executors. It is a candidate for a follow-up ADR if the
known edge cases below are reported as bugs in practice.
Option 3 was rejected because it leaves the invariants un-tested and the
dead code in place, so the next refactor can break the contract without
any signal.
### Invariants
The following invariants are now part of the contract of
`MessageMerger`/hosting executors and are covered by tests in
`MessageMergerTests`:
1. **Single `ResponseId` per turn.** Every `AgentResponseUpdate` produced
by a hosting executor in a single agent turn shares one `ResponseId`.
If the underlying agent does not supply one, the executor assigns it.
Updates with `ResponseId == null` are treated as "dangling" and
flattened into loose messages at the end of the merged response.
2. **Pure emission-order preservation.** Within a `ResponseId` block,
messages appear in the order their updates first arrived at the
merger. Across `ResponseId` blocks, blocks appear in first-seen order.
`CreatedAt` is **not** consulted when ordering messages or blocks —
only when stamping the merged response and its child messages.
3. **Per-`ResponseId` grouping (no interleaving).** Messages produced
under one `ResponseId` are emitted as a contiguous block in the merged
`AgentResponse`. Combined with Invariant 1, this yields:
- **Within a workflow super-step with one agent**: all messages
appear together, in emission order.
- **Within a super-step with multiple agents**: each agent's messages
are a contiguous block, ordered by which agent emitted first.
- **Across super-steps**: a step's blocks all precede the next step's
blocks, because the next step cannot start emitting until the
current step's emissions have arrived at the merger.
### Consequences
- Good, because the merge contract is now explicit, regression-tested,
and trivially reasoned about (it is just emission order with
per-`ResponseId` grouping).
- Good, because removing the unreliable `CreatedAt`-based sort eliminates
a latent bug — the prior comparer was non-transitive on mixed-timestamp
inputs, so `List<T>.Sort` could in principle return any of several
orderings or throw on some runtimes.
- Good, because removing the unused `createdTimes` collection eliminates
a misleading code smell.
- Good, because hosting-executor authors have a written checklist of
invariants to satisfy.
- Neutral, because well-formed agent streams (those that emit updates in
the order they want to appear) see no change in output.
- Bad, because callers who relied on a server-supplied `CreatedAt` to
retro-correct out-of-order emissions will no longer see that
correction — they must ensure emission order matches desired output
order, or attach to `RawRepresentation` for original timestamps.
## Validation
- Unit tests in
`dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs`
cover each invariant:
- Insertion-order preservation with no timestamps.
- Insertion-order preservation with mixed timestamps (emission order
wins over `CreatedAt`).
- Determinism across repeated runs with mixed timestamps.
- Per-`ResponseId` grouping for interleaved multi-agent streams within
a single step.
- Per-`ResponseId` grouping with distinct response ids.
- Existing tests for assembly, function-call/result ordering, and
`FinishReason` propagation continue to pass.
## More Information
### Known edge cases (intentionally not fixed in this ADR)
These are properties of the *current* `MessageMerger` that callers should
be aware of. They are not invariants — they may change in a future ADR —
but they are present in the shipped behavior covered by tests above.
| # | Edge case | Risk | Notes |
|---|-----------|------|-------|
| 1 | Cross-`ResponseId` ordering follows first-seen-`ResponseId` order, not chronological order across responses. | Medium | Acceptable today because each turn has a single `ResponseId` (Invariant 1); only matters if a caller deliberately interleaves multiple response ids inside one step. |
| 2 | Updates with `ResponseId == null` are always emitted **after** all keyed responses, regardless of arrival time. | Medium | Documented as the "dangling" path; agents should always emit a `ResponseId`. |
| 3 | Within a response, updates with `MessageId == null` are always emitted **after** keyed messages. | Low | Same rationale as #2, scoped to messages within a response. |
| 4 | The merged response's `CreatedAt` is set to `DateTimeOffset.UtcNow`; per-response `CreatedAt` is propagated onto each contained `ChatMessage` instead of being preserved at the response level. | Low | Callers who need original per-update timestamps should read them from `RawRepresentation` or capture them before merging. |
| 5 | Metadata on dangling (`ResponseId == null`) updates — `FinishReason`, `Usage`, `AgentId`, `AdditionalProperties` — is **not** merged into the final `AgentResponse`; only their `Messages` are surfaced. | Medium | Hosting executors must attach metadata to a keyed update if they want it reflected in the merged response. |
| 6 | Emission order is the **only** ordering signal — `CreatedAt` differences between updates are ignored when ordering. | Low | This is the intended behavior under Invariant 2; producers must emit in the desired output order. |
If any of these become observable problems in production, the appropriate
follow-up is a new ADR that supersedes this one (likely realizing
"Option 2") rather than a silent fix.
### Code references
- `dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs` — merger
implementation. The previously-unused `createdTimes` collection and
the `CreatedAt`-based sort have both been removed in this change.
- `dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs` —
invariant tests added in this change.
- `AgentInvocationContext.ResponseId` and `ToStreamingResponseAsync` —
the hosting-executor side of Invariant 1.
@@ -97,28 +97,6 @@ internal sealed class MessageMerger
}
}
private int CompareByDateTimeOffset(AgentResponse left, AgentResponse right)
{
const int LESS = -1, EQ = 0, GREATER = 1;
if (left.CreatedAt == right.CreatedAt)
{
return EQ;
}
if (!left.CreatedAt.HasValue)
{
return GREATER;
}
if (!right.CreatedAt.HasValue)
{
return LESS;
}
return left.CreatedAt.Value.CompareTo(right.CreatedAt.Value);
}
public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgentId = null, string? primaryAgentName = null)
{
List<ChatMessage> messages = [];
@@ -126,6 +104,15 @@ internal sealed class MessageMerger
HashSet<string> agentIds = [];
HashSet<ChatFinishReason> finishReasons = [];
// Ordering contract (see docs/decisions/0026-message-merger-invariants.md):
// - Outer loop iterates ResponseIds in first-seen order, which preserves step
// ordering: each agent invocation owns its own ResponseId, and successive
// super-steps emit their first update only after the prior step's updates
// have all arrived. Iterating Dictionary<,>.Keys preserves insertion order.
// - Inner loop iterates MessageIds in first-seen order, then appends dangling
// updates last. This preserves emission order within an agent's block.
// We deliberately do NOT sort by CreatedAt: timestamps from concurrent agents
// or different clocks would interleave per-agent blocks and break Goal 1.
foreach (string responseId in this._mergeStates.Keys)
{
ResponseMergeState mergeState = this._mergeStates[responseId];
@@ -136,14 +123,12 @@ internal sealed class MessageMerger
responseList.Add(mergeState.ComputeDangling());
}
responseList.Sort(this.CompareByDateTimeOffset);
responses[responseId] = responseList.Aggregate(MergeResponses);
messages.AddRange(GetMessagesWithCreatedAt(responses[responseId]));
}
UsageDetails? usage = null;
AdditionalPropertiesDictionary? additionalProperties = null;
HashSet<DateTimeOffset> createdTimes = [];
foreach (AgentResponse response in responses.Values)
{
@@ -152,11 +137,6 @@ internal sealed class MessageMerger
agentIds.Add(response.AgentId);
}
if (response.CreatedAt.HasValue)
{
createdTimes.Add(response.CreatedAt.Value);
}
if (response.FinishReason.HasValue)
{
finishReasons.Add(response.FinishReason.Value);
@@ -433,6 +433,18 @@ internal sealed class HandoffAgentExecutor :
FunctionCallContent handoffRequest = candidateRequests[candidateRequests.Count - 1];
requestedHandoff = handoffRequest.Name;
// Stamp the synthetic "Transferred." tool-result update with the same
// ResponseId as the agent's preceding updates so it groups with the
// rest of this agent's step in MessageMerger (and therefore in
// RunAsync's merged AgentResponse and in chat history). Without this,
// the synthetic update goes to MessageMerger's null-ResponseId
// "dangling" bucket and surfaces after every keyed response, which
// re-orders multi-step handoff transcripts versus streaming output
// (issue #4544).
string? syntheticResponseId = updates
.Select(u => u.ResponseId)
.LastOrDefault(id => id is not null);
await AddUpdateAsync(
new AgentResponseUpdate
{
@@ -441,6 +453,7 @@ internal sealed class HandoffAgentExecutor :
Contents = [CreateHandoffResult(handoffRequest.CallId)],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
ResponseId = syntheticResponseId,
Role = ChatRole.Tool,
},
cancellationToken
@@ -304,6 +304,208 @@ public class HandoffOrchestrationTests
Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent));
}
[Fact]
public async Task Handoffs_MultipleTransfers_MergedMessagesPreserveStepOrderAsync()
{
// Regression test for https://github.com/microsoft/agent-framework/issues/4544
//
// Scenario: a multi-step handoff (A -> B -> C) is run through a workflow
// exposed as an AIAgent. When invoked via the non-streaming RunAsync
// entry-point, the merged AgentResponse.Messages must keep each step's
// messages contiguous — in particular, the "Transferred." tool result
// synthesized for a handoff function call must appear immediately after
// the function call that triggered it, before any messages from later
// steps. Streaming output already preserves this order; the bug
// manifested only after merging, where the synthesized tool results were
// bunched at the end of the response, far from their originating function
// calls (and consequently corrupted ChatHistory order).
//
// Each underlying agent response sets a real ResponseId — that is what
// causes MessageMerger to group its updates under that key. If the
// synthesized Tool update is emitted without that ResponseId, the merger
// routes it to the global "dangling" bucket and flushes it last,
// breaking per-step grouping.
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to second agent"), new FunctionCallContent("call1", transferFuncName)]) { MessageId = "msg-initial" }) { ResponseId = "resp-initial" };
}), name: "initialAgent");
var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to third agent"), new FunctionCallContent("call2", transferFuncName)]) { MessageId = "msg-second" }) { ResponseId = "resp-second" };
}), name: "secondAgent", description: "The second agent");
var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "Hello from agent3") { MessageId = "msg-third" }) { ResponseId = "resp-third" }),
name: "thirdAgent",
description: "The third / final agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, secondAgent)
.WithHandoff(secondAgent, thirdAgent)
.Build();
AIAgent hostAgent = workflow.AsAIAgent(name: "HandoffWorkflow");
AgentResponse response = await hostAgent.RunAsync("abc");
List<ChatMessage> result = response.Messages.ToList();
// Expected merged sequence keeps each step contiguous:
// [0] Assistant (initialAgent): text + FunctionCall(call1)
// [1] Tool (initialAgent): FunctionResult(call1, "Transferred.")
// [2] Assistant (secondAgent): text + FunctionCall(call2)
// [3] Tool (secondAgent): FunctionResult(call2, "Transferred.")
// [4] Assistant (thirdAgent): "Hello from agent3"
//
// The bug surfaced as the two Tool messages being moved to the end of the
// list — after thirdAgent's reply — which broke chat-history ordering.
Assert.Equal(5, result.Count);
Assert.Equal(ChatRole.Assistant, result[0].Role);
Assert.Contains("initialAgent", result[0].AuthorName);
Assert.Contains(result[0].Contents, c => c is FunctionCallContent fcc && fcc.CallId == "call1");
Assert.Equal(ChatRole.Tool, result[1].Role);
Assert.Contains("initialAgent", result[1].AuthorName);
Assert.Contains(result[1].Contents, c => c is FunctionResultContent frc && frc.CallId == "call1");
Assert.Equal(ChatRole.Assistant, result[2].Role);
Assert.Contains("secondAgent", result[2].AuthorName);
Assert.Contains(result[2].Contents, c => c is FunctionCallContent fcc && fcc.CallId == "call2");
Assert.Equal(ChatRole.Tool, result[3].Role);
Assert.Contains("secondAgent", result[3].AuthorName);
Assert.Contains(result[3].Contents, c => c is FunctionResultContent frc && frc.CallId == "call2");
Assert.Equal(ChatRole.Assistant, result[4].Role);
Assert.Contains("thirdAgent", result[4].AuthorName);
Assert.Equal("Hello from agent3", result[4].Text);
}
[Fact]
public async Task Handoffs_CoordSpecCoordPingPong_MergedMessagesPreserveStepOrderAsync()
{
// Regression test for https://github.com/microsoft/agent-framework/issues/4544 /
// https://github.com/microsoft/agent-framework/issues/5720.
//
// Scenario mirrors the user-reported "Coord -> Spec -> Coord" ping-pong where
// the same coordinator agent is invoked twice (first to hand off to the
// specialist, then a final time to summarize). Additionally exercises the
// case where the specialist returns its assistant text and its handoff
// FunctionCall in two SEPARATE ChatMessages (i.e. different MessageIds),
// because real OpenAI streams sometimes split text and tool calls that way.
//
// The merged AgentResponse.Messages must preserve per-step grouping:
// step 1 (Coord): FunctionCall(call1) then synthesized FunctionResult(call1)
// step 2 (Spec) : TextContent "Here are recs" then FunctionCall(call2)
// then synthesized FunctionResult(call2)
// step 3 (Coord): TextContent "Here are recs"
// The bug previously bunched the FunctionResult ("Transferred.") tool messages
// at the very end, breaking the contiguity of each step's block.
int coordCallCount = 0;
int specCallCount = 0;
var coord = new ChatClientAgent(new MockChatClient((messages, options) =>
{
coordCallCount++;
if (coordCallCount == 1)
{
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_coord_1", transferFuncName)]) { MessageId = "coord-msg-1" })
{
ResponseId = "resp-coord-1",
};
}
// Second (final) Coord turn: emit only assistant text - no tool call.
// The test asserts this turn does NOT emit a handoff_to_* FunctionCallContent
// and that the workflow terminates here (coordCallCount stays at 2).
return new(new ChatMessage(ChatRole.Assistant, "Here are two fake Excel course recommendations") { MessageId = "coord-msg-2" })
{
ResponseId = "resp-coord-2",
};
}), name: "Coord");
var spec = new ChatClientAgent(new MockChatClient((messages, options) =>
{
specCallCount++;
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
// Split text and FunctionCall into two separate ChatMessages (distinct MessageIds)
// to mirror the real-world streaming pattern where a specialist's narration
// and its handoff tool-call land in different ChatMessages.
return new(
[
new ChatMessage(ChatRole.Assistant, "Here are two fake Excel course recommendations") { MessageId = "spec-msg-text" },
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_spec_1", transferFuncName)]) { MessageId = "spec-msg-call" },
])
{
ResponseId = "resp-spec-1",
};
}), name: "Spec", description: "The specialist agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(coord)
.WithHandoff(coord, spec)
.WithHandoff(spec, coord)
.Build();
AIAgent hostAgent = workflow.AsAIAgent(name: "HandoffWorkflow");
AgentResponse response = await hostAgent.RunAsync("Tell me about Excel courses.");
List<ChatMessage> result = response.Messages.ToList();
// Expected merged sequence keeps each step contiguous (6 messages):
// [0] Assistant (Coord): FunctionCall(call_coord_1)
// [1] Tool (Coord): FunctionResult(call_coord_1, "Transferred.")
// [2] Assistant (Spec) : TextContent "Here are recs"
// [3] Assistant (Spec) : FunctionCall(call_spec_1)
// [4] Tool (Spec) : FunctionResult(call_spec_1, "Transferred.")
// [5] Assistant (Coord): TextContent "Here are recs"
Assert.Equal(6, result.Count);
Assert.Equal(ChatRole.Assistant, result[0].Role);
Assert.Contains("Coord", result[0].AuthorName);
Assert.Contains(result[0].Contents, c => c is FunctionCallContent fcc && fcc.CallId == "call_coord_1");
Assert.Equal(ChatRole.Tool, result[1].Role);
Assert.Contains("Coord", result[1].AuthorName);
Assert.Contains(result[1].Contents, c => c is FunctionResultContent frc && frc.CallId == "call_coord_1");
Assert.Equal(ChatRole.Assistant, result[2].Role);
Assert.Contains("Spec", result[2].AuthorName);
Assert.Equal("Here are two fake Excel course recommendations", result[2].Text);
Assert.Equal(ChatRole.Assistant, result[3].Role);
Assert.Contains("Spec", result[3].AuthorName);
Assert.Contains(result[3].Contents, c => c is FunctionCallContent fcc && fcc.CallId == "call_spec_1");
Assert.Equal(ChatRole.Tool, result[4].Role);
Assert.Contains("Spec", result[4].AuthorName);
Assert.Contains(result[4].Contents, c => c is FunctionResultContent frc && frc.CallId == "call_spec_1");
Assert.Equal(ChatRole.Assistant, result[5].Role);
Assert.Contains("Coord", result[5].AuthorName);
Assert.Equal("Here are two fake Excel course recommendations", result[5].Text);
// Final Coord turn must emit ONLY assistant text - no further handoff/tool call.
Assert.DoesNotContain(result[5].Contents, c => c is FunctionCallContent);
// And the workflow must have terminated after that turn - no extra Coord/Spec re-invocations.
Assert.Equal(2, coordCallCount);
Assert.Equal(1, specCallCount);
}
[Fact]
public async Task Handoffs_FilteringNone_HandoffTargetReceivesAllMessagesIncludingToolCallsAsync()
{
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using FluentAssertions;
using Microsoft.Extensions.AI;
@@ -42,6 +43,47 @@ public class MessageMergerTests
response.FinishReason.Should().BeNull();
}
[Fact]
public void Test_MessageMerger_PreservesFunctionCallOrderingWhenToolResultHasCreatedAt()
{
// Arrange
string responseId = Guid.NewGuid().ToString("N");
string functionCallMessageId = Guid.NewGuid().ToString("N");
string functionResultMessageId = Guid.NewGuid().ToString("N");
string callId = Guid.NewGuid().ToString("N");
DateTimeOffset toolResultCreatedAt = DateTimeOffset.UtcNow;
MessageMerger merger = new();
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = functionCallMessageId,
AgentId = TestAgentId1,
Role = ChatRole.Assistant,
Contents = [new FunctionCallContent(callId, "handoff_to_TestAgent2")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = functionResultMessageId,
AgentId = TestAgentId1,
CreatedAt = toolResultCreatedAt,
Role = ChatRole.Tool,
Contents = [new FunctionResultContent(callId, "Transferred.")],
});
// Act
AgentResponse response = merger.ComputeMerged(responseId);
// Assert
response.Messages.Should().HaveCount(2);
response.Messages[0].Role.Should().Be(ChatRole.Assistant);
response.Messages[0].Contents.Should().ContainSingle().Which.Should().BeOfType<FunctionCallContent>();
response.Messages[1].Role.Should().Be(ChatRole.Tool);
response.Messages[1].Contents.Should().ContainSingle().Which.Should().BeOfType<FunctionResultContent>();
}
[Fact]
public void Test_MessageMerger_PropagatesFinishReasonFromUpdates()
{
@@ -71,4 +113,494 @@ public class MessageMergerTests
// Assert - FinishReason from the update should propagate through
response.FinishReason.Should().Be(ChatFinishReason.ContentFilter);
}
#region Invariant 2: Output Order Preservation Tests
[Fact]
public void Test_MessageMerger_PreservesInsertionOrder_WhenNoTimestamps()
{
// Arrange: Multiple updates without CreatedAt, in specific order A, B, C
string responseId = Guid.NewGuid().ToString("N");
string messageIdA = Guid.NewGuid().ToString("N");
string messageIdB = Guid.NewGuid().ToString("N");
string messageIdC = Guid.NewGuid().ToString("N");
MessageMerger merger = new();
// Add updates without CreatedAt in order A, B, C
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdA,
Role = ChatRole.Assistant,
Contents = [new TextContent("Message A")],
// No CreatedAt
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdB,
Role = ChatRole.Assistant,
Contents = [new TextContent("Message B")],
// No CreatedAt
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdC,
Role = ChatRole.Assistant,
Contents = [new TextContent("Message C")],
// No CreatedAt
});
// Act
AgentResponse response = merger.ComputeMerged(responseId);
// Assert: Output order should be A, B, C (insertion order)
response.Messages.Should().HaveCount(3);
response.Messages[0].Text.Should().Be("Message A");
response.Messages[1].Text.Should().Be("Message B");
response.Messages[2].Text.Should().Be("Message C");
}
[Fact]
public void Test_MessageMerger_PreservesInsertionOrder_WhenMixedTimestamps()
{
// Arrange: Updates with OUT-OF-ORDER timestamps relative to emission order.
// Emission order: A, B, C.
// Timestamp order: C (oldest), A, B (newest) - reverse of emission.
// Per Invariant 2, emission order wins; timestamps are ignored for ordering.
string responseId = Guid.NewGuid().ToString("N");
string messageIdA = Guid.NewGuid().ToString("N");
string messageIdB = Guid.NewGuid().ToString("N");
string messageIdC = Guid.NewGuid().ToString("N");
DateTimeOffset timeOldest = DateTimeOffset.UtcNow.AddMinutes(-10);
DateTimeOffset timeMiddle = DateTimeOffset.UtcNow.AddMinutes(-5);
DateTimeOffset timeNewest = DateTimeOffset.UtcNow;
MessageMerger merger = new();
// Emit A first but stamp it with timeMiddle.
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdA,
Role = ChatRole.Assistant,
CreatedAt = timeMiddle,
Contents = [new TextContent("Message A")],
});
// Emit B second, without a timestamp.
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdB,
Role = ChatRole.Assistant,
// No CreatedAt
Contents = [new TextContent("Message B")],
});
// Emit C third but stamp it with timeOldest - if we sorted by timestamp,
// C would come first; the new merger MUST keep emission order (A, B, C).
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdC,
Role = ChatRole.Assistant,
CreatedAt = timeOldest,
Contents = [new TextContent("Message C")],
});
// Stamp a fourth message with the newest timestamp - it should still come last
// because it was emitted last, not because of its timestamp.
string messageIdD = Guid.NewGuid().ToString("N");
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdD,
Role = ChatRole.Assistant,
CreatedAt = timeNewest,
Contents = [new TextContent("Message D")],
});
// Act
AgentResponse response = merger.ComputeMerged(responseId);
// Assert: Emission order wins; CreatedAt is ignored for ordering.
response.Messages.Should().HaveCount(4);
response.Messages[0].Text.Should().Be("Message A");
response.Messages[1].Text.Should().Be("Message B");
response.Messages[2].Text.Should().Be("Message C");
response.Messages[3].Text.Should().Be("Message D");
}
[Fact]
public void Test_MessageMerger_ReproducibleOrdering_WithMixedTimestamps()
{
// Arrange: 3+ messages with mixed null/non-null CreatedAt values
// This tests that the same input sequence produces the same output
// (run-to-run reproducibility for a fixed input)
string responseId = Guid.NewGuid().ToString("N");
string messageIdA = Guid.NewGuid().ToString("N");
string messageIdB = Guid.NewGuid().ToString("N");
string messageIdC = Guid.NewGuid().ToString("N");
DateTimeOffset time10 = DateTimeOffset.UtcNow.AddSeconds(10);
DateTimeOffset time5 = DateTimeOffset.UtcNow.AddSeconds(5);
MessageMerger merger = new();
// A: CreatedAt = time10, idx=0
// B: CreatedAt = null, idx=1
// C: CreatedAt = time5, idx=2
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdA,
Role = ChatRole.Assistant,
CreatedAt = time10,
Contents = [new TextContent("Message A (T=10)")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdB,
Role = ChatRole.Assistant,
// No CreatedAt
Contents = [new TextContent("Message B (no timestamp)")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdC,
Role = ChatRole.Assistant,
CreatedAt = time5,
Contents = [new TextContent("Message C (T=5)")],
});
// Act - Run multiple times to verify reproducibility
AgentResponse response1 = merger.ComputeMerged(responseId);
// Create a fresh merger with same data to verify reproducibility
MessageMerger merger2 = new();
merger2.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdA,
Role = ChatRole.Assistant,
CreatedAt = time10,
Contents = [new TextContent("Message A (T=10)")],
});
merger2.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdB,
Role = ChatRole.Assistant,
Contents = [new TextContent("Message B (no timestamp)")],
});
merger2.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageIdC,
Role = ChatRole.Assistant,
CreatedAt = time5,
Contents = [new TextContent("Message C (T=5)")],
});
AgentResponse response2 = merger2.ComputeMerged(responseId);
// Assert: Result is reproducible and consistent across runs with same input order.
// Per Invariant 2, both runs must produce emission order (A, B, C) regardless
// of the messages' CreatedAt values.
response1.Messages.Should().HaveCount(3);
response2.Messages.Should().HaveCount(3);
response1.Messages.Select(m => m.Text).Should().ContainInOrder(
"Message A (T=10)", "Message B (no timestamp)", "Message C (T=5)");
// Both runs should produce identical ordering
for (int i = 0; i < 3; i++)
{
response1.Messages[i].Text.Should().Be(response2.Messages[i].Text);
}
}
#endregion
#region Invariant 3: Agent Message Grouping Tests
[Fact]
public void Test_MessageMerger_GroupsMessagesByResponseId_InMultiAgentScenario()
{
// Arrange: Interleaved updates from Agent1 (R1) and Agent2 (R2)
string responseIdR1 = Guid.NewGuid().ToString("N");
string responseIdR2 = Guid.NewGuid().ToString("N");
string messageIdA1M1 = Guid.NewGuid().ToString("N");
string messageIdA1M2 = Guid.NewGuid().ToString("N");
string messageIdA2M1 = Guid.NewGuid().ToString("N");
string messageIdA2M2 = Guid.NewGuid().ToString("N");
MessageMerger merger = new();
// Interleaved arrival: A1-msg1, A2-msg1, A1-msg2, A2-msg2
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR1,
MessageId = messageIdA1M1,
AgentId = TestAgentId1,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent1 Message 1")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR2,
MessageId = messageIdA2M1,
AgentId = TestAgentId2,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent2 Message 1")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR1,
MessageId = messageIdA1M2,
AgentId = TestAgentId1,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent1 Message 2")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR2,
MessageId = messageIdA2M2,
AgentId = TestAgentId2,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent2 Message 2")],
});
// Act
AgentResponse response = merger.ComputeMerged(responseIdR1);
// Assert: Messages should be grouped by ResponseId (which groups by agent)
// Output should be either [A1-msg1, A1-msg2, A2-msg1, A2-msg2] or [A2-msg1, A2-msg2, A1-msg1, A1-msg2]
// The key invariant: Agent1's messages are contiguous, Agent2's messages are contiguous
response.Messages.Should().HaveCount(4);
// Verify grouping - collect message texts and verify they're grouped by agent
var messageTexts = response.Messages.Select(m => m.Text).ToList();
// Find first Agent1 message index and first Agent2 message index
int firstA1Index = messageTexts.FindIndex(t => t.StartsWith("Agent1", StringComparison.Ordinal));
int firstA2Index = messageTexts.FindIndex(t => t.StartsWith("Agent2", StringComparison.Ordinal));
// Assert both indices are valid (messages were found)
firstA1Index.Should().BeGreaterThanOrEqualTo(0, "Agent1 messages should be present in response");
firstA2Index.Should().BeGreaterThanOrEqualTo(0, "Agent2 messages should be present in response");
// All Agent1 messages should be contiguous (either at start or after all Agent2 messages)
var a1Messages = messageTexts.Where(t => t.StartsWith("Agent1", StringComparison.Ordinal)).ToList();
var a2Messages = messageTexts.Where(t => t.StartsWith("Agent2", StringComparison.Ordinal)).ToList();
a1Messages.Should().HaveCount(2);
a2Messages.Should().HaveCount(2);
// Verify no interleaving: if A1 comes first, A2 should come after all A1 messages
if (firstA1Index < firstA2Index)
{
// A1 messages at indices 0, 1 and A2 messages at indices 2, 3
messageTexts[0].Should().StartWith("Agent1");
messageTexts[1].Should().StartWith("Agent1");
messageTexts[2].Should().StartWith("Agent2");
messageTexts[3].Should().StartWith("Agent2");
}
else
{
// A2 messages at indices 0, 1 and A1 messages at indices 2, 3
messageTexts[0].Should().StartWith("Agent2");
messageTexts[1].Should().StartWith("Agent2");
messageTexts[2].Should().StartWith("Agent1");
messageTexts[3].Should().StartWith("Agent1");
}
}
[Fact]
public void Test_MessageMerger_MaintainsAgentGrouping_WithDifferentResponseIds()
{
// Arrange: Agent1 uses ResponseId=R1, Agent2 uses ResponseId=R2
// Multiple messages per ResponseId to properly test contiguity
string responseIdR1 = Guid.NewGuid().ToString("N");
string responseIdR2 = Guid.NewGuid().ToString("N");
string messageIdA1M1 = Guid.NewGuid().ToString("N");
string messageIdA1M2 = Guid.NewGuid().ToString("N");
string messageIdA1M3 = Guid.NewGuid().ToString("N");
string messageIdA2M1 = Guid.NewGuid().ToString("N");
string messageIdA2M2 = Guid.NewGuid().ToString("N");
string messageIdA2M3 = Guid.NewGuid().ToString("N");
MessageMerger merger = new();
// Interleaved arrival: A1-1, A2-1, A1-2, A2-2, A1-3, A2-3
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR1,
MessageId = messageIdA1M1,
AgentId = TestAgentId1,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent1 Response 1")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR2,
MessageId = messageIdA2M1,
AgentId = TestAgentId2,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent2 Response 1")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR1,
MessageId = messageIdA1M2,
AgentId = TestAgentId1,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent1 Response 2")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR2,
MessageId = messageIdA2M2,
AgentId = TestAgentId2,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent2 Response 2")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR1,
MessageId = messageIdA1M3,
AgentId = TestAgentId1,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent1 Response 3")],
});
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseIdR2,
MessageId = messageIdA2M3,
AgentId = TestAgentId2,
Role = ChatRole.Assistant,
Contents = [new TextContent("Agent2 Response 3")],
});
// Act
AgentResponse response = merger.ComputeMerged(responseIdR1);
// Assert: Messages from each agent are contiguous (not interleaved)
response.Messages.Should().HaveCount(6);
var messageTexts = response.Messages.Select(m => m.Text).ToList();
// Verify all messages are present
messageTexts.Should().Contain("Agent1 Response 1");
messageTexts.Should().Contain("Agent1 Response 2");
messageTexts.Should().Contain("Agent1 Response 3");
messageTexts.Should().Contain("Agent2 Response 1");
messageTexts.Should().Contain("Agent2 Response 2");
messageTexts.Should().Contain("Agent2 Response 3");
// Find indices to verify contiguity
int firstA1Index = messageTexts.FindIndex(t => t.StartsWith("Agent1", StringComparison.Ordinal));
int lastA1Index = messageTexts.FindLastIndex(t => t.StartsWith("Agent1", StringComparison.Ordinal));
int firstA2Index = messageTexts.FindIndex(t => t.StartsWith("Agent2", StringComparison.Ordinal));
int lastA2Index = messageTexts.FindLastIndex(t => t.StartsWith("Agent2", StringComparison.Ordinal));
// Assert indices are valid
firstA1Index.Should().BeGreaterThanOrEqualTo(0, "Agent1 messages should be present");
firstA2Index.Should().BeGreaterThanOrEqualTo(0, "Agent2 messages should be present");
// Verify contiguity: all Agent1 messages should span exactly 3 consecutive indices
(lastA1Index - firstA1Index).Should().Be(2, "Agent1 messages should be contiguous (3 messages spanning 2 index gaps)");
(lastA2Index - firstA2Index).Should().Be(2, "Agent2 messages should be contiguous (3 messages spanning 2 index gaps)");
// Verify no interleaving: ranges should not overlap
bool a1BeforeA2 = lastA1Index < firstA2Index;
bool a2BeforeA1 = lastA2Index < firstA1Index;
(a1BeforeA2 || a2BeforeA1).Should().BeTrue("Agent message blocks should not interleave");
}
#endregion
#region Step Ordering Tests (workflow goals)
[Fact]
public void Test_MessageMerger_OrdersStepsBeforeNextStep_AndGroupsAgentsWithinStep()
{
// Scenario mirroring the workflow ordering goals:
// Step 1: Agent1 alone emits 2 updates.
// Step 2: Agent1 and Agent2 emit updates interleaved (concurrent in the step).
// Step 3: Agent2 alone emits 2 updates.
// Each agent invocation has its own ResponseId, so per-ResponseId grouping
// yields per-agent grouping. Steps are naturally serialized in emission
// order (the next step cannot emit until the prior step's updates arrive),
// so the first-seen-ResponseId ordering preserves step boundaries.
const string step1Agent1 = "step1-agent1";
const string step2Agent1 = "step2-agent1";
const string step2Agent2 = "step2-agent2";
const string step3Agent2 = "step3-agent2";
MessageMerger merger = new();
// Step 1: Agent1 emits 2 messages.
AddSimpleUpdate(merger, step1Agent1, TestAgentId1, "S1-A1-m1");
AddSimpleUpdate(merger, step1Agent1, TestAgentId1, "S1-A1-m2");
// Step 2: Agent1 and Agent2 emit interleaved (A1, A2, A1, A2).
AddSimpleUpdate(merger, step2Agent1, TestAgentId1, "S2-A1-m1");
AddSimpleUpdate(merger, step2Agent2, TestAgentId2, "S2-A2-m1");
AddSimpleUpdate(merger, step2Agent1, TestAgentId1, "S2-A1-m2");
AddSimpleUpdate(merger, step2Agent2, TestAgentId2, "S2-A2-m2");
// Step 3: Agent2 emits 2 messages.
AddSimpleUpdate(merger, step3Agent2, TestAgentId2, "S3-A2-m1");
AddSimpleUpdate(merger, step3Agent2, TestAgentId2, "S3-A2-m2");
// Act
AgentResponse response = merger.ComputeMerged(step1Agent1);
// Assert: expected output order
// Step 1 block (Agent1): S1-A1-m1, S1-A1-m2
// Step 2 Agent1 block: S2-A1-m1, S2-A1-m2
// Step 2 Agent2 block: S2-A2-m1, S2-A2-m2
// Step 3 block (Agent2): S3-A2-m1, S3-A2-m2
response.Messages.Should().HaveCount(8);
var texts = response.Messages.Select(m => m.Text).ToList();
texts.Should().ContainInOrder(
"S1-A1-m1", "S1-A1-m2",
"S2-A1-m1", "S2-A1-m2",
"S2-A2-m1", "S2-A2-m2",
"S3-A2-m1", "S3-A2-m2");
// Step boundaries: no step-2 or step-3 message may appear before the last step-1 message.
int lastStep1Index = texts.FindLastIndex(t => t.StartsWith("S1-", StringComparison.Ordinal));
int firstStep2Index = texts.FindIndex(t => t.StartsWith("S2-", StringComparison.Ordinal));
int lastStep2Index = texts.FindLastIndex(t => t.StartsWith("S2-", StringComparison.Ordinal));
int firstStep3Index = texts.FindIndex(t => t.StartsWith("S3-", StringComparison.Ordinal));
lastStep1Index.Should().BeLessThan(firstStep2Index, "all step-1 messages precede step-2");
lastStep2Index.Should().BeLessThan(firstStep3Index, "all step-2 messages precede step-3");
// Within step 2 (multi-agent), per-agent blocks must be contiguous.
int firstS2A1 = texts.FindIndex(t => t.StartsWith("S2-A1", StringComparison.Ordinal));
int lastS2A1 = texts.FindLastIndex(t => t.StartsWith("S2-A1", StringComparison.Ordinal));
int firstS2A2 = texts.FindIndex(t => t.StartsWith("S2-A2", StringComparison.Ordinal));
int lastS2A2 = texts.FindLastIndex(t => t.StartsWith("S2-A2", StringComparison.Ordinal));
(lastS2A1 - firstS2A1).Should().Be(1, "Agent1 step-2 messages should be contiguous");
(lastS2A2 - firstS2A2).Should().Be(1, "Agent2 step-2 messages should be contiguous");
(lastS2A1 < firstS2A2 || lastS2A2 < firstS2A1).Should().BeTrue("agent blocks within a step must not interleave");
static void AddSimpleUpdate(MessageMerger m, string responseId, string agentId, string text)
{
m.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = Guid.NewGuid().ToString("N"),
AgentId = agentId,
Role = ChatRole.Assistant,
Contents = [new TextContent(text)],
});
}
}
#endregion
}