mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into dev/dotnet_workflow/magentic
This commit is contained in:
@@ -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