.NET: Support reasoning events in AGUI (#4953)

* Support reasoning

* MEAI gives the same MessageId for reasoning and text content because they are part of the same logical model response. Create a new GUID for reasoning messages to be consistent with AGUI protocol and establish no link between reasoning and text messages

* When a frontend AG-UI client sends conversation history back in a subsequent POST, any accumulated role: "reasoning" messages fail deserialization in AGUIMessageJsonConverter because the role wasn't handled - causing the request to fail.

This adds AGUIReasoningMessage with Content and EncryptedValue properties, registers it in the JSON converter and serializer context, and converts it to TextReasoningContent (with ProtectedData) in AsChatMessages.

* Added MapReasoningMessage - converts a ChatMessage containing TextReasoningContent to AGUIReasoningMessage for c# client

* review

* Support reasoning

* MEAI gives the same MessageId for reasoning and text content because they are part of the same logical model response. Create a new GUID for reasoning messages to be consistent with AGUI protocol and establish no link between reasoning and text messages

* When a frontend AG-UI client sends conversation history back in a subsequent POST, any accumulated role: "reasoning" messages fail deserialization in AGUIMessageJsonConverter because the role wasn't handled - causing the request to fail.

This adds AGUIReasoningMessage with Content and EncryptedValue properties, registers it in the JSON converter and serializer context, and converts it to TextReasoningContent (with ProtectedData) in AsChatMessages.

* Added MapReasoningMessage - converts a ChatMessage containing TextReasoningContent to AGUIReasoningMessage for c# client

* review

* dotnet format

* Replace hardcoded string with constant

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jeffin SIby
2026-05-07 16:09:04 +01:00
committed by GitHub
Unverified
parent 213491da66
commit 44381c051b
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
}