diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs index 84a5f74b3b..a7e9dab92c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs @@ -137,7 +137,6 @@ internal sealed class MessageMerger UsageDetails? usage = null; AdditionalPropertiesDictionary? additionalProperties = null; - HashSet createdTimes = []; foreach (AgentResponse response in responses.Values) { @@ -146,11 +145,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); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs index edd9967377..14ea35d076 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; using FluentAssertions; using Microsoft.Extensions.AI; @@ -112,4 +113,326 @@ 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 where some have CreatedAt and some don't + 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 time1 = DateTimeOffset.UtcNow.AddMinutes(-2); + DateTimeOffset time3 = DateTimeOffset.UtcNow; + + MessageMerger merger = new(); + + // A has timestamp (time1), B has no timestamp, C has timestamp (time3) + // Insertion order: A, B, C + // B should maintain its relative position among untimestamped messages + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = responseId, + MessageId = messageIdA, + Role = ChatRole.Assistant, + CreatedAt = time1, + Contents = [new TextContent("Message A")], + }); + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = responseId, + MessageId = messageIdB, + Role = ChatRole.Assistant, + // No CreatedAt - should use insertion order as tiebreaker + Contents = [new TextContent("Message B")], + }); + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = responseId, + MessageId = messageIdC, + Role = ChatRole.Assistant, + CreatedAt = time3, + Contents = [new TextContent("Message C")], + }); + + // Act + AgentResponse response = merger.ComputeMerged(responseId); + + // Assert: Untimestamped messages should maintain relative order via insertion index fallback + response.Messages.Should().HaveCount(3); + + // A (time1) should come first, B (no timestamp, uses index 1) should be in middle, + // C (time3) should come last since it has the latest timestamp + 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_StableSort_WithThreeOrMoreMixedTimestampMessages() + { + // Arrange: 3+ messages with mixed null/non-null CreatedAt values + // This tests the non-transitive comparison edge case + 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 determinism + AgentResponse response1 = merger.ComputeMerged(responseId); + + // Create a fresh merger with same data to verify determinism + 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 deterministic and consistent across runs + response1.Messages.Should().HaveCount(3); + response2.Messages.Should().HaveCount(3); + + // 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)); + + // 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 + string responseIdR1 = Guid.NewGuid().ToString("N"); + string responseIdR2 = Guid.NewGuid().ToString("N"); + string messageIdA1 = Guid.NewGuid().ToString("N"); + string messageIdA2 = Guid.NewGuid().ToString("N"); + + MessageMerger merger = new(); + + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = responseIdR1, + MessageId = messageIdA1, + AgentId = TestAgentId1, + Role = ChatRole.Assistant, + Contents = [new TextContent("Agent1 Response")], + }); + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = responseIdR2, + MessageId = messageIdA2, + AgentId = TestAgentId2, + Role = ChatRole.Assistant, + Contents = [new TextContent("Agent2 Response")], + }); + + // Act + AgentResponse response = merger.ComputeMerged(responseIdR1); + + // Assert: Messages from each agent are contiguous (not interleaved) + response.Messages.Should().HaveCount(2); + + // Both messages should be present + var messageTexts = response.Messages.Select(m => m.Text).ToList(); + messageTexts.Should().Contain("Agent1 Response"); + messageTexts.Should().Contain("Agent2 Response"); + } + + #endregion }