// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.Agents.AI.AGUI.Shared; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.AGUI.UnitTests; public sealed class ChatResponseUpdateAGUIExtensionsTests { [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsRunStartedEvent_ToResponseUpdateWithMetadataAsync() { // Arrange List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" } ]; // 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("run1", updates[0].ResponseId); Assert.NotNull(updates[0].CreatedAt); Assert.Equal("thread1", updates[0].ConversationId); } [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsRunFinishedEvent_ToResponseUpdateWithMetadataAsync() { // Arrange List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonSerializer.SerializeToElement("Success") } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert Assert.Equal(2, updates.Count); // First update is RunStarted Assert.Equal(ChatRole.Assistant, updates[0].Role); Assert.Equal("run1", updates[0].ResponseId); // Second update is RunFinished Assert.Equal(ChatRole.Assistant, updates[1].Role); Assert.Equal("run1", updates[1].ResponseId); Assert.NotNull(updates[1].CreatedAt); TextContent content = Assert.IsType(updates[1].Contents[0]); Assert.Equal("\"Success\"", content.Text); // JSON string representation includes quotes // ConversationId is stored in the ChatResponseUpdate Assert.Equal("thread1", updates[1].ConversationId); } [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsRunErrorEvent_ToErrorContentAsync() { // Arrange List events = [ new RunErrorEvent { Message = "Error occurred", Code = "ERR001" } ]; // 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); ErrorContent content = Assert.IsType(updates[0].Contents[0]); Assert.Equal("Error occurred", content.Message); // Code is stored in ErrorCode property Assert.Equal("ERR001", content.ErrorCode); } [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsTextMessageSequence_ToTextUpdatesWithCorrectRoleAsync() { // Arrange List events = [ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, new TextMessageContentEvent { MessageId = "msg1", Delta = " World" }, new TextMessageEndEvent { MessageId = "msg1" } ]; // 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.Equal("Hello", ((TextContent)updates[0].Contents[0]).Text); Assert.Equal(" World", ((TextContent)updates[1].Contents[0]).Text); } [Fact] public async Task AsChatResponseUpdatesAsync_WithTextMessageStartWhileMessageInProgress_ThrowsInvalidOperationExceptionAsync() { // Arrange List events = [ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.User } ]; // Act & Assert await Assert.ThrowsAsync(async () => { await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { // Intentionally empty - consuming stream to trigger exception } }); } [Fact] public async Task AsChatResponseUpdatesAsync_WithTextMessageEndForWrongMessageId_ThrowsInvalidOperationExceptionAsync() { // Arrange List events = [ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, new TextMessageEndEvent { MessageId = "msg2" } ]; // Act & Assert await Assert.ThrowsAsync(async () => { await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { // Intentionally empty - consuming stream to trigger exception } }); } [Fact] public async Task AsChatResponseUpdatesAsync_MaintainsMessageContext_AcrossMultipleContentEventsAsync() { // Arrange List events = [ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, new TextMessageContentEvent { MessageId = "msg1", Delta = " " }, new TextMessageContentEvent { MessageId = "msg1", Delta = "World" }, new TextMessageEndEvent { MessageId = "msg1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert Assert.Equal(3, updates.Count); Assert.All(updates, u => Assert.Equal(ChatRole.Assistant, u.Role)); Assert.All(updates, u => Assert.Equal("msg1", u.MessageId)); } [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsToolCallEvents_ToFunctionCallContentAsync() { // Arrange List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "GetWeather", ParentMessageId = "msg1" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"location\":" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "\"Seattle\"}" }, new ToolCallEndEvent { ToolCallId = "call_1" }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert ChatResponseUpdate toolCallUpdate = updates.First(u => u.Contents.Any(c => c is FunctionCallContent)); FunctionCallContent functionCall = Assert.IsType(toolCallUpdate.Contents[0]); Assert.Equal("call_1", functionCall.CallId); Assert.Equal("GetWeather", functionCall.Name); Assert.NotNull(functionCall.Arguments); Assert.Equal("Seattle", functionCall.Arguments!["location"]?.ToString()); } [Fact] public async Task AsChatResponseUpdatesAsync_WithMultipleToolCallArgsEvents_AccumulatesArgsCorrectlyAsync() { // Arrange List events = [ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "TestTool", ParentMessageId = "msg1" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"par" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "t1\":\"val" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "ue1\",\"part2" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "\":\"value2\"}" }, new ToolCallEndEvent { ToolCallId = "call_1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert FunctionCallContent functionCall = updates .SelectMany(u => u.Contents) .OfType() .Single(); Assert.Equal("value1", functionCall.Arguments!["part1"]?.ToString()); Assert.Equal("value2", functionCall.Arguments!["part2"]?.ToString()); } [Fact] public async Task AsChatResponseUpdatesAsync_WithEmptyToolCallArgs_HandlesGracefullyAsync() { // Arrange List events = [ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "NoArgsTool", ParentMessageId = "msg1" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "" }, new ToolCallEndEvent { ToolCallId = "call_1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert FunctionCallContent functionCall = updates .SelectMany(u => u.Contents) .OfType() .Single(); Assert.Equal("call_1", functionCall.CallId); Assert.Equal("NoArgsTool", functionCall.Name); Assert.Null(functionCall.Arguments); } [Fact] public async Task AsChatResponseUpdatesAsync_WithOverlappingToolCalls_ThrowsInvalidOperationExceptionAsync() { // Arrange List events = [ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg1" } // Second start before first ends ]; // 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_WithMismatchedToolCallId_ThrowsInvalidOperationExceptionAsync() { // Arrange List events = [ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" }, new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" } // Wrong call 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 AsChatResponseUpdatesAsync_WithMismatchedToolCallEndId_ThrowsInvalidOperationExceptionAsync() { // Arrange List events = [ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, new ToolCallEndEvent { ToolCallId = "call_2" } // Wrong call 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 AsChatResponseUpdatesAsync_WithMultipleSequentialToolCalls_ProcessesAllCorrectlyAsync() { // Arrange List events = [ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" }, new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"arg1\":\"val1\"}" }, new ToolCallEndEvent { ToolCallId = "call_1" }, new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg2" }, new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{\"arg2\":\"val2\"}" }, new ToolCallEndEvent { ToolCallId = "call_2" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert List functionCalls = updates .SelectMany(u => u.Contents) .OfType() .ToList(); Assert.Equal(2, functionCalls.Count); Assert.Equal("call_1", functionCalls[0].CallId); Assert.Equal("Tool1", functionCalls[0].Name); Assert.Equal("call_2", functionCalls[1].CallId); Assert.Equal("Tool2", functionCalls[1].Name); } [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsStateSnapshotEvent_ToDataContentWithJsonAsync() { // Arrange JsonElement stateSnapshot = JsonSerializer.SerializeToElement(new { counter = 42, status = "active" }); List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new StateSnapshotEvent { Snapshot = stateSnapshot }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent)); Assert.Equal(ChatRole.Assistant, stateUpdate.Role); Assert.Equal("thread1", stateUpdate.ConversationId); Assert.Equal("run1", stateUpdate.ResponseId); DataContent dataContent = Assert.IsType(stateUpdate.Contents[0]); Assert.Equal("application/json", dataContent.MediaType); // Verify the JSON content string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); JsonElement deserializedState = JsonElement.Parse(jsonText); Assert.Equal(42, deserializedState.GetProperty("counter").GetInt32()); Assert.Equal("active", deserializedState.GetProperty("status").GetString()); // Verify additional properties Assert.NotNull(stateUpdate.AdditionalProperties); Assert.True((bool)stateUpdate.AdditionalProperties["is_state_snapshot"]!); } [Fact] public async Task AsChatResponseUpdatesAsync_WithNullStateSnapshot_DoesNotEmitUpdateAsync() { // Arrange List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new StateSnapshotEvent { Snapshot = null }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent)); } [Fact] public async Task AsChatResponseUpdatesAsync_WithEmptyObjectStateSnapshot_EmitsDataContentAsync() { // Arrange JsonElement emptyState = JsonSerializer.SerializeToElement(new { }); List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new StateSnapshotEvent { Snapshot = emptyState }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent)); DataContent dataContent = Assert.IsType(stateUpdate.Contents[0]); string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); Assert.Equal("{}", jsonText); } [Fact] public async Task AsChatResponseUpdatesAsync_WithComplexStateSnapshot_PreservesJsonStructureAsync() { // Arrange var complexState = new { user = new { name = "Alice", age = 30 }, items = new[] { "item1", "item2", "item3" }, metadata = new { timestamp = "2024-01-01T00:00:00Z", version = 2 } }; JsonElement stateSnapshot = JsonSerializer.SerializeToElement(complexState); List events = [ new StateSnapshotEvent { Snapshot = stateSnapshot } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert ChatResponseUpdate stateUpdate = updates.First(); DataContent dataContent = Assert.IsType(stateUpdate.Contents[0]); string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); JsonElement roundTrippedState = JsonElement.Parse(jsonText); Assert.Equal("Alice", roundTrippedState.GetProperty("user").GetProperty("name").GetString()); Assert.Equal(30, roundTrippedState.GetProperty("user").GetProperty("age").GetInt32()); Assert.Equal(3, roundTrippedState.GetProperty("items").GetArrayLength()); Assert.Equal("item1", roundTrippedState.GetProperty("items")[0].GetString()); } [Fact] public async Task AsChatResponseUpdatesAsync_WithStateSnapshotAndTextMessages_EmitsBothAsync() { // Arrange JsonElement state = JsonSerializer.SerializeToElement(new { step = 1 }); List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, new TextMessageContentEvent { MessageId = "msg1", Delta = "Processing..." }, new TextMessageEndEvent { MessageId = "msg1" }, new StateSnapshotEvent { Snapshot = state }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert Assert.Contains(updates, u => u.Contents.Any(c => c is TextContent)); Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent)); } #region State Delta Tests [Fact] public async Task AsChatResponseUpdatesAsync_ConvertsStateDeltaEvent_ToDataContentWithJsonPatchAsync() { // Arrange - Create JSON Patch operations (RFC 6902) JsonElement stateDelta = JsonSerializer.SerializeToElement(new object[] { new { op = "replace", path = "/counter", value = 43 }, new { op = "add", path = "/newField", value = "test" } }); List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new StateDeltaEvent { Delta = stateDelta }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert ChatResponseUpdate deltaUpdate = updates.First(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json")); Assert.Equal(ChatRole.Assistant, deltaUpdate.Role); Assert.Equal("thread1", deltaUpdate.ConversationId); Assert.Equal("run1", deltaUpdate.ResponseId); DataContent dataContent = Assert.IsType(deltaUpdate.Contents[0]); Assert.Equal("application/json-patch+json", dataContent.MediaType); // Verify the JSON Patch content string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); JsonElement deserializedDelta = JsonElement.Parse(jsonText); Assert.Equal(JsonValueKind.Array, deserializedDelta.ValueKind); Assert.Equal(2, deserializedDelta.GetArrayLength()); // Verify first operation JsonElement firstOp = deserializedDelta[0]; Assert.Equal("replace", firstOp.GetProperty("op").GetString()); Assert.Equal("/counter", firstOp.GetProperty("path").GetString()); Assert.Equal(43, firstOp.GetProperty("value").GetInt32()); // Verify second operation JsonElement secondOp = deserializedDelta[1]; Assert.Equal("add", secondOp.GetProperty("op").GetString()); Assert.Equal("/newField", secondOp.GetProperty("path").GetString()); Assert.Equal("test", secondOp.GetProperty("value").GetString()); // Verify additional properties Assert.NotNull(deltaUpdate.AdditionalProperties); Assert.True((bool)deltaUpdate.AdditionalProperties["is_state_delta"]!); } [Fact] public async Task AsChatResponseUpdatesAsync_WithNullStateDelta_DoesNotEmitUpdateAsync() { // Arrange List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new StateDeltaEvent { Delta = null }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert - Only run started and finished should be present Assert.Equal(2, updates.Count); Assert.IsType(updates[0]); // Run started Assert.IsType(updates[1]); // Run finished Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent)); } [Fact] public async Task AsChatResponseUpdatesAsync_WithEmptyStateDelta_EmitsUpdateAsync() { // Arrange - Empty JSON Patch array is valid JsonElement emptyDelta = JsonSerializer.SerializeToElement(Array.Empty()); List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new StateDeltaEvent { Delta = emptyDelta }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json")); } [Fact] public async Task AsChatResponseUpdatesAsync_WithMultipleStateDeltaEvents_ConvertsAllAsync() { // Arrange JsonElement delta1 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } }); JsonElement delta2 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 2 } }); JsonElement delta3 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 3 } }); List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new StateDeltaEvent { Delta = delta1 }, new StateDeltaEvent { Delta = delta2 }, new StateDeltaEvent { Delta = delta3 }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } // Assert var deltaUpdates = updates.Where(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json")).ToList(); Assert.Equal(3, deltaUpdates.Count); } [Fact] public async Task AsAGUIEventStreamAsync_ConvertsDataContentWithJsonPatch_ToStateDeltaEventAsync() { // Arrange - Create a ChatResponseUpdate with JSON Patch DataContent JsonElement patchOps = JsonSerializer.SerializeToElement(new object[] { new { op = "remove", path = "/oldField" }, new { op = "add", path = "/newField", value = "newValue" } }); byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(patchOps); DataContent dataContent = new(jsonBytes, "application/json-patch+json"); List updates = [ new ChatResponseUpdate(ChatRole.Assistant, [dataContent]) { MessageId = "msg1" } ]; // Act List outputEvents = []; await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) { outputEvents.Add(evt); } // Assert StateDeltaEvent? deltaEvent = outputEvents.OfType().FirstOrDefault(); Assert.NotNull(deltaEvent); Assert.NotNull(deltaEvent.Delta); Assert.Equal(JsonValueKind.Array, deltaEvent.Delta.Value.ValueKind); // Verify patch operations JsonElement delta = deltaEvent.Delta.Value; Assert.Equal(2, delta.GetArrayLength()); Assert.Equal("remove", delta[0].GetProperty("op").GetString()); Assert.Equal("/oldField", delta[0].GetProperty("path").GetString()); Assert.Equal("add", delta[1].GetProperty("op").GetString()); Assert.Equal("/newField", delta[1].GetProperty("path").GetString()); } [Fact] public async Task AsAGUIEventStreamAsync_WithBothSnapshotAndDelta_EmitsBothEventsAsync() { // Arrange JsonElement snapshot = JsonSerializer.SerializeToElement(new { counter = 0 }); byte[] snapshotBytes = JsonSerializer.SerializeToUtf8Bytes(snapshot); DataContent snapshotContent = new(snapshotBytes, "application/json"); JsonElement delta = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } }); byte[] deltaBytes = JsonSerializer.SerializeToUtf8Bytes(delta); DataContent deltaContent = new(deltaBytes, "application/json-patch+json"); List updates = [ new ChatResponseUpdate(ChatRole.Assistant, [snapshotContent]) { MessageId = "msg1" }, new ChatResponseUpdate(ChatRole.Assistant, [deltaContent]) { MessageId = "msg2" } ]; // 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 StateSnapshotEvent); Assert.Contains(outputEvents, e => e is StateDeltaEvent); } [Fact] public async Task StateDeltaEvent_RoundTrip_PreservesJsonPatchOperationsAsync() { // Arrange - Create complex JSON Patch with various operations JsonElement originalDelta = JsonSerializer.SerializeToElement(new object[] { new { op = "add", path = "/user/email", value = "test@example.com" }, new { op = "remove", path = "/user/tempData" }, new { op = "replace", path = "/user/lastLogin", value = "2025-11-09T12:00:00Z" }, new { op = "move", from = "/user/oldAddress", path = "/user/previousAddress" }, new { op = "copy", from = "/user/name", path = "/user/displayName" }, new { op = "test", path = "/user/version", value = 2 } }); List events = [ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, new StateDeltaEvent { Delta = originalDelta }, new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } ]; // Act - Convert to ChatResponseUpdate and back to events List updates = []; await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options)) { updates.Add(update); } List roundTripEvents = []; await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) { roundTripEvents.Add(evt); } // Assert StateDeltaEvent? roundTripDelta = roundTripEvents.OfType().FirstOrDefault(); Assert.NotNull(roundTripDelta); Assert.NotNull(roundTripDelta.Delta); JsonElement delta = roundTripDelta.Delta.Value; Assert.Equal(6, delta.GetArrayLength()); // Verify each operation type Assert.Equal("add", delta[0].GetProperty("op").GetString()); Assert.Equal("remove", delta[1].GetProperty("op").GetString()); Assert.Equal("replace", delta[2].GetProperty("op").GetString()); Assert.Equal("move", delta[3].GetProperty("op").GetString()); Assert.Equal("copy", delta[4].GetProperty("op").GetString()); Assert.Equal("test", delta[5].GetProperty("op").GetString()); } #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]); var reasoningId = reasoningStart.MessageId; Assert.NotEqual("reason1", reasoningId); var reasoningMessageStart = Assert.IsType(outputEvents[2]); var reasoningMessageId = reasoningMessageStart.MessageId; Assert.NotEqual(reasoningId, reasoningMessageId); var reasoningContent = Assert.IsType(outputEvents[3]); Assert.Equal(reasoningMessageId, reasoningContent.MessageId); Assert.Equal("I need to think about this", reasoningContent.Delta); var reasoningMessageEnd = Assert.IsType(outputEvents[4]); Assert.Equal(reasoningMessageId, reasoningMessageEnd.MessageId); var reasoningEnd = Assert.IsType(outputEvents[5]); Assert.Equal(reasoningId, 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 reasoningMessageId = outputEvents.OfType().Single().MessageId; Assert.NotEqual("reason1", reasoningMessageId); var encryptedEvent = outputEvents.OfType().Single(); Assert.Equal(reasoningMessageId, 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 AsAGUIEventStreamAsync_WithReasoningAndTextSharingSameMessageId_EmitsDistinctEventIdsAsync() { // Arrange List updates = [ new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "shared1" }, new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "shared1" } ]; // Act List outputEvents = []; await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options)) { outputEvents.Add(evt); } // Assert var reasoningId = outputEvents.OfType().Single().MessageId; var reasoningMessageId = outputEvents.OfType().Single().MessageId; var textMessageId = outputEvents.OfType().Single().MessageId; Assert.NotEqual(reasoningId, reasoningMessageId); Assert.NotEqual(reasoningId, textMessageId); Assert.NotEqual(reasoningMessageId, textMessageId); Assert.Equal("shared1", textMessageId); Assert.All(outputEvents.OfType(), e => Assert.Equal(reasoningMessageId, e.MessageId)); Assert.Equal(reasoningMessageId, outputEvents.OfType().Single().MessageId); Assert.Equal(reasoningId, outputEvents.OfType().Single().MessageId); Assert.All(outputEvents.OfType(), e => Assert.Equal("shared1", e.MessageId)); } [Fact] public async Task AsAGUIEventStreamAsync_WithReasoningThenTextSharingSameMessageId_ClosesReasoningBlockBeforeTextStartAsync() { // Arrange List updates = [ new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "shared1" }, new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "shared1" } ]; // Act List 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 updates = [ new(ChatRole.Assistant, [new TextReasoningContent("thinking about which tool to use")]) { MessageId = "shared1" }, new(ChatRole.Assistant, [new FunctionCallContent("call-1", "GetWeather", new Dictionary { ["location"] = "Seattle" })]) { MessageId = "shared1" } ]; // Act List 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 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 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 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 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().Single().MessageId; Assert.NotEqual("reason1", reasoningMessageId); var encryptedEvent = outputEvents.OfType().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 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 }