Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs

607 lines
25 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class MessageMergerTests
{
public static string TestAgentId1 => "TestAgent1";
public static string TestAgentId2 => "TestAgent2";
public static string TestAuthorName1 => "Assistant1";
public static string TestAuthorName2 => "Assistant2";
[Fact]
public void Test_MessageMerger_AssemblesMessage()
{
DateTimeOffset creationTime = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(1));
string responseId = Guid.NewGuid().ToString("N");
string messageId = Guid.NewGuid().ToString("N");
MessageMerger merger = new();
foreach (AgentResponseUpdate update in "Hello Agent Framework Workflows!".ToAgentRunStream(authorName: TestAuthorName1, agentId: TestAgentId1, messageId: messageId, createdAt: creationTime, responseId: responseId))
{
merger.AddUpdate(update);
}
AgentResponse response = merger.ComputeMerged(responseId);
response.Messages.Should().HaveCount(1);
response.Messages[0].Role.Should().Be(ChatRole.Assistant);
response.Messages[0].AuthorName.Should().Be(TestAuthorName1);
response.AgentId.Should().Be(TestAgentId1);
response.CreatedAt.Should().HaveValue();
response.CreatedAt.Value.Should().BeOnOrAfter(creationTime);
response.CreatedAt.Value.Should().BeCloseTo(creationTime, precision: TimeSpan.FromSeconds(5));
response.Messages[0].CreatedAt.Should().Be(creationTime);
response.Messages[0].Contents.Should().HaveCount(1);
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()
{
// Arrange
string responseId = Guid.NewGuid().ToString("N");
string messageId = Guid.NewGuid().ToString("N");
MessageMerger merger = new();
foreach (AgentResponseUpdate update in "Hello".ToAgentRunStream(agentId: TestAgentId1, messageId: messageId, responseId: responseId))
{
merger.AddUpdate(update);
}
// Add a final update with FinishReason set
merger.AddUpdate(new AgentResponseUpdate
{
ResponseId = responseId,
MessageId = messageId,
FinishReason = ChatFinishReason.ContentFilter,
Role = ChatRole.Assistant,
});
// Act
AgentResponse response = merger.ComputeMerged(responseId);
// 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
}