Merge branch 'main' into dev/dotnet_workflow/magentic

This commit is contained in:
Jacob Alber
2026-05-07 11:56:49 -04:00
committed by GitHub
Unverified
18 changed files with 1519 additions and 6 deletions
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
@@ -55,6 +56,32 @@ internal static class AGUIChatMessageExtensions
break;
}
case AGUIReasoningMessage reasoningMessage:
{
var contents = new List<AIContent>();
if (!string.IsNullOrEmpty(reasoningMessage.Content))
{
contents.Add(new TextReasoningContent(reasoningMessage.Content)
{
ProtectedData = reasoningMessage.EncryptedValue
});
}
else if (!string.IsNullOrEmpty(reasoningMessage.EncryptedValue))
{
contents.Add(new TextReasoningContent("")
{
ProtectedData = reasoningMessage.EncryptedValue
});
}
yield return new ChatMessage(role, contents)
{
MessageId = message.Id
};
break;
}
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
{
var contents = new List<AIContent>();
@@ -125,6 +152,12 @@ internal static class AGUIChatMessageExtensions
}
else if (message.Role == ChatRole.Assistant)
{
var reasoningMessage = MapReasoningMessage(message);
if (reasoningMessage != null)
{
yield return reasoningMessage;
}
var assistantMessage = MapAssistantMessage(jsonSerializerOptions, message);
if (assistantMessage != null)
{
@@ -144,6 +177,32 @@ internal static class AGUIChatMessageExtensions
}
}
private static AGUIReasoningMessage? MapReasoningMessage(ChatMessage message)
{
var reasoning = message.Contents.OfType<TextReasoningContent>().FirstOrDefault();
if (reasoning is null)
{
return null;
}
var text = string.Join(
string.Empty,
message.Contents.OfType<TextReasoningContent>()
.Where(r => !string.IsNullOrEmpty(r.Text))
.Select(r => r.Text));
var protectedData = message.Contents.OfType<TextReasoningContent>()
.Select(r => r.ProtectedData)
.LastOrDefault(p => !string.IsNullOrEmpty(p));
return new AGUIReasoningMessage
{
Id = message.MessageId,
Content = text,
EncryptedValue = protectedData,
};
}
private static AGUIAssistantMessage? MapAssistantMessage(JsonSerializerOptions jsonSerializerOptions, ChatMessage message)
{
List<AGUIToolCall>? toolCalls = null;
@@ -212,5 +271,6 @@ internal static class AGUIChatMessageExtensions
string.Equals(role, AGUIRoles.Assistant, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
string.Equals(role, AGUIRoles.Developer, StringComparison.OrdinalIgnoreCase) ? s_developerChatRole :
string.Equals(role, AGUIRoles.Tool, StringComparison.OrdinalIgnoreCase) ? ChatRole.Tool :
string.Equals(role, AGUIRoles.Reasoning, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
throw new InvalidOperationException($"Unknown chat role: {role}");
}
@@ -31,4 +31,18 @@ internal static class AGUIEventTypes
public const string StateSnapshot = "STATE_SNAPSHOT";
public const string StateDelta = "STATE_DELTA";
public const string ReasoningStart = "REASONING_START";
public const string ReasoningMessageStart = "REASONING_MESSAGE_START";
public const string ReasoningMessageContent = "REASONING_MESSAGE_CONTENT";
public const string ReasoningMessageEnd = "REASONING_MESSAGE_END";
public const string ReasoningEnd = "REASONING_END";
public const string ReasoningMessageChunk = "REASONING_MESSAGE_CHUNK";
public const string ReasoningEncryptedValue = "REASONING_ENCRYPTED_VALUE";
}
@@ -28,6 +28,7 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(AGUIUserMessage))]
[JsonSerializable(typeof(AGUIAssistantMessage))]
[JsonSerializable(typeof(AGUIToolMessage))]
[JsonSerializable(typeof(AGUIReasoningMessage))]
[JsonSerializable(typeof(AGUITool))]
[JsonSerializable(typeof(AGUIToolCall))]
[JsonSerializable(typeof(AGUIToolCall[]))]
@@ -46,6 +47,13 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(ToolCallResultEvent))]
[JsonSerializable(typeof(StateSnapshotEvent))]
[JsonSerializable(typeof(StateDeltaEvent))]
[JsonSerializable(typeof(ReasoningStartEvent))]
[JsonSerializable(typeof(ReasoningMessageStartEvent))]
[JsonSerializable(typeof(ReasoningMessageContentEvent))]
[JsonSerializable(typeof(ReasoningMessageEndEvent))]
[JsonSerializable(typeof(ReasoningEndEvent))]
[JsonSerializable(typeof(ReasoningMessageChunkEvent))]
[JsonSerializable(typeof(ReasoningEncryptedValueEvent))]
[JsonSerializable(typeof(IDictionary<string, object?>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(IDictionary<string, System.Text.Json.JsonElement?>))]
@@ -41,6 +41,7 @@ internal sealed class AGUIMessageJsonConverter : JsonConverter<AGUIMessage>
AGUIRoles.User => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIUserMessage))) as AGUIUserMessage,
AGUIRoles.Assistant => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIAssistantMessage))) as AGUIAssistantMessage,
AGUIRoles.Tool => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIToolMessage))) as AGUIToolMessage,
AGUIRoles.Reasoning => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIReasoningMessage))) as AGUIReasoningMessage,
_ => throw new JsonException($"Unknown AGUIMessage role discriminator: '{discriminator}'")
};
@@ -75,6 +76,9 @@ internal sealed class AGUIMessageJsonConverter : JsonConverter<AGUIMessage>
case AGUIToolMessage tool:
JsonSerializer.Serialize(writer, tool, options.GetTypeInfo(typeof(AGUIToolMessage)));
break;
case AGUIReasoningMessage reasoning:
JsonSerializer.Serialize(writer, reasoning, options.GetTypeInfo(typeof(AGUIReasoningMessage)));
break;
default:
throw new JsonException($"Unknown AGUIMessage type: {value.GetType().Name}");
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class AGUIReasoningMessage : AGUIMessage
{
public AGUIReasoningMessage()
{
this.Role = AGUIRoles.Reasoning;
}
[JsonPropertyName("encryptedValue")]
public string? EncryptedValue { get; set; }
}
@@ -17,4 +17,6 @@ internal static class AGUIRoles
public const string Developer = "developer";
public const string Tool = "tool";
public const string Reasoning = "reasoning";
}
@@ -47,6 +47,13 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent,
AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent,
AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent,
AGUIEventTypes.ReasoningStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningStartEvent))) as ReasoningStartEvent,
AGUIEventTypes.ReasoningMessageStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageStartEvent))) as ReasoningMessageStartEvent,
AGUIEventTypes.ReasoningMessageContent => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageContentEvent))) as ReasoningMessageContentEvent,
AGUIEventTypes.ReasoningMessageEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageEndEvent))) as ReasoningMessageEndEvent,
AGUIEventTypes.ReasoningEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningEndEvent))) as ReasoningEndEvent,
AGUIEventTypes.ReasoningMessageChunk => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageChunkEvent))) as ReasoningMessageChunkEvent,
AGUIEventTypes.ReasoningEncryptedValue => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningEncryptedValueEvent))) as ReasoningEncryptedValueEvent,
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
};
@@ -102,6 +109,27 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
case StateDeltaEvent stateDelta:
JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent)));
break;
case ReasoningStartEvent reasoningStart:
JsonSerializer.Serialize(writer, reasoningStart, options.GetTypeInfo(typeof(ReasoningStartEvent)));
break;
case ReasoningMessageStartEvent reasoningMessageStart:
JsonSerializer.Serialize(writer, reasoningMessageStart, options.GetTypeInfo(typeof(ReasoningMessageStartEvent)));
break;
case ReasoningMessageContentEvent reasoningMessageContent:
JsonSerializer.Serialize(writer, reasoningMessageContent, options.GetTypeInfo(typeof(ReasoningMessageContentEvent)));
break;
case ReasoningMessageEndEvent reasoningMessageEnd:
JsonSerializer.Serialize(writer, reasoningMessageEnd, options.GetTypeInfo(typeof(ReasoningMessageEndEvent)));
break;
case ReasoningEndEvent reasoningEnd:
JsonSerializer.Serialize(writer, reasoningEnd, options.GetTypeInfo(typeof(ReasoningEndEvent)));
break;
case ReasoningMessageChunkEvent reasoningMessageChunk:
JsonSerializer.Serialize(writer, reasoningMessageChunk, options.GetTypeInfo(typeof(ReasoningMessageChunkEvent)));
break;
case ReasoningEncryptedValueEvent reasoningEncryptedValue:
JsonSerializer.Serialize(writer, reasoningEncryptedValue, options.GetTypeInfo(typeof(ReasoningEncryptedValueEvent)));
break;
default:
throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}");
}
@@ -31,6 +31,7 @@ internal static class ChatResponseUpdateAGUIExtensions
string? responseId = null;
var textMessageBuilder = new TextMessageBuilder();
var toolCallAccumulator = new ToolCallBuilder();
var reasoningBuilder = new ReasoningMessageBuilder();
await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
{
switch (evt)
@@ -41,6 +42,7 @@ internal static class ChatResponseUpdateAGUIExtensions
responseId = runStarted.RunId;
toolCallAccumulator.SetConversationAndResponseIds(conversationId, responseId);
textMessageBuilder.SetConversationAndResponseIds(conversationId, responseId);
reasoningBuilder.SetConversationAndResponseIds(conversationId, responseId);
yield return ValidateAndEmitRunStart(runStarted);
break;
case RunFinishedEvent runFinished:
@@ -88,6 +90,36 @@ internal static class ChatResponseUpdateAGUIExtensions
yield return CreateStateDeltaUpdate(stateDelta, conversationId, responseId, jsonSerializerOptions);
}
break;
// Reasoning events (explicit lifecycle form)
case ReasoningMessageStartEvent reasoningStart:
reasoningBuilder.AddReasoningStart(reasoningStart);
break;
case ReasoningMessageContentEvent reasoningContent:
yield return reasoningBuilder.EmitReasoningContent(reasoningContent);
break;
case ReasoningMessageEndEvent reasoningEnd:
reasoningBuilder.EndCurrentMessage(reasoningEnd);
break;
// Reasoning events (chunk shorthand form)
case ReasoningMessageChunkEvent reasoningChunk:
var chunkUpdate = reasoningBuilder.EmitReasoningChunk(reasoningChunk);
if (chunkUpdate is not null)
{
yield return chunkUpdate;
}
break;
// Encrypted reasoning value (emitted by either form)
case ReasoningEncryptedValueEvent encryptedValue:
yield return reasoningBuilder.EmitEncryptedValue(encryptedValue);
break;
// ReasoningStartEvent and ReasoningEndEvent are bracket markers only — no content to emit
case ReasoningStartEvent:
case ReasoningEndEvent:
break;
}
}
}
@@ -305,6 +337,81 @@ internal static class ChatResponseUpdateAGUIExtensions
}
}
private sealed class ReasoningMessageBuilder()
{
private string? _currentMessageId;
private string? _conversationId;
private string? _responseId;
public void SetConversationAndResponseIds(string? conversationId, string? responseId)
{
this._conversationId = conversationId;
this._responseId = responseId;
}
public void AddReasoningStart(ReasoningMessageStartEvent reasoningStart)
{
if (this._currentMessageId != null)
{
throw new InvalidOperationException(
"Received ReasoningMessageStartEvent while another message is being processed.");
}
this._currentMessageId = reasoningStart.MessageId;
}
public ChatResponseUpdate EmitReasoningContent(ReasoningMessageContentEvent contentEvent)
{
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent(contentEvent.Delta)])
{
ConversationId = this._conversationId,
ResponseId = this._responseId,
MessageId = contentEvent.MessageId,
CreatedAt = DateTimeOffset.UtcNow
};
}
public ChatResponseUpdate? EmitReasoningChunk(ReasoningMessageChunkEvent chunkEvent)
{
if (string.IsNullOrEmpty(chunkEvent.Delta))
{
// Empty delta is the implicit close signal for chunk-based streaming
this._currentMessageId = null;
return null;
}
this._currentMessageId ??= chunkEvent.MessageId;
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent(chunkEvent.Delta)])
{
ConversationId = this._conversationId,
ResponseId = this._responseId,
MessageId = chunkEvent.MessageId,
CreatedAt = DateTimeOffset.UtcNow
};
}
public ChatResponseUpdate EmitEncryptedValue(ReasoningEncryptedValueEvent encryptedEvent)
{
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("") { ProtectedData = encryptedEvent.EncryptedValue }])
{
ConversationId = this._conversationId,
ResponseId = this._responseId,
MessageId = encryptedEvent.EntityId,
CreatedAt = DateTimeOffset.UtcNow
};
}
public void EndCurrentMessage(ReasoningMessageEndEvent reasoningEnd)
{
if (!string.Equals(this._currentMessageId, reasoningEnd.MessageId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Received ReasoningMessageEndEvent for a different message than the current one.");
}
this._currentMessageId = null;
}
}
private static IDictionary<string, object?>? DeserializeArgumentsIfAvailable(string argsJson, JsonSerializerOptions options)
{
if (!string.IsNullOrEmpty(argsJson))
@@ -342,6 +449,9 @@ internal static class ChatResponseUpdateAGUIExtensions
string? currentMessageId = null;
string? streamingMessageId = null;
string? currentReasoningBaseId = null;
string? currentReasoningId = null;
string? currentReasoningMessageId = null;
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// Generate a fallback MessageId when the provider doesn't supply one.
@@ -356,6 +466,25 @@ internal static class ChatResponseUpdateAGUIExtensions
chatResponse.Contents[0] is TextContent &&
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
{
// Close any open reasoning block before opening a text message, so AG-UI
// events are properly bracketed. MEAI providers share one MessageId across
// reasoning and text content, so the reasoning-block state alone wouldn't
// detect the transition.
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
currentReasoningBaseId = null;
currentReasoningId = null;
currentReasoningMessageId = null;
}
// End the previous message if there was one
if (currentMessageId is not null)
{
@@ -381,7 +510,7 @@ internal static class ChatResponseUpdateAGUIExtensions
{
yield return new TextMessageContentEvent
{
MessageId = chatResponse.MessageId!,
MessageId = currentMessageId!,
Delta = textContent.Text
};
}
@@ -393,6 +522,22 @@ internal static class ChatResponseUpdateAGUIExtensions
{
if (content is FunctionCallContent functionCallContent)
{
// Close any open reasoning block before emitting tool events.
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
currentReasoningBaseId = null;
currentReasoningId = null;
currentReasoningMessageId = null;
}
yield return new ToolCallStartEvent
{
ToolCallId = functionCallContent.CallId,
@@ -415,6 +560,22 @@ internal static class ChatResponseUpdateAGUIExtensions
}
else if (content is FunctionResultContent functionResultContent)
{
// Close any open reasoning block before emitting tool result events.
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
currentReasoningBaseId = null;
currentReasoningId = null;
currentReasoningMessageId = null;
}
yield return new ToolCallResultEvent
{
MessageId = chatResponse.MessageId,
@@ -423,6 +584,55 @@ internal static class ChatResponseUpdateAGUIExtensions
Role = AGUIRoles.Tool
};
}
else if (content is TextReasoningContent reasoningContent
&& (!string.IsNullOrEmpty(reasoningContent.Text) || !string.IsNullOrEmpty(reasoningContent.ProtectedData)))
{
if (!string.Equals(currentReasoningBaseId, chatResponse.MessageId, StringComparison.Ordinal))
{
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
}
currentReasoningBaseId = chatResponse.MessageId;
currentReasoningId = Guid.NewGuid().ToString("N");
currentReasoningMessageId = Guid.NewGuid().ToString("N");
yield return new ReasoningStartEvent
{
MessageId = currentReasoningId
};
yield return new ReasoningMessageStartEvent
{
MessageId = currentReasoningMessageId
};
}
if (!string.IsNullOrEmpty(reasoningContent.Text))
{
yield return new ReasoningMessageContentEvent
{
MessageId = currentReasoningMessageId!,
Delta = reasoningContent.Text
};
}
if (!string.IsNullOrEmpty(reasoningContent.ProtectedData))
{
yield return new ReasoningEncryptedValueEvent
{
EntityId = currentReasoningMessageId!,
EncryptedValue = reasoningContent.ProtectedData
};
}
}
else if (content is DataContent dataContent)
{
if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json))
@@ -476,6 +686,19 @@ internal static class ChatResponseUpdateAGUIExtensions
}
}
// End the last reasoning block if there was one
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
}
// End the last message if there was one
if (currentMessageId is not null)
{
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningEncryptedValueEvent : BaseEvent
{
public ReasoningEncryptedValueEvent()
{
this.Type = AGUIEventTypes.ReasoningEncryptedValue;
}
[JsonPropertyName("subtype")]
public string Subtype { get; set; } = "message";
[JsonPropertyName("entityId")]
public string EntityId { get; set; } = string.Empty;
[JsonPropertyName("encryptedValue")]
public string EncryptedValue { get; set; } = string.Empty;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningEndEvent : BaseEvent
{
public ReasoningEndEvent()
{
this.Type = AGUIEventTypes.ReasoningEnd;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningMessageChunkEvent : BaseEvent
{
public ReasoningMessageChunkEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageChunk;
}
[JsonPropertyName("messageId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MessageId { get; set; }
[JsonPropertyName("delta")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Delta { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningMessageContentEvent : BaseEvent
{
public ReasoningMessageContentEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageContent;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
[JsonPropertyName("delta")]
public string Delta { get; set; } = string.Empty;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningMessageEndEvent : BaseEvent
{
public ReasoningMessageEndEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageEnd;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningMessageStartEvent : BaseEvent
{
public ReasoningMessageStartEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageStart;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
[JsonPropertyName("role")]
public string Role { get; set; } = AGUIRoles.Reasoning;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class ReasoningStartEvent : BaseEvent
{
public ReasoningStartEvent()
{
this.Type = AGUIEventTypes.ReasoningStart;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -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
}