// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Abstractions.UnitTests; public class AgentResponseUpdateExtensionsTests { public static IEnumerable ToAgentResponseCoalescesVariousSequenceAndGapLengthsMemberData() { foreach (bool useAsync in new[] { false, true }) { for (int numSequences = 1; numSequences <= 3; numSequences++) { for (int sequenceLength = 1; sequenceLength <= 3; sequenceLength++) { for (int gapLength = 1; gapLength <= 3; gapLength++) { foreach (bool gapBeginningEnd in new[] { false, true }) { yield return new object[] { useAsync, numSequences, sequenceLength, gapLength, false }; } } } } } } [Fact] public void ToAgentResponseWithInvalidArgsThrows() => Assert.Throws("updates", () => ((List)null!).ToAgentResponse()); [Theory] [InlineData(false)] [InlineData(true)] public async Task ToAgentResponseSuccessfullyCreatesResponseAsync(bool useAsync) { AgentResponseUpdate[] updates = [ new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), AgentId = "agentId" }, new(new("human"), ", ") { AuthorName = "Someone", AdditionalProperties = new() { ["a"] = "b" } }, new(null, "world!") { CreatedAt = new DateTimeOffset(2025, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["c"] = "d" } }, new() { Contents = [new UsageContent(new() { InputTokenCount = 1, OutputTokenCount = 2 })] }, new() { Contents = [new UsageContent(new() { InputTokenCount = 4, OutputTokenCount = 5 })] }, ]; AgentResponse response = useAsync ? updates.ToAgentResponse() : await YieldAsync(updates).ToAgentResponseAsync(); Assert.NotNull(response); Assert.Equal("agentId", response.AgentId); Assert.NotNull(response.Usage); Assert.Equal(5, response.Usage.InputTokenCount); Assert.Equal(7, response.Usage.OutputTokenCount); Assert.Equal("someResponse", response.ResponseId); Assert.Equal(new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), response.CreatedAt); Assert.Equal(2, response.Messages.Count); ChatMessage message = response.Messages[0]; Assert.Equal("12345", message.MessageId); Assert.Equal(ChatRole.Assistant, message.Role); Assert.Null(message.AuthorName); Assert.Null(message.AdditionalProperties); Assert.Single(message.Contents); Assert.Equal("Hello", Assert.IsType(message.Contents[0]).Text); message = response.Messages[1]; Assert.Null(message.MessageId); Assert.Equal(new("human"), message.Role); Assert.Equal("Someone", message.AuthorName); Assert.Single(message.Contents); Assert.Equal(", world!", Assert.IsType(message.Contents[0]).Text); Assert.NotNull(response.AdditionalProperties); Assert.Equal(2, response.AdditionalProperties.Count); Assert.Equal("b", response.AdditionalProperties["a"]); Assert.Equal("d", response.AdditionalProperties["c"]); Assert.Equal("Hello" + Environment.NewLine + ", world!", response.Text); } [Theory] [MemberData(nameof(ToAgentResponseCoalescesVariousSequenceAndGapLengthsMemberData))] public async Task ToAgentResponseCoalescesVariousSequenceAndGapLengthsAsync(bool useAsync, int numSequences, int sequenceLength, int gapLength, bool gapBeginningEnd) { List updates = []; List expected = []; if (gapBeginningEnd) { AddGap(); } for (int sequenceNum = 0; sequenceNum < numSequences; sequenceNum++) { StringBuilder sb = new(); for (int i = 0; i < sequenceLength; i++) { string text = $"{(char)('A' + sequenceNum)}{i}"; updates.Add(new(null, text)); sb.Append(text); } expected.Add(sb.ToString()); if (sequenceNum < numSequences - 1) { AddGap(); } } if (gapBeginningEnd) { AddGap(); } void AddGap() { for (int i = 0; i < gapLength; i++) { updates.Add(new() { Contents = [new DataContent("data:image/png;base64,aGVsbG8=")] }); } } AgentResponse response = useAsync ? await YieldAsync(updates).ToAgentResponseAsync() : updates.ToAgentResponse(); Assert.NotNull(response); ChatMessage message = response.Messages.Single(); Assert.NotNull(message); Assert.Equal(expected.Count + (gapLength * (numSequences - 1 + (gapBeginningEnd ? 2 : 0))), message.Contents.Count); TextContent[] contents = message.Contents.OfType().ToArray(); Assert.Equal(expected.Count, contents.Length); for (int i = 0; i < expected.Count; i++) { Assert.Equal(expected[i], contents[i].Text); } } [Theory] [InlineData(false)] [InlineData(true)] public async Task ToAgentResponseCoalescesTextContentAndTextReasoningContentSeparatelyAsync(bool useAsync) { AgentResponseUpdate[] updates = [ new(null, "A"), new(null, "B"), new(null, "C"), new() { Contents = [new TextReasoningContent("D")] }, new() { Contents = [new TextReasoningContent("E")] }, new() { Contents = [new TextReasoningContent("F")] }, new(null, "G"), new(null, "H"), new() { Contents = [new TextReasoningContent("I")] }, new() { Contents = [new TextReasoningContent("J")] }, new(null, "K"), new() { Contents = [new TextReasoningContent("L")] }, new(null, "M"), new(null, "N"), new() { Contents = [new TextReasoningContent("O")] }, new() { Contents = [new TextReasoningContent("P")] }, ]; AgentResponse response = useAsync ? await YieldAsync(updates).ToAgentResponseAsync() : updates.ToAgentResponse(); ChatMessage message = Assert.Single(response.Messages); Assert.Equal(8, message.Contents.Count); Assert.Equal("ABC", Assert.IsType(message.Contents[0]).Text); Assert.Equal("DEF", Assert.IsType(message.Contents[1]).Text); Assert.Equal("GH", Assert.IsType(message.Contents[2]).Text); Assert.Equal("IJ", Assert.IsType(message.Contents[3]).Text); Assert.Equal("K", Assert.IsType(message.Contents[4]).Text); Assert.Equal("L", Assert.IsType(message.Contents[5]).Text); Assert.Equal("MN", Assert.IsType(message.Contents[6]).Text); Assert.Equal("OP", Assert.IsType(message.Contents[7]).Text); } [Fact] public async Task ToAgentResponseUsesContentExtractedFromContentsAsync() { AgentResponseUpdate[] updates = [ new(null, "Hello, "), new(null, "world!"), new() { Contents = [new UsageContent(new() { TotalTokenCount = 42 })] }, ]; AgentResponse response = await YieldAsync(updates).ToAgentResponseAsync(); Assert.NotNull(response); Assert.NotNull(response.Usage); Assert.Equal(42, response.Usage.TotalTokenCount); Assert.Equal("Hello, world!", Assert.IsType(Assert.Single(Assert.Single(response.Messages).Contents)).Text); } [Theory] [InlineData(false)] [InlineData(true)] public async Task ToAgentResponse_AlternativeTimestampsAsync(bool useAsync) { DateTimeOffset early = new(2024, 1, 1, 10, 0, 0, TimeSpan.Zero); DateTimeOffset middle = new(2024, 1, 1, 11, 0, 0, TimeSpan.Zero); DateTimeOffset late = new(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); DateTimeOffset unixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); AgentResponseUpdate[] updates = [ // Start with an early timestamp new(ChatRole.Tool, "a") { MessageId = "4", CreatedAt = early }, // Unix epoch (as "null") should not overwrite new(null, "b") { CreatedAt = unixEpoch }, // Newer timestamp should not overwrite (first timestamp wins) new(null, "c") { CreatedAt = middle }, // Older timestamp should not overwrite new(null, "d") { CreatedAt = early }, // Even newer timestamp should not overwrite (first timestamp wins) new(null, "e") { CreatedAt = late }, // Unix epoch should not overwrite again new(null, "f") { CreatedAt = unixEpoch }, // null should not overwrite new(null, "g") { CreatedAt = null }, ]; AgentResponse response = useAsync ? updates.ToAgentResponse() : await YieldAsync(updates).ToAgentResponseAsync(); Assert.Single(response.Messages); Assert.Equal("abcdefg", response.Messages[0].Text); Assert.Equal(ChatRole.Tool, response.Messages[0].Role); Assert.Equal(early, response.Messages[0].CreatedAt); Assert.Equal(early, response.CreatedAt); } public static IEnumerable ToAgentResponse_TimestampFolding_MemberData() { // Base test cases - first non-null valid timestamp wins var testCases = new (string? timestamp1, string? timestamp2, string? expectedTimestamp)[] { (null, null, null), ("2024-01-01T10:00:00Z", null, "2024-01-01T10:00:00Z"), (null, "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), ("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z"), // First timestamp wins ("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"), // First timestamp wins ("2024-01-01T10:00:00Z", "1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z"), ("1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), }; // Yield each test case twice, once for useAsync = false and once for useAsync = true foreach (var (timestamp1, timestamp2, expectedTimestamp) in testCases) { yield return new object?[] { false, timestamp1, timestamp2, expectedTimestamp }; yield return new object?[] { true, timestamp1, timestamp2, expectedTimestamp }; } } [Theory] [MemberData(nameof(ToAgentResponse_TimestampFolding_MemberData))] public async Task ToAgentResponse_TimestampFoldingAsync(bool useAsync, string? timestamp1, string? timestamp2, string? expectedTimestamp) { DateTimeOffset? first = timestamp1 is not null ? DateTimeOffset.Parse(timestamp1) : null; DateTimeOffset? second = timestamp2 is not null ? DateTimeOffset.Parse(timestamp2) : null; DateTimeOffset? expected = expectedTimestamp is not null ? DateTimeOffset.Parse(expectedTimestamp) : null; AgentResponseUpdate[] updates = [ new(ChatRole.Assistant, "a") { CreatedAt = first }, new(null, "b") { CreatedAt = second }, ]; AgentResponse response = useAsync ? updates.ToAgentResponse() : await YieldAsync(updates).ToAgentResponseAsync(); Assert.Single(response.Messages); Assert.Equal("ab", response.Messages[0].Text); Assert.Equal(expected, response.Messages[0].CreatedAt); Assert.Equal(expected, response.CreatedAt); } #region AsChatResponse Tests [Fact] public void AsChatResponse_WithNullArgument_ThrowsArgumentNullException() { // Arrange & Act & Assert Assert.Throws("response", () => ((AgentResponse)null!).AsChatResponse()); } [Fact] public void AsChatResponse_WithRawRepresentationAsChatResponse_ReturnsSameInstance() { // Arrange ChatResponse originalChatResponse = new() { ResponseId = "original-response", Messages = [new ChatMessage(ChatRole.Assistant, "Hello")] }; AgentResponse agentResponse = new(originalChatResponse); // Act ChatResponse result = agentResponse.AsChatResponse(); // Assert Assert.Same(originalChatResponse, result); } [Fact] public void AsChatResponse_WithoutRawRepresentation_CreatesNewChatResponse() { // Arrange AgentResponse agentResponse = new(new ChatMessage(ChatRole.Assistant, "Test message")) { ResponseId = "test-response-id", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), FinishReason = ChatFinishReason.ContentFilter, Usage = new UsageDetails { TotalTokenCount = 50 }, AdditionalProperties = new() { ["key"] = "value" }, ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), }; // Act ChatResponse result = agentResponse.AsChatResponse(); // Assert Assert.NotNull(result); Assert.Equal("test-response-id", result.ResponseId); Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), result.CreatedAt); Assert.Equal(ChatFinishReason.ContentFilter, result.FinishReason); Assert.Same(agentResponse.Messages, result.Messages); Assert.Same(agentResponse, result.RawRepresentation); Assert.Same(agentResponse.Usage, result.Usage); Assert.Same(agentResponse.AdditionalProperties, result.AdditionalProperties); Assert.Equal(agentResponse.ContinuationToken, result.ContinuationToken); } #endregion #region AsChatResponseUpdate Tests [Fact] public void AsChatResponseUpdate_WithNullArgument_ThrowsArgumentNullException() { // Arrange & Act & Assert Assert.Throws("responseUpdate", () => ((AgentResponseUpdate)null!).AsChatResponseUpdate()); } [Fact] public void AsChatResponseUpdate_WithRawRepresentationAsChatResponseUpdate_ReturnsSameInstance() { // Arrange ChatResponseUpdate originalChatResponseUpdate = new() { ResponseId = "original-update", Contents = [new TextContent("Hello")] }; AgentResponseUpdate agentResponseUpdate = new(originalChatResponseUpdate); // Act ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate(); // Assert Assert.Same(originalChatResponseUpdate, result); } [Fact] public void AsChatResponseUpdate_WithRawRepresentationNullMessageId_ReturnsRawDirectly() { // Arrange - RawRepresentation has null MessageId ChatResponseUpdate originalChatResponseUpdate = new() { ResponseId = "original-update", Contents = [new TextContent("Hello")] }; AgentResponseUpdate agentResponseUpdate = new(originalChatResponseUpdate); // Act ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate(); // Assert - Returns the raw representation directly without mutation Assert.Same(originalChatResponseUpdate, result); Assert.Null(result.MessageId); } [Fact] public void AsChatResponseUpdate_WithRawRepresentationExistingMessageId_PreservesOriginal() { // Arrange - RawRepresentation already has MessageId set by provider ChatResponseUpdate originalChatResponseUpdate = new() { ResponseId = "original-update", MessageId = "provider-message-id", Contents = [new TextContent("Hello")] }; AgentResponseUpdate agentResponseUpdate = new(originalChatResponseUpdate); // Act ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate(); // Assert - Provider's original MessageId should be preserved Assert.Same(originalChatResponseUpdate, result); Assert.Equal("provider-message-id", result.MessageId); } [Fact] public void AsChatResponseUpdate_WithoutRawRepresentation_CreatesNewChatResponseUpdate() { // Arrange AgentResponseUpdate agentResponseUpdate = new(ChatRole.Assistant, "Test") { AuthorName = "TestAuthor", ResponseId = "update-id", MessageId = "message-id", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), FinishReason = ChatFinishReason.ToolCalls, AdditionalProperties = new() { ["key"] = "value" }, ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), }; // Act ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate(); // Assert Assert.NotNull(result); Assert.Equal("TestAuthor", result.AuthorName); Assert.Equal("update-id", result.ResponseId); Assert.Equal("message-id", result.MessageId); Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), result.CreatedAt); Assert.Equal(ChatFinishReason.ToolCalls, result.FinishReason); Assert.Equal(ChatRole.Assistant, result.Role); Assert.Same(agentResponseUpdate.Contents, result.Contents); Assert.Same(agentResponseUpdate, result.RawRepresentation); Assert.Same(agentResponseUpdate.AdditionalProperties, result.AdditionalProperties); Assert.Equal(agentResponseUpdate.ContinuationToken, result.ContinuationToken); } #endregion #region AsChatResponseUpdatesAsync Tests [Fact] public async Task AsChatResponseUpdatesAsync_WithNullArgument_ThrowsArgumentNullExceptionAsync() { // Arrange & Act & Assert await Assert.ThrowsAsync("responseUpdates", async () => { await foreach (ChatResponseUpdate _ in ((IAsyncEnumerable)null!).AsChatResponseUpdatesAsync()) { // Do nothing } }); } [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsUpdatesAsync() { // Arrange AgentResponseUpdate[] updates = [ new(ChatRole.Assistant, "First"), new(ChatRole.Assistant, "Second"), ]; // Act List results = []; await foreach (ChatResponseUpdate update in YieldAsync(updates).AsChatResponseUpdatesAsync()) { results.Add(update); } // Assert Assert.Equal(2, results.Count); Assert.Equal("First", Assert.IsType(results[0].Contents[0]).Text); Assert.Equal("Second", Assert.IsType(results[1].Contents[0]).Text); } #endregion private static async IAsyncEnumerable YieldAsync(IEnumerable updates) { foreach (AgentResponseUpdate update in updates) { await Task.Yield(); yield return update; } } }