mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
53693d7aea
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>
439 lines
16 KiB
C#
439 lines
16 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 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
|
|
}
|