mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
44381c051b
* Support reasoning * MEAI gives the same MessageId for reasoning and text content because they are part of the same logical model response. Create a new GUID for reasoning messages to be consistent with AGUI protocol and establish no link between reasoning and text messages * When a frontend AG-UI client sends conversation history back in a subsequent POST, any accumulated role: "reasoning" messages fail deserialization in AGUIMessageJsonConverter because the role wasn't handled - causing the request to fail. This adds AGUIReasoningMessage with Content and EncryptedValue properties, registers it in the JSON converter and serializer context, and converts it to TextReasoningContent (with ProtectedData) in AsChatMessages. * Added MapReasoningMessage - converts a ChatMessage containing TextReasoningContent to AGUIReasoningMessage for c# client * review * Support reasoning * MEAI gives the same MessageId for reasoning and text content because they are part of the same logical model response. Create a new GUID for reasoning messages to be consistent with AGUI protocol and establish no link between reasoning and text messages * When a frontend AG-UI client sends conversation history back in a subsequent POST, any accumulated role: "reasoning" messages fail deserialization in AGUIMessageJsonConverter because the role wasn't handled - causing the request to fail. This adds AGUIReasoningMessage with Content and EncryptedValue properties, registers it in the JSON converter and serializer context, and converts it to TextReasoningContent (with ProtectedData) in AsChatMessages. * Added MapReasoningMessage - converts a ChatMessage containing TextReasoningContent to AGUIReasoningMessage for c# client * review * dotnet format * Replace hardcoded string with constant Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1246 lines
51 KiB
C#
1246 lines
51 KiB
C#
// 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<BaseEvent> events =
|
|
[
|
|
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }
|
|
];
|
|
|
|
// 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("run1", updates[0].ResponseId);
|
|
Assert.NotNull(updates[0].CreatedAt);
|
|
Assert.Equal("thread1", updates[0].ConversationId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AsChatResponseUpdatesAsync_ConvertsRunFinishedEvent_ToResponseUpdateWithMetadataAsync()
|
|
{
|
|
// Arrange
|
|
List<BaseEvent> events =
|
|
[
|
|
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
|
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonSerializer.SerializeToElement("Success") }
|
|
];
|
|
|
|
// Act
|
|
List<ChatResponseUpdate> 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<TextContent>(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<BaseEvent> events =
|
|
[
|
|
new RunErrorEvent { Message = "Error occurred", Code = "ERR001" }
|
|
];
|
|
|
|
// 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);
|
|
ErrorContent content = Assert.IsType<ErrorContent>(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<BaseEvent> 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<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.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<BaseEvent> 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<InvalidOperationException>(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<BaseEvent> events =
|
|
[
|
|
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
|
|
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
|
|
new TextMessageEndEvent { MessageId = "msg2" }
|
|
];
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<InvalidOperationException>(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<BaseEvent> 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<ChatResponseUpdate> 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<BaseEvent> 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<ChatResponseUpdate> 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<FunctionCallContent>(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<BaseEvent> 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<ChatResponseUpdate> updates = [];
|
|
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
|
{
|
|
updates.Add(update);
|
|
}
|
|
|
|
// Assert
|
|
FunctionCallContent functionCall = updates
|
|
.SelectMany(u => u.Contents)
|
|
.OfType<FunctionCallContent>()
|
|
.Single();
|
|
Assert.Equal("value1", functionCall.Arguments!["part1"]?.ToString());
|
|
Assert.Equal("value2", functionCall.Arguments!["part2"]?.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AsChatResponseUpdatesAsync_WithEmptyToolCallArgs_HandlesGracefullyAsync()
|
|
{
|
|
// Arrange
|
|
List<BaseEvent> events =
|
|
[
|
|
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "NoArgsTool", ParentMessageId = "msg1" },
|
|
new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "" },
|
|
new ToolCallEndEvent { ToolCallId = "call_1" }
|
|
];
|
|
|
|
// Act
|
|
List<ChatResponseUpdate> updates = [];
|
|
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
|
{
|
|
updates.Add(update);
|
|
}
|
|
|
|
// Assert
|
|
FunctionCallContent functionCall = updates
|
|
.SelectMany(u => u.Contents)
|
|
.OfType<FunctionCallContent>()
|
|
.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<BaseEvent> 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<InvalidOperationException>(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<BaseEvent> events =
|
|
[
|
|
new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
|
|
new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" } // Wrong call 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 AsChatResponseUpdatesAsync_WithMismatchedToolCallEndId_ThrowsInvalidOperationExceptionAsync()
|
|
{
|
|
// Arrange
|
|
List<BaseEvent> 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<InvalidOperationException>(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<BaseEvent> 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<ChatResponseUpdate> updates = [];
|
|
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
|
{
|
|
updates.Add(update);
|
|
}
|
|
|
|
// Assert
|
|
List<FunctionCallContent> functionCalls = updates
|
|
.SelectMany(u => u.Contents)
|
|
.OfType<FunctionCallContent>()
|
|
.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<BaseEvent> events =
|
|
[
|
|
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
|
new StateSnapshotEvent { Snapshot = stateSnapshot },
|
|
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
|
];
|
|
|
|
// Act
|
|
List<ChatResponseUpdate> 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<DataContent>(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<BaseEvent> events =
|
|
[
|
|
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
|
new StateSnapshotEvent { Snapshot = null },
|
|
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
|
];
|
|
|
|
// Act
|
|
List<ChatResponseUpdate> 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<BaseEvent> events =
|
|
[
|
|
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
|
new StateSnapshotEvent { Snapshot = emptyState },
|
|
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
|
];
|
|
|
|
// Act
|
|
List<ChatResponseUpdate> 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<DataContent>(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<BaseEvent> events =
|
|
[
|
|
new StateSnapshotEvent { Snapshot = stateSnapshot }
|
|
];
|
|
|
|
// Act
|
|
List<ChatResponseUpdate> updates = [];
|
|
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
|
{
|
|
updates.Add(update);
|
|
}
|
|
|
|
// Assert
|
|
ChatResponseUpdate stateUpdate = updates.First();
|
|
DataContent dataContent = Assert.IsType<DataContent>(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<BaseEvent> 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<ChatResponseUpdate> 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<BaseEvent> events =
|
|
[
|
|
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
|
new StateDeltaEvent { Delta = stateDelta },
|
|
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
|
];
|
|
|
|
// Act
|
|
List<ChatResponseUpdate> 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<DataContent>(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<BaseEvent> events =
|
|
[
|
|
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
|
new StateDeltaEvent { Delta = null },
|
|
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
|
];
|
|
|
|
// Act
|
|
List<ChatResponseUpdate> 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<ChatResponseUpdate>(updates[0]); // Run started
|
|
Assert.IsType<ChatResponseUpdate>(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<object>());
|
|
List<BaseEvent> events =
|
|
[
|
|
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
|
|
new StateDeltaEvent { Delta = emptyDelta },
|
|
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
|
|
];
|
|
|
|
// Act
|
|
List<ChatResponseUpdate> 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<BaseEvent> 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<ChatResponseUpdate> 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<ChatResponseUpdate> updates =
|
|
[
|
|
new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
|
|
{
|
|
MessageId = "msg1"
|
|
}
|
|
];
|
|
|
|
// Act
|
|
List<BaseEvent> outputEvents = [];
|
|
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
|
|
{
|
|
outputEvents.Add(evt);
|
|
}
|
|
|
|
// Assert
|
|
StateDeltaEvent? deltaEvent = outputEvents.OfType<StateDeltaEvent>().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<ChatResponseUpdate> updates =
|
|
[
|
|
new ChatResponseUpdate(ChatRole.Assistant, [snapshotContent]) { MessageId = "msg1" },
|
|
new ChatResponseUpdate(ChatRole.Assistant, [deltaContent]) { MessageId = "msg2" }
|
|
];
|
|
|
|
// 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 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<BaseEvent> 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<ChatResponseUpdate> updates = [];
|
|
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
|
|
{
|
|
updates.Add(update);
|
|
}
|
|
|
|
List<BaseEvent> roundTripEvents = [];
|
|
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
|
|
{
|
|
roundTripEvents.Add(evt);
|
|
}
|
|
|
|
// Assert
|
|
StateDeltaEvent? roundTripDelta = roundTripEvents.OfType<StateDeltaEvent>().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<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
|
|
}
|