mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
213491da66
commit
44381c051b
@@ -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
|
||||
}
|
||||
|
||||
+465
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user