mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
44381c051b
* Support reasoning * MEAI gives the same MessageId for reasoning and text content because they are part of the same logical model response. Create a new GUID for reasoning messages to be consistent with AGUI protocol and establish no link between reasoning and text messages * When a frontend AG-UI client sends conversation history back in a subsequent POST, any accumulated role: "reasoning" messages fail deserialization in AGUIMessageJsonConverter because the role wasn't handled - causing the request to fail. This adds AGUIReasoningMessage with Content and EncryptedValue properties, registers it in the JSON converter and serializer context, and converts it to TextReasoningContent (with ProtectedData) in AsChatMessages. * Added MapReasoningMessage - converts a ChatMessage containing TextReasoningContent to AGUIReasoningMessage for c# client * review * Support reasoning * MEAI gives the same MessageId for reasoning and text content because they are part of the same logical model response. Create a new GUID for reasoning messages to be consistent with AGUI protocol and establish no link between reasoning and text messages * When a frontend AG-UI client sends conversation history back in a subsequent POST, any accumulated role: "reasoning" messages fail deserialization in AGUIMessageJsonConverter because the role wasn't handled - causing the request to fail. This adds AGUIReasoningMessage with Content and EncryptedValue properties, registers it in the JSON converter and serializer context, and converts it to TextReasoningContent (with ProtectedData) in AsChatMessages. * Added MapReasoningMessage - converts a ChatMessage containing TextReasoningContent to AGUIReasoningMessage for c# client * review * dotnet format * Replace hardcoded string with constant Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
918 lines
35 KiB
C#
918 lines
35 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Agents.AI.AGUI.Shared;
|
|
using Microsoft.Extensions.AI;
|
|
|
|
namespace Microsoft.Agents.AI.AGUI.UnitTests;
|
|
|
|
// Custom complex type for testing tool call parameters
|
|
public sealed class WeatherRequest
|
|
{
|
|
public string Location { get; set; } = string.Empty;
|
|
public string Units { get; set; } = "celsius";
|
|
public bool IncludeForecast { get; set; }
|
|
}
|
|
|
|
// Custom complex type for testing tool call results
|
|
public sealed class WeatherResponse
|
|
{
|
|
public double Temperature { get; set; }
|
|
public string Conditions { get; set; } = string.Empty;
|
|
public DateTime Timestamp { get; set; }
|
|
}
|
|
|
|
// Custom JsonSerializerContext for the custom types
|
|
[JsonSerializable(typeof(WeatherRequest))]
|
|
[JsonSerializable(typeof(WeatherResponse))]
|
|
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
|
internal sealed partial class CustomTypesContext : JsonSerializerContext;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the <see cref="AGUIChatMessageExtensions"/> class.
|
|
/// </summary>
|
|
public sealed class AGUIChatMessageExtensionsTests
|
|
{
|
|
[Fact]
|
|
public void AsChatMessages_WithEmptyCollection_ReturnsEmptyList()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages = [];
|
|
|
|
// Act
|
|
IEnumerable<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options);
|
|
|
|
// Assert
|
|
Assert.NotNull(chatMessages);
|
|
Assert.Empty(chatMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithSingleMessage_ConvertsToChatMessageCorrectly()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIUserMessage
|
|
{
|
|
Id = "msg1",
|
|
Content = "Hello"
|
|
}
|
|
];
|
|
|
|
// Act
|
|
IEnumerable<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options);
|
|
|
|
// Assert
|
|
ChatMessage message = Assert.Single(chatMessages);
|
|
Assert.Equal(ChatRole.User, message.Role);
|
|
Assert.Equal("Hello", message.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithMultipleMessages_PreservesOrder()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIUserMessage { Id = "msg1", Content = "First" },
|
|
new AGUIAssistantMessage { Id = "msg2", Content = "Second" },
|
|
new AGUIUserMessage { Id = "msg3", Content = "Third" }
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(3, chatMessages.Count);
|
|
Assert.Equal("First", chatMessages[0].Text);
|
|
Assert.Equal("Second", chatMessages[1].Text);
|
|
Assert.Equal("Third", chatMessages[2].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_MapsAllSupportedRoleTypes_Correctly()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUISystemMessage { Id = "msg1", Content = "System message" },
|
|
new AGUIUserMessage { Id = "msg2", Content = "User message" },
|
|
new AGUIAssistantMessage { Id = "msg3", Content = "Assistant message" },
|
|
new AGUIDeveloperMessage { Id = "msg4", Content = "Developer message" },
|
|
new AGUIReasoningMessage { Id = "msg5", Content = "Reasoning message" }
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(5, chatMessages.Count);
|
|
Assert.Equal(ChatRole.System, chatMessages[0].Role);
|
|
Assert.Equal(ChatRole.User, chatMessages[1].Role);
|
|
Assert.Equal(ChatRole.Assistant, chatMessages[2].Role);
|
|
Assert.Equal("developer", chatMessages[3].Role.Value);
|
|
Assert.Equal(ChatRole.Assistant, chatMessages[4].Role);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithEmptyCollection_ReturnsEmptyList()
|
|
{
|
|
// Arrange
|
|
List<ChatMessage> chatMessages = [];
|
|
|
|
// Act
|
|
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options);
|
|
|
|
// Assert
|
|
Assert.NotNull(aguiMessages);
|
|
Assert.Empty(aguiMessages);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithSingleMessage_ConvertsToAGUIMessageCorrectly()
|
|
{
|
|
// Arrange
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.User, "Hello") { MessageId = "msg1" }
|
|
];
|
|
|
|
// Act
|
|
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options);
|
|
|
|
// Assert
|
|
AGUIMessage message = Assert.Single(aguiMessages);
|
|
Assert.Equal("msg1", message.Id);
|
|
Assert.Equal(AGUIRoles.User, message.Role);
|
|
Assert.Equal("Hello", ((AGUIUserMessage)message).Content);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithMultipleMessages_PreservesOrder()
|
|
{
|
|
// Arrange
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.User, "First"),
|
|
new ChatMessage(ChatRole.Assistant, "Second"),
|
|
new ChatMessage(ChatRole.User, "Third")
|
|
];
|
|
|
|
// Act
|
|
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(3, aguiMessages.Count);
|
|
Assert.Equal("First", ((AGUIUserMessage)aguiMessages[0]).Content);
|
|
Assert.Equal("Second", ((AGUIAssistantMessage)aguiMessages[1]).Content);
|
|
Assert.Equal("Third", ((AGUIUserMessage)aguiMessages[2]).Content);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_PreservesMessageId_WhenPresent()
|
|
{
|
|
// Arrange
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.User, "Hello") { MessageId = "msg123" }
|
|
];
|
|
|
|
// Act
|
|
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options);
|
|
|
|
// Assert
|
|
AGUIMessage message = Assert.Single(aguiMessages);
|
|
Assert.Equal("msg123", message.Id);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(AGUIRoles.System, "system")]
|
|
[InlineData(AGUIRoles.User, "user")]
|
|
[InlineData(AGUIRoles.Assistant, "assistant")]
|
|
[InlineData(AGUIRoles.Developer, "developer")]
|
|
public void MapChatRole_WithValidRole_ReturnsCorrectChatRole(string aguiRole, string expectedRoleValue)
|
|
{
|
|
// Arrange & Act
|
|
ChatRole role = AGUIChatMessageExtensions.MapChatRole(aguiRole);
|
|
|
|
// Assert
|
|
Assert.Equal(expectedRoleValue, role.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapChatRole_WithUnknownRole_ThrowsInvalidOperationException()
|
|
{
|
|
// Arrange & Act & Assert
|
|
Assert.Throws<InvalidOperationException>(() => AGUIChatMessageExtensions.MapChatRole("unknown"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithToolResultMessage_SerializesResultCorrectly()
|
|
{
|
|
// Arrange
|
|
var result = new Dictionary<string, object?> { ["temperature"] = 72, ["condition"] = "Sunny" };
|
|
FunctionResultContent toolResult = new("call_123", result);
|
|
ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]);
|
|
List<ChatMessage> messages = [toolMessage];
|
|
|
|
// Act
|
|
List<AGUIMessage> aguiMessages = messages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
AGUIMessage aguiMessage = Assert.Single(aguiMessages);
|
|
Assert.Equal(AGUIRoles.Tool, aguiMessage.Role);
|
|
Assert.Equal("call_123", ((AGUIToolMessage)aguiMessage).ToolCallId);
|
|
Assert.NotEmpty(((AGUIToolMessage)aguiMessage).Content);
|
|
// Content should be serialized JSON
|
|
Assert.Contains("temperature", ((AGUIToolMessage)aguiMessage).Content);
|
|
Assert.Contains("72", ((AGUIToolMessage)aguiMessage).Content);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithNullToolResult_HandlesGracefully()
|
|
{
|
|
// Arrange
|
|
FunctionResultContent toolResult = new("call_456", null);
|
|
ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]);
|
|
List<ChatMessage> messages = [toolMessage];
|
|
|
|
// Act
|
|
List<AGUIMessage> aguiMessages = messages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
AGUIMessage aguiMessage = Assert.Single(aguiMessages);
|
|
Assert.Equal(AGUIRoles.Tool, aguiMessage.Role);
|
|
Assert.Equal("call_456", ((AGUIToolMessage)aguiMessage).ToolCallId);
|
|
Assert.Equal(string.Empty, ((AGUIToolMessage)aguiMessage).Content);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithoutTypeInfoResolver_ThrowsInvalidOperationException()
|
|
{
|
|
// Arrange
|
|
FunctionResultContent toolResult = new("call_789", "Result");
|
|
ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]);
|
|
List<ChatMessage> messages = [toolMessage];
|
|
System.Text.Json.JsonSerializerOptions optionsWithoutResolver = new();
|
|
|
|
// Act & Assert
|
|
NotSupportedException ex = Assert.Throws<NotSupportedException>(() => messages.AsAGUIMessages(optionsWithoutResolver).ToList());
|
|
Assert.Contains("JsonTypeInfo", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithToolMessage_DeserializesResultCorrectly()
|
|
{
|
|
// Arrange
|
|
const string JsonContent = "{\"status\":\"success\",\"value\":42}";
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIToolMessage
|
|
{
|
|
Id = "msg1",
|
|
Content = JsonContent,
|
|
ToolCallId = "call_abc"
|
|
}
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
ChatMessage message = Assert.Single(chatMessages);
|
|
Assert.Equal(ChatRole.Tool, message.Role);
|
|
FunctionResultContent result = Assert.IsType<FunctionResultContent>(message.Contents[0]);
|
|
Assert.Equal("call_abc", result.CallId);
|
|
Assert.NotNull(result.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithEmptyToolContent_CreatesNullResult()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIToolMessage
|
|
{
|
|
Id = "msg1",
|
|
Content = string.Empty,
|
|
ToolCallId = "call_def"
|
|
}
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
ChatMessage message = Assert.Single(chatMessages);
|
|
FunctionResultContent result = Assert.IsType<FunctionResultContent>(message.Contents[0]);
|
|
Assert.Equal("call_def", result.CallId);
|
|
Assert.Equal(string.Empty, result.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithToolMessageWithoutCallId_TreatsAsRegularMessage()
|
|
{
|
|
// Arrange - use valid JSON for Content
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIToolMessage
|
|
{
|
|
Id = "msg1",
|
|
Content = "{\"result\":\"Some content\"}",
|
|
ToolCallId = string.Empty
|
|
}
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
ChatMessage message = Assert.Single(chatMessages);
|
|
Assert.Equal(ChatRole.Tool, message.Role);
|
|
var resultContent = Assert.IsType<FunctionResultContent>(message.Contents.First());
|
|
Assert.Equal(string.Empty, resultContent.CallId);
|
|
}
|
|
|
|
[Fact]
|
|
public void RoundTrip_ToolResultMessage_PreservesData()
|
|
{
|
|
// Arrange
|
|
var resultData = new Dictionary<string, object?> { ["location"] = "Seattle", ["temperature"] = 68, ["forecast"] = "Partly cloudy" };
|
|
FunctionResultContent originalResult = new("call_roundtrip", resultData);
|
|
ChatMessage originalMessage = new(ChatRole.Tool, [originalResult]);
|
|
|
|
// Act - Convert to AGUI and back
|
|
List<ChatMessage> originalList = [originalMessage];
|
|
AGUIMessage aguiMessage = originalList.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).Single();
|
|
List<AGUIMessage> aguiList = [aguiMessage];
|
|
ChatMessage reconstructedMessage = aguiList.AsChatMessages(AGUIJsonSerializerContext.Default.Options).Single();
|
|
|
|
// Assert
|
|
Assert.Equal(ChatRole.Tool, reconstructedMessage.Role);
|
|
FunctionResultContent reconstructedResult = Assert.IsType<FunctionResultContent>(reconstructedMessage.Contents[0]);
|
|
Assert.Equal("call_roundtrip", reconstructedResult.CallId);
|
|
Assert.NotNull(reconstructedResult.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapChatRole_WithToolRole_ReturnsToolChatRole()
|
|
{
|
|
// Arrange & Act
|
|
ChatRole role = AGUIChatMessageExtensions.MapChatRole(AGUIRoles.Tool);
|
|
|
|
// Assert
|
|
Assert.Equal(ChatRole.Tool, role);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithReasoningMessage_ConvertsToTextReasoningContent()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIReasoningMessage
|
|
{
|
|
Id = "reason1",
|
|
Content = "I need to consider the user's request.",
|
|
EncryptedValue = "ErgDCkgIDB..."
|
|
}
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
ChatMessage message = Assert.Single(chatMessages);
|
|
Assert.Equal(ChatRole.Assistant, message.Role);
|
|
Assert.Equal("reason1", message.MessageId);
|
|
var reasoningContent = Assert.IsType<TextReasoningContent>(message.Contents[0]);
|
|
Assert.Equal("I need to consider the user's request.", reasoningContent.Text);
|
|
Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithReasoningMessageWithoutEncryptedValue_ConvertsToTextReasoningContent()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIReasoningMessage
|
|
{
|
|
Id = "reason1",
|
|
Content = "Thinking about this problem."
|
|
}
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
ChatMessage message = Assert.Single(chatMessages);
|
|
Assert.Equal(ChatRole.Assistant, message.Role);
|
|
var reasoningContent = Assert.IsType<TextReasoningContent>(message.Contents[0]);
|
|
Assert.Equal("Thinking about this problem.", reasoningContent.Text);
|
|
Assert.Null(reasoningContent.ProtectedData);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithReasoningMessageWithOnlyEncryptedValue_ConvertsToTextReasoningContent()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIReasoningMessage
|
|
{
|
|
Id = "reason1",
|
|
Content = string.Empty,
|
|
EncryptedValue = "ErgDCkgIDB..."
|
|
}
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
ChatMessage message = Assert.Single(chatMessages);
|
|
var reasoningContent = Assert.IsType<TextReasoningContent>(message.Contents[0]);
|
|
Assert.Equal("", reasoningContent.Text);
|
|
Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithEmptyReasoningMessage_ProducesEmptyContents()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIReasoningMessage
|
|
{
|
|
Id = "reason1",
|
|
Content = string.Empty
|
|
}
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
ChatMessage message = Assert.Single(chatMessages);
|
|
Assert.Equal(ChatRole.Assistant, message.Role);
|
|
Assert.Empty(message.Contents);
|
|
}
|
|
|
|
[Fact]
|
|
public void MapChatRole_WithReasoningRole_ReturnsAssistantChatRole()
|
|
{
|
|
// Arrange & Act
|
|
ChatRole role = AGUIChatMessageExtensions.MapChatRole(AGUIRoles.Reasoning);
|
|
|
|
// Assert
|
|
Assert.Equal(ChatRole.Assistant, role);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithMixedMessagesIncludingReasoning_PreservesOrder()
|
|
{
|
|
// Arrange
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIUserMessage { Id = "msg1", Content = "What is 2+2?" },
|
|
new AGUIReasoningMessage { Id = "msg2", Content = "I need to add 2 and 2.", EncryptedValue = "tok-123" },
|
|
new AGUIAssistantMessage { Id = "msg3", Content = "The answer is 4." }
|
|
];
|
|
|
|
// Act
|
|
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(3, chatMessages.Count);
|
|
Assert.Equal(ChatRole.User, chatMessages[0].Role);
|
|
Assert.Equal(ChatRole.Assistant, chatMessages[1].Role);
|
|
Assert.IsType<TextReasoningContent>(chatMessages[1].Contents[0]);
|
|
Assert.Equal(ChatRole.Assistant, chatMessages[2].Role);
|
|
Assert.Equal("The answer is 4.", chatMessages[2].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithReasoningContent_ProducesReasoningMessage()
|
|
{
|
|
// Arrange
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Assistant, [
|
|
new TextReasoningContent("I need to think about this.") { ProtectedData = "encrypted-tok-1" }
|
|
]) { MessageId = "reason-1" }
|
|
];
|
|
|
|
// Act
|
|
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
AGUIMessage message = Assert.Single(aguiMessages);
|
|
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
|
|
Assert.Equal("reason-1", reasoningMessage.Id);
|
|
Assert.Equal(AGUIRoles.Reasoning, reasoningMessage.Role);
|
|
Assert.Equal("I need to think about this.", reasoningMessage.Content);
|
|
Assert.Equal("encrypted-tok-1", reasoningMessage.EncryptedValue);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithReasoningContentWithoutProtectedData_ProducesReasoningMessage()
|
|
{
|
|
// Arrange
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Assistant, [
|
|
new TextReasoningContent("Just thinking.")
|
|
]) { MessageId = "reason-2" }
|
|
];
|
|
|
|
// Act
|
|
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
AGUIMessage message = Assert.Single(aguiMessages);
|
|
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
|
|
Assert.Equal("Just thinking.", reasoningMessage.Content);
|
|
Assert.Null(reasoningMessage.EncryptedValue);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithMultipleReasoningChunksInOneMessage_ConcatenatesText()
|
|
{
|
|
// Arrange
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Assistant, [
|
|
new TextReasoningContent("First part. "),
|
|
new TextReasoningContent("Second part.") { ProtectedData = "final-token" }
|
|
]) { MessageId = "reason-3" }
|
|
];
|
|
|
|
// Act
|
|
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
AGUIMessage message = Assert.Single(aguiMessages);
|
|
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
|
|
Assert.Equal("First part. Second part.", reasoningMessage.Content);
|
|
Assert.Equal("final-token", reasoningMessage.EncryptedValue);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithMixedReasoningAndTextContent_EmitsBothMessages()
|
|
{
|
|
// Arrange
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Assistant, [
|
|
new TextReasoningContent("Thinking about the answer.") { ProtectedData = "enc-tok" },
|
|
new TextContent("The answer is 42.")
|
|
]) { MessageId = "msg-mixed" }
|
|
];
|
|
|
|
// Act
|
|
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(2, aguiMessages.Count);
|
|
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(aguiMessages[0]);
|
|
Assert.Equal("msg-mixed", reasoningMessage.Id);
|
|
Assert.Equal("Thinking about the answer.", reasoningMessage.Content);
|
|
Assert.Equal("enc-tok", reasoningMessage.EncryptedValue);
|
|
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(aguiMessages[1]);
|
|
Assert.Equal("msg-mixed", assistantMessage.Id);
|
|
Assert.Equal("The answer is 42.", assistantMessage.Content);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithReasoningAndToolCallInSameMessage_EmitsBothMessages()
|
|
{
|
|
// Arrange
|
|
var arguments = new Dictionary<string, object?> { ["location"] = "Seattle" };
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Assistant, [
|
|
new TextReasoningContent("I should look up the weather."),
|
|
new FunctionCallContent("call-1", "GetWeather", arguments)
|
|
]) { MessageId = "msg-toolcall" }
|
|
];
|
|
|
|
// Act
|
|
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(2, aguiMessages.Count);
|
|
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(aguiMessages[0]);
|
|
Assert.Equal("I should look up the weather.", reasoningMessage.Content);
|
|
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(aguiMessages[1]);
|
|
Assert.NotNull(assistantMessage.ToolCalls);
|
|
var toolCall = Assert.Single(assistantMessage.ToolCalls);
|
|
Assert.Equal("call-1", toolCall.Id);
|
|
Assert.Equal("GetWeather", toolCall.Function.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void RoundTrip_ReasoningMessage_PreservesData()
|
|
{
|
|
// Arrange
|
|
List<ChatMessage> originalMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Assistant, [
|
|
new TextReasoningContent("Thinking about the problem.") { ProtectedData = "ErgDCkgIDB..." }
|
|
]) { MessageId = "reason-rt" }
|
|
];
|
|
|
|
// Act - Convert to AGUI and back
|
|
AGUIMessage aguiMessage = originalMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).Single();
|
|
List<AGUIMessage> aguiList = [aguiMessage];
|
|
ChatMessage reconstructed = aguiList.AsChatMessages(AGUIJsonSerializerContext.Default.Options).Single();
|
|
|
|
// Assert
|
|
Assert.Equal(ChatRole.Assistant, reconstructed.Role);
|
|
var reasoningContent = Assert.IsType<TextReasoningContent>(reconstructed.Contents[0]);
|
|
Assert.Equal("Thinking about the problem.", reasoningContent.Text);
|
|
Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData);
|
|
}
|
|
|
|
#region Custom Type Serialization Tests
|
|
|
|
[Fact]
|
|
public void AsChatMessages_WithFunctionCallContainingCustomType_SerializesCorrectly()
|
|
{
|
|
// Arrange
|
|
var customRequest = new WeatherRequest { Location = "Seattle", Units = "fahrenheit", IncludeForecast = true };
|
|
var parameters = new Dictionary<string, object?>
|
|
{
|
|
["location"] = customRequest.Location,
|
|
["units"] = customRequest.Units,
|
|
["includeForecast"] = customRequest.IncludeForecast
|
|
};
|
|
|
|
List<AGUIMessage> aguiMessages =
|
|
[
|
|
new AGUIAssistantMessage
|
|
{
|
|
Id = "msg1",
|
|
ToolCalls =
|
|
[
|
|
new AGUIToolCall
|
|
{
|
|
Id = "call_1",
|
|
Function = new AGUIFunctionCall
|
|
{
|
|
Name = "GetWeather",
|
|
Arguments = System.Text.Json.JsonSerializer.Serialize(parameters, AGUIJsonSerializerContext.Default.Options)
|
|
}
|
|
}
|
|
]
|
|
}
|
|
];
|
|
|
|
// Combine contexts for serialization
|
|
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
|
AGUIJsonSerializerContext.Default,
|
|
CustomTypesContext.Default)
|
|
};
|
|
|
|
// Act
|
|
IEnumerable<ChatMessage> chatMessages = aguiMessages.AsChatMessages(combinedOptions);
|
|
|
|
// Assert
|
|
ChatMessage message = Assert.Single(chatMessages);
|
|
Assert.Equal(ChatRole.Assistant, message.Role);
|
|
var toolCallContent = Assert.IsType<FunctionCallContent>(message.Contents.First());
|
|
Assert.Equal("call_1", toolCallContent.CallId);
|
|
Assert.Equal("GetWeather", toolCallContent.Name);
|
|
Assert.NotNull(toolCallContent.Arguments);
|
|
// Compare as strings since deserialization produces JsonElement objects
|
|
Assert.Equal("Seattle", ((System.Text.Json.JsonElement)toolCallContent.Arguments["location"]!).GetString());
|
|
Assert.Equal("fahrenheit", ((System.Text.Json.JsonElement)toolCallContent.Arguments["units"]!).GetString());
|
|
Assert.True(toolCallContent.Arguments["includeForecast"] is System.Text.Json.JsonElement j && j.GetBoolean());
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithFunctionResultContainingCustomType_SerializesCorrectly()
|
|
{
|
|
// Arrange
|
|
var customResponse = new WeatherResponse { Temperature = 72.5, Conditions = "Sunny", Timestamp = DateTime.UtcNow };
|
|
var resultObject = new Dictionary<string, object?>
|
|
{
|
|
["temperature"] = customResponse.Temperature,
|
|
["conditions"] = customResponse.Conditions,
|
|
["timestamp"] = customResponse.Timestamp.ToString("O")
|
|
};
|
|
|
|
var resultJson = System.Text.Json.JsonSerializer.Serialize(resultObject, AGUIJsonSerializerContext.Default.Options);
|
|
var functionResult = new FunctionResultContent("call_1", System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(resultJson, AGUIJsonSerializerContext.Default.Options));
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Tool, [functionResult])
|
|
];
|
|
|
|
// Combine contexts for serialization
|
|
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
|
AGUIJsonSerializerContext.Default,
|
|
CustomTypesContext.Default)
|
|
};
|
|
|
|
// Act
|
|
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(combinedOptions);
|
|
|
|
// Assert
|
|
AGUIMessage message = Assert.Single(aguiMessages);
|
|
var toolMessage = Assert.IsType<AGUIToolMessage>(message);
|
|
Assert.Equal("call_1", toolMessage.ToolCallId);
|
|
Assert.NotNull(toolMessage.Content);
|
|
|
|
// Verify the content can be deserialized back
|
|
var deserializedResult = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(
|
|
toolMessage.Content,
|
|
combinedOptions);
|
|
Assert.NotNull(deserializedResult);
|
|
Assert.Equal(72.5, deserializedResult["temperature"].GetDouble());
|
|
Assert.Equal("Sunny", deserializedResult["conditions"].GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void RoundTrip_WithCustomTypesInFunctionCallAndResult_PreservesData()
|
|
{
|
|
// Arrange
|
|
var customRequest = new WeatherRequest { Location = "New York", Units = "celsius", IncludeForecast = false };
|
|
var parameters = new Dictionary<string, object?>
|
|
{
|
|
["location"] = customRequest.Location,
|
|
["units"] = customRequest.Units,
|
|
["includeForecast"] = customRequest.IncludeForecast
|
|
};
|
|
|
|
var customResponse = new WeatherResponse { Temperature = 22.3, Conditions = "Cloudy", Timestamp = DateTime.UtcNow };
|
|
var resultObject = new Dictionary<string, object?>
|
|
{
|
|
["temperature"] = customResponse.Temperature,
|
|
["conditions"] = customResponse.Conditions,
|
|
["timestamp"] = customResponse.Timestamp.ToString("O")
|
|
};
|
|
|
|
var resultJson = System.Text.Json.JsonSerializer.Serialize(resultObject, AGUIJsonSerializerContext.Default.Options);
|
|
var resultElement = System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(resultJson, AGUIJsonSerializerContext.Default.Options);
|
|
|
|
List<ChatMessage> originalChatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_1", "GetWeather", parameters)]),
|
|
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call_1", resultElement)])
|
|
];
|
|
|
|
// Combine contexts for serialization
|
|
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
|
AGUIJsonSerializerContext.Default,
|
|
CustomTypesContext.Default)
|
|
};
|
|
|
|
// Act - Convert to AGUI messages and back
|
|
IEnumerable<AGUIMessage> aguiMessages = originalChatMessages.AsAGUIMessages(combinedOptions);
|
|
List<ChatMessage> roundTrippedChatMessages = aguiMessages.AsChatMessages(combinedOptions).ToList();
|
|
|
|
// Assert
|
|
Assert.Equal(2, roundTrippedChatMessages.Count);
|
|
|
|
// Verify function call
|
|
ChatMessage callMessage = roundTrippedChatMessages[0];
|
|
Assert.Equal(ChatRole.Assistant, callMessage.Role);
|
|
var functionCall = Assert.IsType<FunctionCallContent>(callMessage.Contents.First());
|
|
Assert.Equal("call_1", functionCall.CallId);
|
|
Assert.Equal("GetWeather", functionCall.Name);
|
|
Assert.NotNull(functionCall.Arguments);
|
|
// Compare string values from JsonElement
|
|
Assert.Equal(customRequest.Location, functionCall.Arguments["location"]?.ToString());
|
|
Assert.Equal(customRequest.Units, functionCall.Arguments["units"]?.ToString());
|
|
|
|
// Verify function result
|
|
ChatMessage resultMessage = roundTrippedChatMessages[1];
|
|
Assert.Equal(ChatRole.Tool, resultMessage.Role);
|
|
var functionResultContent = Assert.IsType<FunctionResultContent>(resultMessage.Contents.First());
|
|
Assert.Equal("call_1", functionResultContent.CallId);
|
|
Assert.NotNull(functionResultContent.Result);
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithNestedCustomObjects_HandlesComplexSerialization()
|
|
{
|
|
// Arrange - nested custom types
|
|
var nestedParameters = new Dictionary<string, object?>
|
|
{
|
|
["request"] = new Dictionary<string, object?>
|
|
{
|
|
["location"] = "Boston",
|
|
["options"] = new Dictionary<string, object?>
|
|
{
|
|
["units"] = "fahrenheit",
|
|
["includeHumidity"] = true,
|
|
["daysAhead"] = 5
|
|
}
|
|
}
|
|
};
|
|
|
|
var functionCall = new FunctionCallContent("call_nested", "GetDetailedWeather", nestedParameters);
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Assistant, [functionCall])
|
|
];
|
|
|
|
// Combine contexts for serialization
|
|
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
|
AGUIJsonSerializerContext.Default,
|
|
CustomTypesContext.Default)
|
|
};
|
|
|
|
// Act
|
|
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(combinedOptions);
|
|
|
|
// Assert
|
|
AGUIMessage message = Assert.Single(aguiMessages);
|
|
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(message);
|
|
Assert.NotNull(assistantMessage.ToolCalls);
|
|
var toolCall = Assert.Single(assistantMessage.ToolCalls);
|
|
Assert.Equal("call_nested", toolCall.Id);
|
|
Assert.Equal("GetDetailedWeather", toolCall.Function?.Name);
|
|
|
|
// Verify nested structure is preserved
|
|
var deserializedArgs = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(
|
|
toolCall.Function?.Arguments ?? "{}",
|
|
combinedOptions);
|
|
Assert.NotNull(deserializedArgs);
|
|
Assert.True(deserializedArgs.ContainsKey("request"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AsAGUIMessages_WithDictionaryContainingCustomTypes_SerializesDirectly()
|
|
{
|
|
// Arrange - Create a dictionary with custom type values (not flattened)
|
|
var customRequest = new WeatherRequest { Location = "Tokyo", Units = "celsius", IncludeForecast = true };
|
|
var parameters = new Dictionary<string, object?>
|
|
{
|
|
["customRequest"] = customRequest, // Custom type as value
|
|
["simpleString"] = "test",
|
|
["simpleNumber"] = 42
|
|
};
|
|
|
|
List<ChatMessage> chatMessages =
|
|
[
|
|
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_custom", "ProcessWeather", parameters)])
|
|
];
|
|
|
|
// Combine contexts for serialization
|
|
var combinedOptions = new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
|
|
AGUIJsonSerializerContext.Default,
|
|
CustomTypesContext.Default)
|
|
};
|
|
|
|
// Act
|
|
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(combinedOptions);
|
|
|
|
// Assert
|
|
AGUIMessage message = Assert.Single(aguiMessages);
|
|
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(message);
|
|
Assert.NotNull(assistantMessage.ToolCalls);
|
|
var toolCall = Assert.Single(assistantMessage.ToolCalls);
|
|
Assert.Equal("call_custom", toolCall.Id);
|
|
Assert.Equal("ProcessWeather", toolCall.Function?.Name);
|
|
|
|
// Verify custom type was serialized correctly without flattening
|
|
var deserializedArgs = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(
|
|
toolCall.Function?.Arguments ?? "{}",
|
|
combinedOptions);
|
|
Assert.NotNull(deserializedArgs);
|
|
Assert.True(deserializedArgs.ContainsKey("customRequest"));
|
|
Assert.True(deserializedArgs.ContainsKey("simpleString"));
|
|
Assert.True(deserializedArgs.ContainsKey("simpleNumber"));
|
|
|
|
// Verify the custom type properties are accessible
|
|
var customRequestElement = deserializedArgs["customRequest"];
|
|
Assert.Equal("Tokyo", customRequestElement.GetProperty("Location").GetString());
|
|
Assert.Equal("celsius", customRequestElement.GetProperty("Units").GetString());
|
|
Assert.True(customRequestElement.GetProperty("IncludeForecast").GetBoolean());
|
|
|
|
// Verify simple types
|
|
Assert.Equal("test", deserializedArgs["simpleString"].GetString());
|
|
Assert.Equal(42, deserializedArgs["simpleNumber"].GetInt32());
|
|
}
|
|
|
|
#endregion
|
|
}
|