test
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
Python - Merge - Tests / paths-filter (push) Has been cancelled
Python - Merge - Tests / Python Tests - Core (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / Python Tests - Azure AI (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / python-integration-tests-check (push) Has been cancelled
Python - Lab Tests / paths-filter (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.14) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.14) (push) Has been cancelled
Check .md links / markdown-link-check (push) Has been cancelled

This commit is contained in:
2026-01-24 03:05:12 +11:00
parent f78f2388b3
commit 539852f81c
2584 changed files with 287471 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,644 @@
// 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" }
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(4, 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);
}
[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);
}
#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
}

View File

@@ -0,0 +1,198 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.AGUI.Shared;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.AGUI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AGUIHttpService"/> class.
/// </summary>
public sealed class AGUIHttpServiceTests
{
[Fact]
public async Task PostRunAsync_SendsRequestAndParsesSSEStream_SuccessfullyAsync()
{
// Arrange
BaseEvent[] events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
HttpClient httpClient = CreateMockHttpClient(events, HttpStatusCode.OK);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act
List<BaseEvent> resultEvents = [];
await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None))
{
resultEvents.Add(evt);
}
// Assert
Assert.Equal(5, resultEvents.Count);
Assert.IsType<RunStartedEvent>(resultEvents[0]);
Assert.IsType<TextMessageStartEvent>(resultEvents[1]);
Assert.IsType<TextMessageContentEvent>(resultEvents[2]);
Assert.IsType<TextMessageEndEvent>(resultEvents[3]);
Assert.IsType<RunFinishedEvent>(resultEvents[4]);
}
[Fact]
public async Task PostRunAsync_WithNonSuccessStatusCode_ThrowsHttpRequestExceptionAsync()
{
// Arrange
HttpClient httpClient = CreateMockHttpClient([], HttpStatusCode.InternalServerError);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act & Assert
await Assert.ThrowsAsync<HttpRequestException>(async () =>
{
await foreach (var _ in service.PostRunAsync(input, CancellationToken.None))
{
// Consume the stream
}
});
}
[Fact]
public async Task PostRunAsync_DeserializesMultipleEventTypes_CorrectlyAsync()
{
// Arrange
BaseEvent[] events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunErrorEvent { Message = "Error occurred", Code = "ERR001" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonElement.Parse("\"Success\"") }
];
HttpClient httpClient = CreateMockHttpClient(events, HttpStatusCode.OK);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act
List<BaseEvent> resultEvents = [];
await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None))
{
resultEvents.Add(evt);
}
// Assert
Assert.Equal(3, resultEvents.Count);
RunStartedEvent startedEvent = Assert.IsType<RunStartedEvent>(resultEvents[0]);
Assert.Equal("thread1", startedEvent.ThreadId);
RunErrorEvent errorEvent = Assert.IsType<RunErrorEvent>(resultEvents[1]);
Assert.Equal("Error occurred", errorEvent.Message);
RunFinishedEvent finishedEvent = Assert.IsType<RunFinishedEvent>(resultEvents[2]);
Assert.Equal("Success", finishedEvent.Result?.GetString());
}
[Fact]
public async Task PostRunAsync_WithEmptyEventStream_CompletesSuccessfullyAsync()
{
// Arrange
HttpClient httpClient = CreateMockHttpClient([], HttpStatusCode.OK);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act
List<BaseEvent> resultEvents = [];
await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None))
{
resultEvents.Add(evt);
}
// Assert
Assert.Empty(resultEvents);
}
[Fact]
public async Task PostRunAsync_WithCancellationToken_CancelsRequestAsync()
{
// Arrange
CancellationTokenSource cts = new();
cts.Cancel();
Mock<HttpMessageHandler> handlerMock = new(MockBehavior.Strict);
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new TaskCanceledException());
HttpClient httpClient = new(handlerMock.Object);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act & Assert
await Assert.ThrowsAsync<TaskCanceledException>(async () =>
{
await foreach (var _ in service.PostRunAsync(input, cts.Token))
{
// Intentionally empty - consuming stream to trigger cancellation
}
});
}
private static HttpClient CreateMockHttpClient(BaseEvent[] events, HttpStatusCode statusCode)
{
string sseContent = string.Concat(events.Select(e =>
$"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
Mock<HttpMessageHandler> handlerMock = new(MockBehavior.Strict);
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(sseContent)
});
return new HttpClient(handlerMock.Object);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.AGUI.Shared;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.AGUI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIToolExtensions"/> class.
/// </summary>
public sealed class AIToolExtensionsTests
{
[Fact]
public void AsAGUITools_WithAIFunction_ConvertsToAGUIToolCorrectly()
{
// Arrange
AIFunction function = AIFunctionFactory.Create(
(string location) => $"Weather in {location}",
"GetWeather",
"Gets the current weather");
List<AITool> tools = [function];
// Act
List<AGUITool> aguiTools = tools.AsAGUITools().ToList();
// Assert
AGUITool aguiTool = Assert.Single(aguiTools);
Assert.Equal("GetWeather", aguiTool.Name);
Assert.Equal("Gets the current weather", aguiTool.Description);
Assert.NotEqual(default, aguiTool.Parameters);
}
[Fact]
public void AsAGUITools_WithMultipleFunctions_ConvertsAllCorrectly()
{
// Arrange
List<AITool> tools =
[
AIFunctionFactory.Create(() => "Result1", "Tool1", "First tool"),
AIFunctionFactory.Create(() => "Result2", "Tool2", "Second tool"),
AIFunctionFactory.Create(() => "Result3", "Tool3", "Third tool")
];
// Act
List<AGUITool> aguiTools = tools.AsAGUITools().ToList();
// Assert
Assert.Equal(3, aguiTools.Count);
Assert.Equal("Tool1", aguiTools[0].Name);
Assert.Equal("Tool2", aguiTools[1].Name);
Assert.Equal("Tool3", aguiTools[2].Name);
}
[Fact]
public void AsAGUITools_WithNullInput_ReturnsEmptyEnumerable()
{
// Arrange
IEnumerable<AITool>? tools = null;
// Act
IEnumerable<AGUITool> aguiTools = tools!.AsAGUITools();
// Assert
Assert.NotNull(aguiTools);
Assert.Empty(aguiTools);
}
[Fact]
public void AsAGUITools_WithEmptyInput_ReturnsEmptyEnumerable()
{
// Arrange
List<AITool> tools = [];
// Act
List<AGUITool> aguiTools = tools.AsAGUITools().ToList();
// Assert
Assert.Empty(aguiTools);
}
[Fact]
public void AsAGUITools_FiltersOutNonAIFunctionTools()
{
// Arrange - mix of AIFunction and non-function tools
AIFunction function = AIFunctionFactory.Create(() => "Result", "TestTool");
// Create a custom AITool that's not an AIFunction
var declaration = AIFunctionFactory.CreateDeclaration("DeclarationOnly", "Description", JsonElement.Parse("{}"));
List<AITool> tools = [function, declaration];
// Act
List<AGUITool> aguiTools = tools.AsAGUITools().ToList();
// Assert
// Only the AIFunction should be converted, declarations are filtered
Assert.Equal(2, aguiTools.Count); // Actually both convert since declaration is also AIFunctionDeclaration
}
[Fact]
public void AsAITools_WithAGUITool_ConvertsToAIFunctionDeclarationCorrectly()
{
// Arrange
AGUITool aguiTool = new()
{
Name = "TestTool",
Description = "Test description",
Parameters = JsonElement.Parse("""{"type":"object","properties":{}}""")
};
List<AGUITool> aguiTools = [aguiTool];
// Act
List<AITool> tools = aguiTools.AsAITools().ToList();
// Assert
AITool tool = Assert.Single(tools);
Assert.IsType<AIFunctionDeclaration>(tool, exactMatch: false);
var declaration = (AIFunctionDeclaration)tool;
Assert.Equal("TestTool", declaration.Name);
Assert.Equal("Test description", declaration.Description);
}
[Fact]
public void AsAITools_WithMultipleAGUITools_ConvertsAllCorrectly()
{
// Arrange
List<AGUITool> aguiTools =
[
new AGUITool { Name = "Tool1", Description = "Desc1", Parameters = JsonElement.Parse("{}") },
new AGUITool { Name = "Tool2", Description = "Desc2", Parameters = JsonElement.Parse("{}") },
new AGUITool { Name = "Tool3", Description = "Desc3", Parameters = JsonElement.Parse("{}") }
];
// Act
List<AITool> tools = aguiTools.AsAITools().ToList();
// Assert
Assert.Equal(3, tools.Count);
Assert.All(tools, t => Assert.IsType<AIFunctionDeclaration>(t, exactMatch: false));
}
[Fact]
public void AsAITools_WithNullInput_ReturnsEmptyEnumerable()
{
// Arrange
IEnumerable<AGUITool>? aguiTools = null;
// Act
IEnumerable<AITool> tools = aguiTools!.AsAITools();
// Assert
Assert.NotNull(tools);
Assert.Empty(tools);
}
[Fact]
public void AsAITools_WithEmptyInput_ReturnsEmptyEnumerable()
{
// Arrange
List<AGUITool> aguiTools = [];
// Act
List<AITool> tools = aguiTools.AsAITools().ToList();
// Assert
Assert.Empty(tools);
}
[Fact]
public void AsAITools_CreatesDeclarationsOnly_NotInvokableFunctions()
{
// Arrange
AGUITool aguiTool = new()
{
Name = "RemoteTool",
Description = "Tool implemented on server",
Parameters = JsonElement.Parse("""{"type":"object"}""")
};
// Act
List<AGUITool> aguiToolsList = [aguiTool];
AITool tool = aguiToolsList.AsAITools().Single();
// Assert
// The tool should be a declaration, not an executable function
Assert.IsType<AIFunctionDeclaration>(tool, exactMatch: false);
// AIFunctionDeclaration cannot be invoked (no implementation)
// This is correct since the actual implementation exists on the client side
}
[Fact]
public void RoundTrip_AIFunctionToAGUIToolBackToDeclaration_PreservesMetadata()
{
// Arrange
AIFunction originalFunction = AIFunctionFactory.Create(
(string name, int age) => $"{name} is {age} years old",
"FormatPerson",
"Formats person information");
// Act
List<AIFunction> originalList = [originalFunction];
AGUITool aguiTool = originalList.AsAGUITools().Single();
List<AGUITool> aguiToolsList = [aguiTool];
AITool reconstructed = aguiToolsList.AsAITools().Single();
// Assert
Assert.IsType<AIFunctionDeclaration>(reconstructed, exactMatch: false);
var declaration = (AIFunctionDeclaration)reconstructed;
Assert.Equal("FormatPerson", declaration.Name);
Assert.Equal("Formats person information", declaration.Description);
// Schema should be preserved through the round trip
Assert.NotEqual(default, declaration.JsonSchema);
}
}

View File

@@ -0,0 +1,780 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.AGUI.Shared;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.AGUI.UnitTests;
public sealed class ChatResponseUpdateAGUIExtensionsTests
{
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsRunStartedEvent_ToResponseUpdateWithMetadataAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
Assert.Equal(ChatRole.Assistant, updates[0].Role);
Assert.Equal("run1", updates[0].ResponseId);
Assert.NotNull(updates[0].CreatedAt);
Assert.Equal("thread1", updates[0].ConversationId);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsRunFinishedEvent_ToResponseUpdateWithMetadataAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonSerializer.SerializeToElement("Success") }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Equal(2, updates.Count);
// First update is RunStarted
Assert.Equal(ChatRole.Assistant, updates[0].Role);
Assert.Equal("run1", updates[0].ResponseId);
// Second update is RunFinished
Assert.Equal(ChatRole.Assistant, updates[1].Role);
Assert.Equal("run1", updates[1].ResponseId);
Assert.NotNull(updates[1].CreatedAt);
TextContent content = Assert.IsType<TextContent>(updates[1].Contents[0]);
Assert.Equal("\"Success\"", content.Text); // JSON string representation includes quotes
// ConversationId is stored in the ChatResponseUpdate
Assert.Equal("thread1", updates[1].ConversationId);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsRunErrorEvent_ToErrorContentAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunErrorEvent { Message = "Error occurred", Code = "ERR001" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
Assert.Equal(ChatRole.Assistant, updates[0].Role);
ErrorContent content = Assert.IsType<ErrorContent>(updates[0].Contents[0]);
Assert.Equal("Error occurred", content.Message);
// Code is stored in ErrorCode property
Assert.Equal("ERR001", content.ErrorCode);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsTextMessageSequence_ToTextUpdatesWithCorrectRoleAsync()
{
// Arrange
List<BaseEvent> events =
[
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new TextMessageContentEvent { MessageId = "msg1", Delta = " World" },
new TextMessageEndEvent { MessageId = "msg1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Equal(2, updates.Count);
Assert.All(updates, u => Assert.Equal(ChatRole.Assistant, u.Role));
Assert.Equal("Hello", ((TextContent)updates[0].Contents[0]).Text);
Assert.Equal(" World", ((TextContent)updates[1].Contents[0]).Text);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithTextMessageStartWhileMessageInProgress_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.User }
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Intentionally empty - consuming stream to trigger exception
}
});
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithTextMessageEndForWrongMessageId_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new TextMessageEndEvent { MessageId = "msg2" }
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Intentionally empty - consuming stream to trigger exception
}
});
}
[Fact]
public async Task AsChatResponseUpdatesAsync_MaintainsMessageContext_AcrossMultipleContentEventsAsync()
{
// Arrange
List<BaseEvent> events =
[
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new TextMessageContentEvent { MessageId = "msg1", Delta = " " },
new TextMessageContentEvent { MessageId = "msg1", Delta = "World" },
new TextMessageEndEvent { MessageId = "msg1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Equal(3, updates.Count);
Assert.All(updates, u => Assert.Equal(ChatRole.Assistant, u.Role));
Assert.All(updates, u => Assert.Equal("msg1", u.MessageId));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsToolCallEvents_ToFunctionCallContentAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "GetWeather", ParentMessageId = "msg1" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"location\":" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "\"Seattle\"}" },
new ToolCallEndEvent { ToolCallId = "call_1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate toolCallUpdate = updates.First(u => u.Contents.Any(c => c is FunctionCallContent));
FunctionCallContent functionCall = Assert.IsType<FunctionCallContent>(toolCallUpdate.Contents[0]);
Assert.Equal("call_1", functionCall.CallId);
Assert.Equal("GetWeather", functionCall.Name);
Assert.NotNull(functionCall.Arguments);
Assert.Equal("Seattle", functionCall.Arguments!["location"]?.ToString());
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithMultipleToolCallArgsEvents_AccumulatesArgsCorrectlyAsync()
{
// Arrange
List<BaseEvent> events =
[
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "TestTool", ParentMessageId = "msg1" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"par" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "t1\":\"val" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "ue1\",\"part2" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "\":\"value2\"}" },
new ToolCallEndEvent { ToolCallId = "call_1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
FunctionCallContent functionCall = updates
.SelectMany(u => u.Contents)
.OfType<FunctionCallContent>()
.Single();
Assert.Equal("value1", functionCall.Arguments!["part1"]?.ToString());
Assert.Equal("value2", functionCall.Arguments!["part2"]?.ToString());
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithEmptyToolCallArgs_HandlesGracefullyAsync()
{
// Arrange
List<BaseEvent> events =
[
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "NoArgsTool", ParentMessageId = "msg1" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "" },
new ToolCallEndEvent { ToolCallId = "call_1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
FunctionCallContent functionCall = updates
.SelectMany(u => u.Contents)
.OfType<FunctionCallContent>()
.Single();
Assert.Equal("call_1", functionCall.CallId);
Assert.Equal("NoArgsTool", functionCall.Name);
Assert.Null(functionCall.Arguments);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithOverlappingToolCalls_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg1" } // Second start before first ends
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Consume stream to trigger exception
}
});
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithMismatchedToolCallId_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" } // Wrong call ID
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Consume stream to trigger exception
}
});
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithMismatchedToolCallEndId_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
new ToolCallEndEvent { ToolCallId = "call_2" } // Wrong call ID
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Consume stream to trigger exception
}
});
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithMultipleSequentialToolCalls_ProcessesAllCorrectlyAsync()
{
// Arrange
List<BaseEvent> events =
[
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"arg1\":\"val1\"}" },
new ToolCallEndEvent { ToolCallId = "call_1" },
new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg2" },
new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{\"arg2\":\"val2\"}" },
new ToolCallEndEvent { ToolCallId = "call_2" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
List<FunctionCallContent> functionCalls = updates
.SelectMany(u => u.Contents)
.OfType<FunctionCallContent>()
.ToList();
Assert.Equal(2, functionCalls.Count);
Assert.Equal("call_1", functionCalls[0].CallId);
Assert.Equal("Tool1", functionCalls[0].Name);
Assert.Equal("call_2", functionCalls[1].CallId);
Assert.Equal("Tool2", functionCalls[1].Name);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsStateSnapshotEvent_ToDataContentWithJsonAsync()
{
// Arrange
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(new { counter = 42, status = "active" });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
Assert.Equal(ChatRole.Assistant, stateUpdate.Role);
Assert.Equal("thread1", stateUpdate.ConversationId);
Assert.Equal("run1", stateUpdate.ResponseId);
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
Assert.Equal("application/json", dataContent.MediaType);
// Verify the JSON content
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedState = JsonElement.Parse(jsonText);
Assert.Equal(42, deserializedState.GetProperty("counter").GetInt32());
Assert.Equal("active", deserializedState.GetProperty("status").GetString());
// Verify additional properties
Assert.NotNull(stateUpdate.AdditionalProperties);
Assert.True((bool)stateUpdate.AdditionalProperties["is_state_snapshot"]!);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithNullStateSnapshot_DoesNotEmitUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = null },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithEmptyObjectStateSnapshot_EmitsDataContentAsync()
{
// Arrange
JsonElement emptyState = JsonSerializer.SerializeToElement(new { });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = emptyState },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
Assert.Equal("{}", jsonText);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithComplexStateSnapshot_PreservesJsonStructureAsync()
{
// Arrange
var complexState = new
{
user = new { name = "Alice", age = 30 },
items = new[] { "item1", "item2", "item3" },
metadata = new { timestamp = "2024-01-01T00:00:00Z", version = 2 }
};
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(complexState);
List<BaseEvent> events =
[
new StateSnapshotEvent { Snapshot = stateSnapshot }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First();
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement roundTrippedState = JsonElement.Parse(jsonText);
Assert.Equal("Alice", roundTrippedState.GetProperty("user").GetProperty("name").GetString());
Assert.Equal(30, roundTrippedState.GetProperty("user").GetProperty("age").GetInt32());
Assert.Equal(3, roundTrippedState.GetProperty("items").GetArrayLength());
Assert.Equal("item1", roundTrippedState.GetProperty("items")[0].GetString());
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithStateSnapshotAndTextMessages_EmitsBothAsync()
{
// Arrange
JsonElement state = JsonSerializer.SerializeToElement(new { step = 1 });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Processing..." },
new TextMessageEndEvent { MessageId = "msg1" },
new StateSnapshotEvent { Snapshot = state },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Contains(updates, u => u.Contents.Any(c => c is TextContent));
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent));
}
#region State Delta Tests
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsStateDeltaEvent_ToDataContentWithJsonPatchAsync()
{
// Arrange - Create JSON Patch operations (RFC 6902)
JsonElement stateDelta = JsonSerializer.SerializeToElement(new object[]
{
new { op = "replace", path = "/counter", value = 43 },
new { op = "add", path = "/newField", value = "test" }
});
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = stateDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate deltaUpdate = updates.First(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
Assert.Equal(ChatRole.Assistant, deltaUpdate.Role);
Assert.Equal("thread1", deltaUpdate.ConversationId);
Assert.Equal("run1", deltaUpdate.ResponseId);
DataContent dataContent = Assert.IsType<DataContent>(deltaUpdate.Contents[0]);
Assert.Equal("application/json-patch+json", dataContent.MediaType);
// Verify the JSON Patch content
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedDelta = JsonElement.Parse(jsonText);
Assert.Equal(JsonValueKind.Array, deserializedDelta.ValueKind);
Assert.Equal(2, deserializedDelta.GetArrayLength());
// Verify first operation
JsonElement firstOp = deserializedDelta[0];
Assert.Equal("replace", firstOp.GetProperty("op").GetString());
Assert.Equal("/counter", firstOp.GetProperty("path").GetString());
Assert.Equal(43, firstOp.GetProperty("value").GetInt32());
// Verify second operation
JsonElement secondOp = deserializedDelta[1];
Assert.Equal("add", secondOp.GetProperty("op").GetString());
Assert.Equal("/newField", secondOp.GetProperty("path").GetString());
Assert.Equal("test", secondOp.GetProperty("value").GetString());
// Verify additional properties
Assert.NotNull(deltaUpdate.AdditionalProperties);
Assert.True((bool)deltaUpdate.AdditionalProperties["is_state_delta"]!);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithNullStateDelta_DoesNotEmitUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = null },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert - Only run started and finished should be present
Assert.Equal(2, updates.Count);
Assert.IsType<ChatResponseUpdate>(updates[0]); // Run started
Assert.IsType<ChatResponseUpdate>(updates[1]); // Run finished
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithEmptyStateDelta_EmitsUpdateAsync()
{
// Arrange - Empty JSON Patch array is valid
JsonElement emptyDelta = JsonSerializer.SerializeToElement(Array.Empty<object>());
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = emptyDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithMultipleStateDeltaEvents_ConvertsAllAsync()
{
// Arrange
JsonElement delta1 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
JsonElement delta2 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 2 } });
JsonElement delta3 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 3 } });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = delta1 },
new StateDeltaEvent { Delta = delta2 },
new StateDeltaEvent { Delta = delta3 },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
var deltaUpdates = updates.Where(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json")).ToList();
Assert.Equal(3, deltaUpdates.Count);
}
[Fact]
public async Task AsAGUIEventStreamAsync_ConvertsDataContentWithJsonPatch_ToStateDeltaEventAsync()
{
// Arrange - Create a ChatResponseUpdate with JSON Patch DataContent
JsonElement patchOps = JsonSerializer.SerializeToElement(new object[]
{
new { op = "remove", path = "/oldField" },
new { op = "add", path = "/newField", value = "newValue" }
});
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(patchOps);
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
MessageId = "msg1"
}
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
StateDeltaEvent? deltaEvent = outputEvents.OfType<StateDeltaEvent>().FirstOrDefault();
Assert.NotNull(deltaEvent);
Assert.NotNull(deltaEvent.Delta);
Assert.Equal(JsonValueKind.Array, deltaEvent.Delta.Value.ValueKind);
// Verify patch operations
JsonElement delta = deltaEvent.Delta.Value;
Assert.Equal(2, delta.GetArrayLength());
Assert.Equal("remove", delta[0].GetProperty("op").GetString());
Assert.Equal("/oldField", delta[0].GetProperty("path").GetString());
Assert.Equal("add", delta[1].GetProperty("op").GetString());
Assert.Equal("/newField", delta[1].GetProperty("path").GetString());
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithBothSnapshotAndDelta_EmitsBothEventsAsync()
{
// Arrange
JsonElement snapshot = JsonSerializer.SerializeToElement(new { counter = 0 });
byte[] snapshotBytes = JsonSerializer.SerializeToUtf8Bytes(snapshot);
DataContent snapshotContent = new(snapshotBytes, "application/json");
JsonElement delta = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
byte[] deltaBytes = JsonSerializer.SerializeToUtf8Bytes(delta);
DataContent deltaContent = new(deltaBytes, "application/json-patch+json");
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, [snapshotContent]) { MessageId = "msg1" },
new ChatResponseUpdate(ChatRole.Assistant, [deltaContent]) { MessageId = "msg2" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.Contains(outputEvents, e => e is StateSnapshotEvent);
Assert.Contains(outputEvents, e => e is StateDeltaEvent);
}
[Fact]
public async Task StateDeltaEvent_RoundTrip_PreservesJsonPatchOperationsAsync()
{
// Arrange - Create complex JSON Patch with various operations
JsonElement originalDelta = JsonSerializer.SerializeToElement(new object[]
{
new { op = "add", path = "/user/email", value = "test@example.com" },
new { op = "remove", path = "/user/tempData" },
new { op = "replace", path = "/user/lastLogin", value = "2025-11-09T12:00:00Z" },
new { op = "move", from = "/user/oldAddress", path = "/user/previousAddress" },
new { op = "copy", from = "/user/name", path = "/user/displayName" },
new { op = "test", path = "/user/version", value = 2 }
});
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = originalDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act - Convert to ChatResponseUpdate and back to events
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
List<BaseEvent> roundTripEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
roundTripEvents.Add(evt);
}
// Assert
StateDeltaEvent? roundTripDelta = roundTripEvents.OfType<StateDeltaEvent>().FirstOrDefault();
Assert.NotNull(roundTripDelta);
Assert.NotNull(roundTripDelta.Delta);
JsonElement delta = roundTripDelta.Delta.Value;
Assert.Equal(6, delta.GetArrayLength());
// Verify each operation type
Assert.Equal("add", delta[0].GetProperty("op").GetString());
Assert.Equal("remove", delta[1].GetProperty("op").GetString());
Assert.Equal("replace", delta[2].GetProperty("op").GetString());
Assert.Equal("move", delta[3].GetProperty("op").GetString());
Assert.Equal("copy", delta[4].GetProperty("op").GetString());
Assert.Equal("test", delta[5].GetProperty("op").GetString());
}
#endregion State Delta Tests
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.AGUI.UnitTests;
internal static class TestHelpers
{
/// <summary>
/// Extension method to convert a synchronous enumerable to an async enumerable for testing purposes.
/// </summary>
public static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(this IEnumerable<T> source)
{
foreach (T item in source)
{
yield return item;
await Task.CompletedTask;
}
}
}