Merge branch 'main' into features/foundry-hosting-it-msb3026-fix

This commit is contained in:
Roger Barreto
2026-05-07 19:16:45 +01:00
committed by GitHub
Unverified
33 changed files with 2404 additions and 195 deletions
@@ -102,18 +102,20 @@ public sealed class AGUIChatMessageExtensionsTests
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 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(4, chatMessages.Count);
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]
@@ -367,6 +369,277 @@ public sealed class AGUIChatMessageExtensionsTests
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]
@@ -64,6 +64,49 @@ public sealed class AGUIJsonSerializerContextTests
Assert.Single(input.Messages);
}
[Fact]
public void RunAgentInput_Deserializes_FromJsonWithReasoningMessages()
{
// Arrange
const string Json = """
{
"threadId": "thread1",
"runId": "run1",
"messages": [
{
"id": "m1",
"role": "user",
"content": "Hello"
},
{
"id": "m2",
"role": "reasoning",
"content": "I need to consider this.",
"encryptedValue": "ErgDCkgIDB..."
},
{
"id": "m3",
"role": "assistant",
"content": "Here is my answer."
}
]
}
""";
// Act
RunAgentInput? input = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.RunAgentInput);
// Assert
Assert.NotNull(input);
var messages = input.Messages.ToList();
Assert.Equal(3, messages.Count);
Assert.IsType<AGUIUserMessage>(messages[0]);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(messages[1]);
Assert.Equal("I need to consider this.", reasoningMessage.Content);
Assert.Equal("ErgDCkgIDB...", reasoningMessage.EncryptedValue);
Assert.IsType<AGUIAssistantMessage>(messages[2]);
}
[Fact]
public void RunAgentInput_HandlesOptionalFields_StateContextAndForwardedProperties()
{
@@ -963,7 +1006,76 @@ public sealed class AGUIJsonSerializerContextTests
}
[Fact]
public void AllFiveMessageTypes_SerializeAsPolymorphicArray_Correctly()
public void AGUIReasoningMessage_SerializesAndDeserializes_Correctly()
{
// Arrange
var originalMessage = new AGUIReasoningMessage
{
Id = "reason1",
Content = "I need to consider the user's request carefully.",
EncryptedValue = "ErgDCkgIDB..."
};
// Act
string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("reason1", deserialized.Id);
Assert.Equal("I need to consider the user's request carefully.", deserialized.Content);
Assert.Equal("ErgDCkgIDB...", deserialized.EncryptedValue);
Assert.Equal(AGUIRoles.Reasoning, deserialized.Role);
}
[Fact]
public void AGUIReasoningMessage_WithoutEncryptedValue_SerializesAndDeserializes_Correctly()
{
// Arrange
var originalMessage = new AGUIReasoningMessage
{
Id = "reason2",
Content = "Thinking about this problem."
};
// Act
string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("reason2", deserialized.Id);
Assert.Equal("Thinking about this problem.", deserialized.Content);
Assert.Null(deserialized.EncryptedValue);
}
[Fact]
public void AGUIReasoningMessage_DeserializesViaPolymorphicConverter_Correctly()
{
// Arrange
const string Json = """
{
"id": "reason1",
"role": "reasoning",
"content": "Let me think about this.",
"encryptedValue": "tok-encrypted"
}
""";
// Act
AGUIMessage? message = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.AGUIMessage);
// Assert
Assert.NotNull(message);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
Assert.Equal("reason1", reasoningMessage.Id);
Assert.Equal(AGUIRoles.Reasoning, reasoningMessage.Role);
Assert.Equal("Let me think about this.", reasoningMessage.Content);
Assert.Equal("tok-encrypted", reasoningMessage.EncryptedValue);
}
[Fact]
public void AllSixMessageTypes_SerializeAsPolymorphicArray_Correctly()
{
// Arrange
AGUIMessage[] messages =
@@ -972,7 +1084,8 @@ public sealed class AGUIJsonSerializerContextTests
new AGUIDeveloperMessage { Id = "2", Content = "Developer message" },
new AGUIUserMessage { Id = "3", Content = "User message" },
new AGUIAssistantMessage { Id = "4", Content = "Assistant message" },
new AGUIToolMessage { Id = "5", ToolCallId = "call_1", Content = "{\"result\":\"success\"}" }
new AGUIToolMessage { Id = "5", ToolCallId = "call_1", Content = "{\"result\":\"success\"}" },
new AGUIReasoningMessage { Id = "6", Content = "Reasoning message", EncryptedValue = "tok-123" }
];
// Act
@@ -981,12 +1094,13 @@ public sealed class AGUIJsonSerializerContextTests
// Assert
Assert.NotNull(deserialized);
Assert.Equal(5, deserialized.Length);
Assert.Equal(6, deserialized.Length);
Assert.IsType<AGUISystemMessage>(deserialized[0]);
Assert.IsType<AGUIDeveloperMessage>(deserialized[1]);
Assert.IsType<AGUIUserMessage>(deserialized[2]);
Assert.IsType<AGUIAssistantMessage>(deserialized[3]);
Assert.IsType<AGUIToolMessage>(deserialized[4]);
Assert.IsType<AGUIReasoningMessage>(deserialized[5]);
}
#endregion
@@ -1111,4 +1225,149 @@ public sealed class AGUIJsonSerializerContextTests
}
#endregion
#region Reasoning Event Serialization Tests
[Fact]
public void ReasoningStartEvent_Serializes_WithCorrectTypeDiscriminator()
{
// Arrange
ReasoningStartEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningStartEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningStart, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
}
[Fact]
public void ReasoningMessageStartEvent_Serializes_WithRoleReasoningAndMessageId()
{
// Arrange
ReasoningMessageStartEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageStartEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageStart, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
Assert.Equal("reasoning", jsonElement.GetProperty("role").GetString());
}
[Fact]
public void ReasoningMessageContentEvent_Serializes_WithDeltaAndMessageId()
{
// Arrange
ReasoningMessageContentEvent evt = new() { MessageId = "reason1", Delta = "I am thinking" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageContentEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageContent, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
Assert.Equal("I am thinking", jsonElement.GetProperty("delta").GetString());
}
[Fact]
public void ReasoningMessageEndEvent_Serializes_WithMessageId()
{
// Arrange
ReasoningMessageEndEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageEndEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageEnd, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
}
[Fact]
public void ReasoningEndEvent_Serializes_WithMessageId()
{
// Arrange
ReasoningEndEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningEndEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningEnd, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
}
[Fact]
public void ReasoningMessageChunkEvent_Serializes_WithDeltaAndMessageId()
{
// Arrange
ReasoningMessageChunkEvent evt = new() { MessageId = "reason1", Delta = "chunk" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageChunkEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageChunk, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
Assert.Equal("chunk", jsonElement.GetProperty("delta").GetString());
}
[Fact]
public void ReasoningEncryptedValueEvent_Serializes_WithAllFields()
{
// Arrange
ReasoningEncryptedValueEvent evt = new() { EntityId = "reason1", EncryptedValue = "tok-abc123" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningEncryptedValueEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningEncryptedValue, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("entityId").GetString());
Assert.Equal("tok-abc123", jsonElement.GetProperty("encryptedValue").GetString());
Assert.Equal("message", jsonElement.GetProperty("subtype").GetString());
}
[Fact]
public void AllReasoningEventTypes_DeserializeViaBaseEventConverter_ToCorrectTypes()
{
// Arrange
BaseEvent[] events =
[
new ReasoningStartEvent { MessageId = "r1" },
new ReasoningMessageStartEvent { MessageId = "r1" },
new ReasoningMessageContentEvent { MessageId = "r1", Delta = "thinking" },
new ReasoningMessageEndEvent { MessageId = "r1" },
new ReasoningEndEvent { MessageId = "r1" },
new ReasoningMessageChunkEvent { MessageId = "r1", Delta = "chunk" },
new ReasoningEncryptedValueEvent { EntityId = "r1", EncryptedValue = "tok" }
];
// Act
string json = JsonSerializer.Serialize(events, AGUIJsonSerializerContext.Default.Options);
var deserialized = JsonSerializer.Deserialize<BaseEvent[]>(json, AGUIJsonSerializerContext.Default.Options);
// Assert
Assert.NotNull(deserialized);
Assert.Equal(7, deserialized.Length);
Assert.IsType<ReasoningStartEvent>(deserialized[0]);
Assert.IsType<ReasoningMessageStartEvent>(deserialized[1]);
Assert.IsType<ReasoningMessageContentEvent>(deserialized[2]);
Assert.IsType<ReasoningMessageEndEvent>(deserialized[3]);
Assert.IsType<ReasoningEndEvent>(deserialized[4]);
Assert.IsType<ReasoningMessageChunkEvent>(deserialized[5]);
Assert.IsType<ReasoningEncryptedValueEvent>(deserialized[6]);
}
#endregion Reasoning Event Serialization Tests
}
@@ -777,4 +777,469 @@ public sealed class ChatResponseUpdateAGUIExtensionsTests
}
#endregion State Delta Tests
#region Reasoning Tests
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageEndForWrongMessageId_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageStartEvent { MessageId = "reason1" },
new ReasoningMessageContentEvent { MessageId = "reason1", Delta = "thinking..." },
new ReasoningMessageEndEvent { MessageId = "reason2" } // Wrong message 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 AsAGUIEventStreamAsync_WithReasoningContent_EmitsCorrectReasoningEventSequenceAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("I need to think about this")]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.IsType<RunStartedEvent>(outputEvents[0]);
var reasoningStart = Assert.IsType<ReasoningStartEvent>(outputEvents[1]);
var reasoningId = reasoningStart.MessageId;
Assert.NotEqual("reason1", reasoningId);
var reasoningMessageStart = Assert.IsType<ReasoningMessageStartEvent>(outputEvents[2]);
var reasoningMessageId = reasoningMessageStart.MessageId;
Assert.NotEqual(reasoningId, reasoningMessageId);
var reasoningContent = Assert.IsType<ReasoningMessageContentEvent>(outputEvents[3]);
Assert.Equal(reasoningMessageId, reasoningContent.MessageId);
Assert.Equal("I need to think about this", reasoningContent.Delta);
var reasoningMessageEnd = Assert.IsType<ReasoningMessageEndEvent>(outputEvents[4]);
Assert.Equal(reasoningMessageId, reasoningMessageEnd.MessageId);
var reasoningEnd = Assert.IsType<ReasoningEndEvent>(outputEvents[5]);
Assert.Equal(reasoningId, reasoningEnd.MessageId);
Assert.IsType<RunFinishedEvent>(outputEvents[6]);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithMultipleReasoningDeltas_EmitsContentEventPerDeltaAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("First")]) { MessageId = "reason1" },
new(ChatRole.Assistant, [new TextReasoningContent(" step")]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
var contentEvents = outputEvents.OfType<ReasoningMessageContentEvent>().ToList();
Assert.Equal(2, contentEvents.Count);
Assert.Equal("First", contentEvents[0].Delta);
Assert.Equal(" step", contentEvents[1].Delta);
// Only one START/END pair
Assert.Single(outputEvents.OfType<ReasoningStartEvent>());
Assert.Single(outputEvents.OfType<ReasoningMessageStartEvent>());
Assert.Single(outputEvents.OfType<ReasoningMessageEndEvent>());
Assert.Single(outputEvents.OfType<ReasoningEndEvent>());
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningAndProtectedData_EmitsEncryptedValueEventAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking") { ProtectedData = "encrypted-abc" }]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
var reasoningMessageId = outputEvents.OfType<ReasoningMessageStartEvent>().Single().MessageId;
Assert.NotEqual("reason1", reasoningMessageId);
var encryptedEvent = outputEvents.OfType<ReasoningEncryptedValueEvent>().Single();
Assert.Equal(reasoningMessageId, encryptedEvent.EntityId);
Assert.Equal("encrypted-abc", encryptedEvent.EncryptedValue);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningFollowedByText_EmitsBothEventSequencesAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "reason1" },
new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "msg1" }
];
// 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 ReasoningStartEvent);
Assert.Contains(outputEvents, e => e is ReasoningMessageContentEvent);
Assert.Contains(outputEvents, e => e is ReasoningEndEvent);
Assert.Contains(outputEvents, e => e is TextMessageStartEvent);
Assert.Contains(outputEvents, e => e is TextMessageContentEvent);
Assert.Contains(outputEvents, e => e is TextMessageEndEvent);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningAndTextSharingSameMessageId_EmitsDistinctEventIdsAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "shared1" },
new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
var reasoningId = outputEvents.OfType<ReasoningStartEvent>().Single().MessageId;
var reasoningMessageId = outputEvents.OfType<ReasoningMessageStartEvent>().Single().MessageId;
var textMessageId = outputEvents.OfType<TextMessageStartEvent>().Single().MessageId;
Assert.NotEqual(reasoningId, reasoningMessageId);
Assert.NotEqual(reasoningId, textMessageId);
Assert.NotEqual(reasoningMessageId, textMessageId);
Assert.Equal("shared1", textMessageId);
Assert.All(outputEvents.OfType<ReasoningMessageContentEvent>(), e => Assert.Equal(reasoningMessageId, e.MessageId));
Assert.Equal(reasoningMessageId, outputEvents.OfType<ReasoningMessageEndEvent>().Single().MessageId);
Assert.Equal(reasoningId, outputEvents.OfType<ReasoningEndEvent>().Single().MessageId);
Assert.All(outputEvents.OfType<TextMessageContentEvent>(), e => Assert.Equal("shared1", e.MessageId));
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningThenTextSharingSameMessageId_ClosesReasoningBlockBeforeTextStartAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "shared1" },
new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
int reasoningMessageEndIndex = outputEvents.FindIndex(e => e is ReasoningMessageEndEvent);
int reasoningEndIndex = outputEvents.FindIndex(e => e is ReasoningEndEvent);
int textMessageStartIndex = outputEvents.FindIndex(e => e is TextMessageStartEvent);
Assert.True(reasoningMessageEndIndex < textMessageStartIndex);
Assert.True(reasoningEndIndex < textMessageStartIndex);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningThenToolCallSharingSameMessageId_ClosesReasoningBlockBeforeToolCallStartAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking about which tool to use")]) { MessageId = "shared1" },
new(ChatRole.Assistant, [new FunctionCallContent("call-1", "GetWeather", new Dictionary<string, object?> { ["location"] = "Seattle" })]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
int reasoningEndIndex = outputEvents.FindIndex(e => e is ReasoningEndEvent);
int toolCallStartIndex = outputEvents.FindIndex(e => e is ToolCallStartEvent);
Assert.True(reasoningEndIndex < toolCallStartIndex);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningThenToolResultSharingSameMessageId_ClosesReasoningBlockBeforeToolResultAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("reflecting on result")]) { MessageId = "shared1" },
new(ChatRole.Tool, [new FunctionResultContent("call-1", "72F and sunny")]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
int reasoningEndIndex = outputEvents.FindIndex(e => e is ReasoningEndEvent);
int toolCallResultIndex = outputEvents.FindIndex(e => e is ToolCallResultEvent);
Assert.True(reasoningEndIndex < toolCallResultIndex);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageSequence_ProducesTextReasoningContentPerDeltaAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningStartEvent { MessageId = "reason1" },
new ReasoningMessageStartEvent { MessageId = "reason1" },
new ReasoningMessageContentEvent { MessageId = "reason1", Delta = "First thought" },
new ReasoningMessageContentEvent { MessageId = "reason1", Delta = " and more" },
new ReasoningMessageEndEvent { MessageId = "reason1" },
new ReasoningEndEvent { MessageId = "reason1" }
];
// 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.All(updates, u => Assert.Equal("reason1", u.MessageId));
var firstContent = Assert.IsType<TextReasoningContent>(updates[0].Contents[0]);
Assert.Equal("First thought", firstContent.Text);
var secondContent = Assert.IsType<TextReasoningContent>(updates[1].Contents[0]);
Assert.Equal(" and more", secondContent.Text);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningStartAndEndEvents_DoNotProduceUpdatesAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningStartEvent { MessageId = "reason1" },
new ReasoningEndEvent { MessageId = "reason1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Empty(updates);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningEncryptedValueEvent_ProducesTextReasoningContentWithProtectedDataAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningEncryptedValueEvent { EntityId = "reason1", EncryptedValue = "secret-token" }
];
// 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("reason1", updates[0].MessageId);
var content = Assert.IsType<TextReasoningContent>(updates[0].Contents[0]);
Assert.Equal("secret-token", content.ProtectedData);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageChunks_ProducesTextReasoningContentPerChunkAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "chunk one" },
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = " chunk two" },
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "" }
];
// 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));
var firstContent = Assert.IsType<TextReasoningContent>(updates[0].Contents[0]);
Assert.Equal("chunk one", firstContent.Text);
var secondContent = Assert.IsType<TextReasoningContent>(updates[1].Contents[0]);
Assert.Equal(" chunk two", secondContent.Text);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageChunkEmptyDelta_ProducesNoUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Empty(updates);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageStartWhileMessageInProgress_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageStartEvent { MessageId = "reason1" },
new ReasoningMessageStartEvent { MessageId = "reason2" } // Overlapping start
];
// 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_WithReasoningMessageEndWithoutStart_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageEndEvent { MessageId = "reason1" } // End without start
];
// 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 AsAGUIEventStreamAsync_WithProtectedDataOnly_EmitsEncryptedValueEventWithoutContentDeltaAsync()
{
// Arrange — TextReasoningContent with empty text but non-empty ProtectedData
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("") { ProtectedData = "encrypted-only" }]) { MessageId = "reason1" }
];
// 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 ReasoningStartEvent);
Assert.Contains(outputEvents, e => e is ReasoningMessageStartEvent);
Assert.DoesNotContain(outputEvents, e => e is ReasoningMessageContentEvent);
var reasoningMessageId = outputEvents.OfType<ReasoningMessageStartEvent>().Single().MessageId;
Assert.NotEqual("reason1", reasoningMessageId);
var encryptedEvent = outputEvents.OfType<ReasoningEncryptedValueEvent>().Single();
Assert.Equal(reasoningMessageId, encryptedEvent.EntityId);
Assert.Equal("encrypted-only", encryptedEvent.EncryptedValue);
Assert.Contains(outputEvents, e => e is ReasoningMessageEndEvent);
Assert.Contains(outputEvents, e => e is ReasoningEndEvent);
}
[Fact]
public async Task ReasoningContent_RoundTrip_OutboundThenInbound_PreservesTextAndProtectedDataAsync()
{
// Arrange
List<ChatResponseUpdate> outboundUpdates =
[
new(ChatRole.Assistant, [new TextReasoningContent("I'm thinking") { ProtectedData = "enc-value" }]) { MessageId = "reason1" }
];
// Act - outbound: ChatResponseUpdate → AGUI events
List<BaseEvent> aguilEvents = [];
await foreach (BaseEvent evt in outboundUpdates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
aguilEvents.Add(evt);
}
// Act - inbound: AGUI events → ChatResponseUpdate
List<ChatResponseUpdate> inboundUpdates = [];
await foreach (ChatResponseUpdate update in aguilEvents.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
inboundUpdates.Add(update);
}
// Assert
var reasoningContents = inboundUpdates
.SelectMany(u => u.Contents)
.OfType<TextReasoningContent>()
.ToList();
Assert.Contains(reasoningContents, c => c.Text == "I'm thinking");
Assert.Contains(reasoningContents, c => c.ProtectedData == "enc-value");
}
#endregion Reasoning Tests
}
@@ -14,7 +14,7 @@ public class GitHubCopilotAgentTests
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
private static Task<PermissionRequestResult> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
=> Task.FromResult(new PermissionRequestResult { Kind = "approved" });
=> Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
[Fact(Skip = SkipReason)]
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
@@ -201,11 +201,10 @@ public class GitHubCopilotAgentTests
SessionConfig sessionConfig = new()
{
OnPermissionRequest = OnPermissionRequestAsync,
McpServers = new Dictionary<string, object>
McpServers = new Dictionary<string, McpServerConfig>
{
["filesystem"] = new McpLocalServerConfig
["filesystem"] = new McpStdioServerConfig
{
Type = "stdio",
Command = "npx",
Args = ["-y", "@modelcontextprotocol/server-filesystem", "."],
Tools = ["*"],
@@ -234,11 +233,10 @@ public class GitHubCopilotAgentTests
SessionConfig sessionConfig = new()
{
OnPermissionRequest = OnPermissionRequestAsync,
McpServers = new Dictionary<string, object>
McpServers = new Dictionary<string, McpServerConfig>
{
["microsoft-learn"] = new McpRemoteServerConfig
["microsoft-learn"] = new McpHttpServerConfig
{
Type = "http",
Url = "https://learn.microsoft.com/api/mcp",
Tools = ["*"],
},
@@ -111,7 +111,7 @@ public sealed class GitHubCopilotAgentTests
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
var mcpServers = new Dictionary<string, object> { ["server1"] = new McpLocalServerConfig() };
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
var source = new SessionConfig
{
@@ -162,7 +162,7 @@ public sealed class GitHubCopilotAgentTests
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
var mcpServers = new Dictionary<string, object> { ["server1"] = new McpLocalServerConfig() };
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
var source = new SessionConfig
{