diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs index 1b8958cdf0..045685b2a5 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs @@ -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"; } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs index b13a803625..25e774eb11 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs @@ -46,6 +46,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))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs index eca2131f23..0dcddcf53e 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs @@ -47,6 +47,13 @@ public override BaseEvent Read( 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 @@ public override void Write( 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}"); } diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs index f5fb103bd4..e9f0420d4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs @@ -31,6 +31,7 @@ public static async IAsyncEnumerable AsChatResponseUpdatesAs 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 @@ public static async IAsyncEnumerable AsChatResponseUpdatesAs 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 @@ public static async IAsyncEnumerable AsChatResponseUpdatesAs 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 void SetConversationAndResponseIds(string conversationId, string respon } } + 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? DeserializeArgumentsIfAvailable(string argsJson, JsonSerializerOptions options) { if (!string.IsNullOrEmpty(argsJson)) @@ -341,6 +448,7 @@ public static async IAsyncEnumerable AsAGUIEventStreamAsync( }; string? currentMessageId = null; + string? currentReasoningMessageId = null; await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { if (chatResponse is { Contents.Count: > 0 } && @@ -414,6 +522,52 @@ chatResponse.Contents[0] is TextContent && Role = AGUIRoles.Tool }; } + else if (content is TextReasoningContent reasoningContent + && (!string.IsNullOrEmpty(reasoningContent.Text) || !string.IsNullOrEmpty(reasoningContent.ProtectedData))) + { + if (!string.Equals(currentReasoningMessageId, chatResponse.MessageId, StringComparison.Ordinal)) + { + if (currentReasoningMessageId is not null) + { + yield return new ReasoningMessageEndEvent + { + MessageId = currentReasoningMessageId + }; + yield return new ReasoningEndEvent + { + MessageId = currentReasoningMessageId + }; + } + + yield return new ReasoningStartEvent + { + MessageId = chatResponse.MessageId! + }; + yield return new ReasoningMessageStartEvent + { + MessageId = chatResponse.MessageId! + }; + currentReasoningMessageId = chatResponse.MessageId; + } + + 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)) @@ -467,6 +621,19 @@ chatResponse.Contents[0] is TextContent && } } + // 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 = currentReasoningMessageId + }; + } + // End the last message if there was one if (currentMessageId is not null) { diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEncryptedValueEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEncryptedValueEvent.cs new file mode 100644 index 0000000000..8c3deff5f2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEncryptedValueEvent.cs @@ -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; +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEndEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEndEvent.cs new file mode 100644 index 0000000000..2f70e5beea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEndEvent.cs @@ -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; +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageChunkEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageChunkEvent.cs new file mode 100644 index 0000000000..9afebd4e09 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageChunkEvent.cs @@ -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; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageContentEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageContentEvent.cs new file mode 100644 index 0000000000..60461caf93 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageContentEvent.cs @@ -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; +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageEndEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageEndEvent.cs new file mode 100644 index 0000000000..b07e8e9604 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageEndEvent.cs @@ -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; +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageStartEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageStartEvent.cs new file mode 100644 index 0000000000..45a633516a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageStartEvent.cs @@ -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; } = "reasoning"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningStartEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningStartEvent.cs new file mode 100644 index 0000000000..13a96a67b2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningStartEvent.cs @@ -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; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs index 33f259a681..5e7066bf8f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs @@ -1111,4 +1111,149 @@ public void AllToolEventTypes_SerializeAsPolymorphicBaseEvent_Correctly() } #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(json, AGUIJsonSerializerContext.Default.Options); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(7, deserialized.Length); + Assert.IsType(deserialized[0]); + Assert.IsType(deserialized[1]); + Assert.IsType(deserialized[2]); + Assert.IsType(deserialized[3]); + Assert.IsType(deserialized[4]); + Assert.IsType(deserialized[5]); + Assert.IsType(deserialized[6]); + } + + #endregion Reasoning Event Serialization Tests } diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs index 7d40cc014d..ace4e0d516 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs @@ -777,4 +777,361 @@ public async Task StateDeltaEvent_RoundTrip_PreservesJsonPatchOperationsAsync() } #endregion State Delta Tests + + #region Reasoning Tests + + [Fact] + public async Task AsChatResponseUpdatesAsync_WithReasoningMessageEndForWrongMessageId_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + List events = + [ + new ReasoningMessageStartEvent { MessageId = "reason1" }, + new ReasoningMessageContentEvent { MessageId = "reason1", Delta = "thinking..." }, + new ReasoningMessageEndEvent { MessageId = "reason2" } // Wrong message ID + ]; + + // Act & Assert + await Assert.ThrowsAsync(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 updates = + [ + new(ChatRole.Assistant, [new TextReasoningContent("I need to think about this")]) { MessageId = "reason1" } + ]; + + // Act + List outputEvents = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) + { + outputEvents.Add(evt); + } + + // Assert + Assert.IsType(outputEvents[0]); + var reasoningStart = Assert.IsType(outputEvents[1]); + Assert.Equal("reason1", reasoningStart.MessageId); + var reasoningMessageStart = Assert.IsType(outputEvents[2]); + Assert.Equal("reason1", reasoningMessageStart.MessageId); + var reasoningContent = Assert.IsType(outputEvents[3]); + Assert.Equal("reason1", reasoningContent.MessageId); + Assert.Equal("I need to think about this", reasoningContent.Delta); + var reasoningMessageEnd = Assert.IsType(outputEvents[4]); + Assert.Equal("reason1", reasoningMessageEnd.MessageId); + var reasoningEnd = Assert.IsType(outputEvents[5]); + Assert.Equal("reason1", reasoningEnd.MessageId); + Assert.IsType(outputEvents[6]); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WithMultipleReasoningDeltas_EmitsContentEventPerDeltaAsync() + { + // Arrange + List updates = + [ + new(ChatRole.Assistant, [new TextReasoningContent("First")]) { MessageId = "reason1" }, + new(ChatRole.Assistant, [new TextReasoningContent(" step")]) { MessageId = "reason1" } + ]; + + // Act + List outputEvents = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) + { + outputEvents.Add(evt); + } + + // Assert + var contentEvents = outputEvents.OfType().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()); + Assert.Single(outputEvents.OfType()); + Assert.Single(outputEvents.OfType()); + Assert.Single(outputEvents.OfType()); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WithReasoningAndProtectedData_EmitsEncryptedValueEventAsync() + { + // Arrange + List updates = + [ + new(ChatRole.Assistant, [new TextReasoningContent("thinking") { ProtectedData = "encrypted-abc" }]) { MessageId = "reason1" } + ]; + + // Act + List outputEvents = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) + { + outputEvents.Add(evt); + } + + // Assert + var encryptedEvent = outputEvents.OfType().Single(); + Assert.Equal("reason1", encryptedEvent.EntityId); + Assert.Equal("encrypted-abc", encryptedEvent.EncryptedValue); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WithReasoningFollowedByText_EmitsBothEventSequencesAsync() + { + // Arrange + List updates = + [ + new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "reason1" }, + new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "msg1" } + ]; + + // Act + List 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 AsChatResponseUpdatesAsync_WithReasoningMessageSequence_ProducesTextReasoningContentPerDeltaAsync() + { + // Arrange + List 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 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(updates[0].Contents[0]); + Assert.Equal("First thought", firstContent.Text); + var secondContent = Assert.IsType(updates[1].Contents[0]); + Assert.Equal(" and more", secondContent.Text); + } + + [Fact] + public async Task AsChatResponseUpdatesAsync_WithReasoningStartAndEndEvents_DoNotProduceUpdatesAsync() + { + // Arrange + List events = + [ + new ReasoningStartEvent { MessageId = "reason1" }, + new ReasoningEndEvent { MessageId = "reason1" } + ]; + + // Act + List 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 events = + [ + new ReasoningEncryptedValueEvent { EntityId = "reason1", EncryptedValue = "secret-token" } + ]; + + // Act + List 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(updates[0].Contents[0]); + Assert.Equal("secret-token", content.ProtectedData); + } + + [Fact] + public async Task AsChatResponseUpdatesAsync_WithReasoningMessageChunks_ProducesTextReasoningContentPerChunkAsync() + { + // Arrange + List events = + [ + new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "chunk one" }, + new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = " chunk two" }, + new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "" } + ]; + + // Act + List 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(updates[0].Contents[0]); + Assert.Equal("chunk one", firstContent.Text); + var secondContent = Assert.IsType(updates[1].Contents[0]); + Assert.Equal(" chunk two", secondContent.Text); + } + + [Fact] + public async Task AsChatResponseUpdatesAsync_WithReasoningMessageChunkEmptyDelta_ProducesNoUpdateAsync() + { + // Arrange + List events = + [ + new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "" } + ]; + + // Act + List 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 events = + [ + new ReasoningMessageStartEvent { MessageId = "reason1" }, + new ReasoningMessageStartEvent { MessageId = "reason2" } // Overlapping start + ]; + + // Act & Assert + await Assert.ThrowsAsync(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 events = + [ + new ReasoningMessageEndEvent { MessageId = "reason1" } // End without start + ]; + + // Act & Assert + await Assert.ThrowsAsync(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 updates = + [ + new(ChatRole.Assistant, [new TextReasoningContent("") { ProtectedData = "encrypted-only" }]) { MessageId = "reason1" } + ]; + + // Act + List outputEvents = []; + await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) + { + outputEvents.Add(evt); + } + + // Assert — encrypted value should not be silently dropped + Assert.Contains(outputEvents, e => e is ReasoningStartEvent); + Assert.Contains(outputEvents, e => e is ReasoningMessageStartEvent); + Assert.DoesNotContain(outputEvents, e => e is ReasoningMessageContentEvent); + var encryptedEvent = outputEvents.OfType().Single(); + Assert.Equal("reason1", 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 outboundUpdates = + [ + new(ChatRole.Assistant, [new TextReasoningContent("I'm thinking") { ProtectedData = "enc-value" }]) { MessageId = "reason1" } + ]; + + // Act - outbound: ChatResponseUpdate → AGUI events + List aguilEvents = []; + await foreach (BaseEvent evt in outboundUpdates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) + { + aguilEvents.Add(evt); + } + + // Act - inbound: AGUI events → ChatResponseUpdate + List inboundUpdates = []; + await foreach (ChatResponseUpdate update in aguilEvents.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) + { + inboundUpdates.Add(update); + } + + // Assert + var reasoningContents = inboundUpdates + .SelectMany(u => u.Contents) + .OfType() + .ToList(); + + Assert.Contains(reasoningContents, c => c.Text == "I'm thinking"); + Assert.Contains(reasoningContents, c => c.ProtectedData == "enc-value"); + } + + #endregion Reasoning Tests }