From fc5dc20f9f1d1daaa368c27ea98ed010f67fd427 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Sat, 20 Jun 2026 14:48:08 +0200 Subject: [PATCH] .NET: Replace internal AG-UI implementation with external ag-ui packages Remove the in-tree Microsoft.Agents.AI.AGUI sources and consume the external AG-UI .NET SDK packages (AGUI.Abstractions, AGUI.Formatting, AGUI.Protobuf, AGUI.Client, AGUI.Server) at 0.1.0-preview instead. - Microsoft.Agents.AI.Hosting.AGUI.AspNetCore keeps its own ASP.NET glue (MapAGUI / AddAGUI / SSE result) layered over the framework-agnostic AGUI.Server primitives (ToChatRequestContext / AsAGUIEventStreamAsync). - Migrate call sites to the options-based AGUIChatClient constructor and recover the originating AG-UI input via ChatOptions.TryGetRunAgentInput. - Multi-turn continuation flows through parentRunId + threadId on RawRepresentationFactory; shared state flows through RunAgentInput.State and is surfaced as StateSnapshotEvent raw representations. - Update samples, hosting/unit/integration tests, and central package versions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 12 +- dotnet/agent-framework-dotnet.slnx | 2 - dotnet/agent-framework-release.slnf | 1 - dotnet/samples/02-agents/AGUI/README.md | 8 +- .../Client/Client.csproj | 4 +- .../Step01_GettingStarted/Client/Program.cs | 15 +- .../Step01_GettingStarted/Server/Program.cs | 6 +- .../Step02_BackendTools/Client/Client.csproj | 4 +- .../Step02_BackendTools/Client/Program.cs | 15 +- .../Step02_BackendTools/Server/Program.cs | 6 +- .../Step03_FrontendTools/Client/Client.csproj | 4 +- .../Step03_FrontendTools/Client/Program.cs | 15 +- .../Step03_FrontendTools/Server/Program.cs | 6 +- .../Step04_HumanInLoop/Client/Client.csproj | 4 +- .../AGUI/Step04_HumanInLoop/Client/Program.cs | 4 +- .../AGUI/Step04_HumanInLoop/Server/Program.cs | 6 +- .../Client/Client.csproj | 4 +- .../Step05_StateManagement/Client/Program.cs | 15 +- .../Step05_StateManagement/Server/Program.cs | 6 +- .../Server/SharedStateAgent.cs | 9 +- .../AGUIClient/AGUIClient.csproj | 2 +- .../AGUIClientServer/AGUIClient/Program.cs | 25 +- .../AGUIDojoServer/Program.cs | 18 +- .../SharedState/SharedStateAgent.cs | 7 +- .../AGUIServer/AGUIServer.csproj | 1 - .../AGUIClientServer/AGUIServer/Program.cs | 6 +- .../05-end-to-end/AGUIClientServer/README.md | 26 +- .../Client/AGUIWebChatClient.csproj | 2 +- .../AGUIWebChat/Client/Program.cs | 6 +- .../05-end-to-end/AGUIWebChat/README.md | 6 +- .../Server/AGUIWebChatServer.csproj | 1 - .../AGUIWebChat/Server/Program.cs | 6 +- .../AGUIChatClient.cs | 379 ---- .../AGUIHttpService.cs | 52 - .../Microsoft.Agents.AI.AGUI.csproj | 33 - .../Shared/AGUIAssistantMessage.cs | 23 - .../Shared/AGUIChatMessageExtensions.cs | 298 --- .../Shared/AGUIContextItem.cs | 18 - .../Shared/AGUIDeveloperMessage.cs | 15 - .../Shared/AGUIEventTypes.cs | 48 - .../Shared/AGUIFunctionCall.cs | 18 - .../Shared/AGUIJsonSerializerContext.cs | 70 - .../Shared/AGUIMessage.cs | 22 - .../Shared/AGUIMessageJsonConverter.cs | 86 - .../Shared/AGUIReasoningMessage.cs | 20 - .../Shared/AGUIRoles.cs | 22 - .../Shared/AGUISystemMessage.cs | 15 - .../Shared/AGUITool.cs | 22 - .../Shared/AGUIToolCall.cs | 21 - .../Shared/AGUIToolMessage.cs | 23 - .../Shared/AGUIUserMessage.cs | 20 - .../Shared/AIToolExtensions.cs | 56 - .../Shared/BaseEvent.cs | 16 - .../Shared/BaseEventJsonConverter.cs | 137 -- .../ChatResponseUpdateAGUIExtensions.cs | 747 ------- .../Shared/ReasoningEncryptedValueEvent.cs | 26 - .../Shared/ReasoningEndEvent.cs | 20 - .../Shared/ReasoningMessageChunkEvent.cs | 25 - .../Shared/ReasoningMessageContentEvent.cs | 23 - .../Shared/ReasoningMessageEndEvent.cs | 20 - .../Shared/ReasoningMessageStartEvent.cs | 23 - .../Shared/ReasoningStartEvent.cs | 20 - .../Shared/RunAgentInput.cs | 38 - .../Shared/RunErrorEvent.cs | 23 - .../Shared/RunFinishedEvent.cs | 27 - .../Shared/RunStartedEvent.cs | 23 - .../Shared/StateDeltaEvent.cs | 21 - .../Shared/StateSnapshotEvent.cs | 21 - .../Shared/TextMessageContentEvent.cs | 23 - .../Shared/TextMessageEndEvent.cs | 20 - .../Shared/TextMessageStartEvent.cs | 23 - .../Shared/ToolCallArgsEvent.cs | 23 - .../Shared/ToolCallEndEvent.cs | 20 - .../Shared/ToolCallResultEvent.cs | 29 - .../Shared/ToolCallStartEvent.cs | 26 - .../AGUIChatResponseUpdateStreamExtensions.cs | 90 - .../AGUIEndpointRouteBuilderExtensions.cs | 84 +- .../AGUIJsonSerializerOptions.cs | 24 - .../AGUIServerSentEventsResult.cs | 29 +- ... AGUIServerServiceCollectionExtensions.cs} | 12 +- .../ConfigureAGUIJsonOptions.cs | 26 + ...t.Agents.AI.Hosting.AGUI.AspNetCore.csproj | 8 +- .../AGUIChatClientTests.cs | 1782 ----------------- .../AGUIChatMessageExtensionsTests.cs | 1060 ---------- .../AGUIHttpServiceTests.cs | 198 -- .../AGUIJsonSerializerContextTests.cs | 1373 ------------- .../AGUIStreamingMessageIdTests.cs | 401 ---- .../AIToolExtensionsTests.cs | 216 -- .../ChatResponseUpdateAGUIExtensionsTests.cs | 1245 ------------ .../Microsoft.Agents.AI.AGUI.UnitTests.csproj | 12 - .../TestHelpers.cs | 21 - .../BasicStreamingTests.cs | 35 +- .../ForwardedPropertiesTests.cs | 16 +- ...ng.AGUI.AspNetCore.IntegrationTests.csproj | 4 +- .../SessionPersistenceTests.cs | 49 +- .../SharedStateTests.cs | 334 ++- .../ToolCallingTests.cs | 50 +- ...AGUIEndpointRouteBuilderExtensionsTests.cs | 596 +----- .../AGUIServerSentEventsResultTests.cs | 6 +- .../ChatResponseUpdateAGUIExtensionsTests.cs | 286 --- ...I.Hosting.AGUI.AspNetCore.UnitTests.csproj | 1 + .../TestHelpers.cs | 4 + 102 files changed, 516 insertions(+), 10333 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/AGUIHttpService.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIAssistantMessage.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIChatMessageExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIContextItem.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIDeveloperMessage.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIFunctionCall.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessage.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessageJsonConverter.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIReasoningMessage.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIRoles.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUISystemMessage.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUITool.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolCall.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolMessage.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIUserMessage.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AIToolExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEncryptedValueEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEndEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageChunkEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageContentEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageEndEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageStartEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningStartEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunErrorEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunFinishedEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunStartedEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateDeltaEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateSnapshotEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageContentEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageEndEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageStartEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallArgsEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallEndEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallResultEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallStartEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIChatResponseUpdateStreamExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIJsonSerializerOptions.cs rename dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/{ServiceCollectionExtensions.cs => AGUIServerServiceCollectionExtensions.cs} (62%) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AIToolExtensionsTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj delete mode 100644 dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/TestHelpers.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 3d30f24e6ee..64768936564 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -1,4 +1,4 @@ - + @@ -52,7 +52,13 @@ - + + + + + + + @@ -88,7 +94,7 @@ - + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index f5bcc57b733..33ea2b71c19 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -600,7 +600,6 @@ - @@ -659,7 +658,6 @@ - diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index f4bf930e454..42facf94889 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -4,7 +4,6 @@ "projects": [ "src\\Microsoft.Agents.AI.A2A\\Microsoft.Agents.AI.A2A.csproj", "src\\Microsoft.Agents.AI.Abstractions\\Microsoft.Agents.AI.Abstractions.csproj", - "src\\Microsoft.Agents.AI.AGUI\\Microsoft.Agents.AI.AGUI.csproj", "src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj", "src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj", "src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj", diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md index 77a58b3198c..b95293566d0 100644 --- a/dotnet/samples/02-agents/AGUI/README.md +++ b/dotnet/samples/02-agents/AGUI/README.md @@ -35,7 +35,7 @@ A basic AG-UI server and client that demonstrate the foundational concepts. A basic AG-UI server that hosts an AI agent accessible via HTTP. Demonstrates: - Creating an ASP.NET Core web application -- Setting up an AG-UI server endpoint with `MapAGUI` +- Setting up an AG-UI server endpoint with `MapAGUIServer` - Creating an AI agent from an Azure OpenAI chat client - Streaming responses via Server-Sent Events (SSE) @@ -204,7 +204,7 @@ dotnet run ### Server-Side 1. Client sends HTTP POST request with messages -2. ASP.NET Core endpoint receives the request via `MapAGUI` +2. ASP.NET Core endpoint receives the request via `MapAGUIServer` 3. Agent processes messages using Agent Framework 4. Responses are streamed back as Server-Sent Events (SSE) @@ -214,14 +214,14 @@ dotnet run 2. Server responds with SSE stream 3. Client parses events into `AgentResponseUpdate` objects 4. Updates are displayed based on content type -5. `ConversationId` maintains conversation context +5. The client sends the full message history each turn (the stateless AG-UI client does not rely on a server-assigned `ConversationId`) ### Protocol Features - **HTTP POST** for requests - **Server-Sent Events (SSE)** for streaming responses - **JSON** for event serialization -- **Thread IDs** (as `ConversationId`) for conversation context +- **Thread IDs** (read from the `RUN_STARTED` event's raw representation) for conversation context. `AGUIChatClient` is stateless and intentionally does not surface a `ConversationId`. - **Run IDs** (as `ResponseId`) for tracking individual executions ## Troubleshooting diff --git a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Client.csproj index a76a2b37efd..8d226ce8168 100644 --- a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Client.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Program.cs index cff6cbbfde1..bb18aa9a959 100644 --- a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Client/Program.cs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using AGUI.Abstractions; +using AGUI.Client; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; @@ -14,7 +15,7 @@ Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(httpClient, serverUrl); +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); AIAgent agent = chatClient.AsAIAgent( name: "agui-client", @@ -49,7 +50,7 @@ // Stream the response bool isFirstUpdate = true; - string? sessionId = null; + string? threadId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) { @@ -58,9 +59,11 @@ // First update indicates run started if (isFirstUpdate) { - sessionId = chatUpdate.ConversationId; + // AGUIChatClient is stateless and never surfaces a ConversationId; the thread + // id is carried on the AG-UI RUN_STARTED event's raw representation. + threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId; Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"\n[Run Started - Session: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]"); + Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } @@ -84,7 +87,7 @@ } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Session: {sessionId}]"); + Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); Console.ResetColor(); } } diff --git a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs index 0981ece7899..0c523554ae1 100644 --- a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Azure.AI.OpenAI; using Azure.Identity; @@ -8,7 +8,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, // make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user @@ -36,6 +36,6 @@ instructions: "You are a helpful assistant."); // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUIServer("/", agent); await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Client.csproj index a76a2b37efd..8d226ce8168 100644 --- a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Client.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Program.cs index 203a2a0802c..e6a8e834fbc 100644 --- a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Client/Program.cs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using AGUI.Abstractions; +using AGUI.Client; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; @@ -14,7 +15,7 @@ Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(httpClient, serverUrl); +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); AIAgent agent = chatClient.AsAIAgent( name: "agui-client", @@ -49,7 +50,7 @@ // Stream the response bool isFirstUpdate = true; - string? sessionId = null; + string? threadId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) { @@ -58,9 +59,11 @@ // First update indicates run started if (isFirstUpdate) { - sessionId = chatUpdate.ConversationId; + // AGUIChatClient is stateless and never surfaces a ConversationId; the thread + // id is carried on the AG-UI RUN_STARTED event's raw representation. + threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId; Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"\n[Run Started - Session: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]"); + Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } @@ -116,7 +119,7 @@ } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Session: {sessionId}]"); + Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); Console.ResetColor(); } } diff --git a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs index 53b680c8613..ff2528274a0 100644 --- a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.ComponentModel; using System.Text.Json.Serialization; @@ -14,7 +14,7 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default)); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, // make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user @@ -93,7 +93,7 @@ static RestaurantSearchResponse SearchRestaurants( tools: tools); // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUIServer("/", agent); await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Client.csproj index a76a2b37efd..8d226ce8168 100644 --- a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Client.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Program.cs index 7f3806a7215..5b01e70ded5 100644 --- a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Client/Program.cs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System.ComponentModel; +using AGUI.Abstractions; +using AGUI.Client; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888"; @@ -26,7 +27,7 @@ static string GetUserLocation() Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(httpClient, serverUrl); +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); AIAgent agent = chatClient.AsAIAgent( name: "agui-client", @@ -62,7 +63,7 @@ static string GetUserLocation() // Stream the response bool isFirstUpdate = true; - string? sessionId = null; + string? threadId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session)) { @@ -71,9 +72,11 @@ static string GetUserLocation() // First update indicates run started if (isFirstUpdate) { - sessionId = chatUpdate.ConversationId; + // AGUIChatClient is stateless and never surfaces a ConversationId; the thread + // id is carried on the AG-UI RUN_STARTED event's raw representation. + threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId; Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"\n[Run Started - Session: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]"); + Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } @@ -109,7 +112,7 @@ static string GetUserLocation() } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Session: {sessionId}]"); + Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); Console.ResetColor(); } } diff --git a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs index 0981ece7899..0c523554ae1 100644 --- a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Azure.AI.OpenAI; using Azure.Identity; @@ -8,7 +8,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, // make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user @@ -36,6 +36,6 @@ instructions: "You are a helpful assistant."); // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUIServer("/", agent); await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Client.csproj index a76a2b37efd..8d226ce8168 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Client.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs index 0c3e75cf96e..788ad4fb3d3 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text.Json; +using AGUI.Client; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; using Microsoft.Extensions.AI; string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:5100"; @@ -13,7 +13,7 @@ Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(httpClient, serverUrl); +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); // Create agent ChatClientAgent baseAgent = chatClient.AsAIAgent( diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs index 88967acb993..424eb5e3919 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.ComponentModel; using Azure.AI.OpenAI; @@ -25,7 +25,7 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default)); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, // make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user @@ -73,5 +73,5 @@ static string ApproveExpenseReport(string expenseReportId) // Wrap with ServerFunctionApprovalAgent var agent = new ServerFunctionApprovalAgent(baseAgent, jsonOptions.SerializerOptions); -app.MapAGUI("/", agent); +app.MapAGUIServer("/", agent); await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj index a76a2b37efd..8d226ce8168 100644 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,7 @@ - + diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Program.cs index a358956ce81..fbda62cc92a 100644 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Client/Program.cs @@ -2,8 +2,9 @@ using System.Text.Json; using System.Text.Json.Serialization; +using AGUI.Abstractions; +using AGUI.Client; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; using Microsoft.Extensions.AI; using RecipeClient; @@ -17,7 +18,7 @@ Timeout = TimeSpan.FromSeconds(60) }; -AGUIChatClient chatClient = new(httpClient, serverUrl); +AGUIChatClient chatClient = new(new(httpClient, serverUrl)); AIAgent baseAgent = chatClient.AsAIAgent( name: "recipe-client", @@ -65,7 +66,7 @@ // Stream the response bool isFirstUpdate = true; - string? sessionId = null; + string? threadId = null; bool stateReceived = false; Console.WriteLine(); @@ -77,9 +78,11 @@ // First update indicates run started if (isFirstUpdate) { - sessionId = chatUpdate.ConversationId; + // AGUIChatClient is stateless and never surfaces a ConversationId; the thread + // id is carried on the AG-UI RUN_STARTED event's raw representation. + threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId; Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"[Run Started - Session: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]"); + Console.WriteLine($"[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } @@ -113,7 +116,7 @@ } Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"\n[Run Finished - Session: {sessionId}]"); + Console.WriteLine($"\n[Run Finished - Thread: {threadId}]"); Console.ResetColor(); // Display final state if received diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs index 67a6889fb10..5ccee137481 100644 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Azure.AI.OpenAI; using Azure.Identity; @@ -12,7 +12,7 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(RecipeSerializerContext.Default)); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); // Configure to listen on port 8888 builder.WebHost.UseUrls("http://localhost:8888"); @@ -61,6 +61,6 @@ Always include all fields in the response. Be creative and helpful. AIAgent agent = new SharedStateAgent(baseAgent, jsonOptions.SerializerOptions); // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUIServer("/", agent); await app.RunAsync(); diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs index 17bde7d2154..032ce5976ec 100644 --- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs +++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs @@ -2,6 +2,8 @@ using System.Runtime.CompilerServices; using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Server; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -34,10 +36,9 @@ protected override async IAsyncEnumerable RunCoreStreamingA [EnumeratorCancellation] CancellationToken cancellationToken = default) { // Check if the client sent state in the request - if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions || - !properties.TryGetValue("ag_ui_state", out object? stateObj) || - stateObj is not JsonElement state || - state.ValueKind != JsonValueKind.Object) + if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions } chatRunOptions || + !chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) || + agentInput.State is not { ValueKind: JsonValueKind.Object } state) { // No state management requested, pass through to inner agent await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIClient/AGUIClient.csproj b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIClient/AGUIClient.csproj index 8a45c09ce08..459a523a520 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIClient/AGUIClient.csproj +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIClient/AGUIClient.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIClient/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIClient/Program.cs index 1e5b6d6fee6..d98566c7839 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIClient/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIClient/Program.cs @@ -7,8 +7,9 @@ using System.ComponentModel; using System.Reflection; using System.Text; +using AGUI.Abstractions; +using AGUI.Client; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.AGUI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -78,10 +79,10 @@ private static async Task HandleCommandsAsync(CancellationToken cancellationToke serializerOptions: AGUIClientSerializerContext.Default.Options ); - var chatClient = new AGUIChatClient( - httpClient, - serverUrl, - jsonSerializerOptions: AGUIClientSerializerContext.Default.Options); + var chatClient = new AGUIChatClient(new(httpClient, serverUrl) + { + JsonSerializerOptions = AGUIClientSerializerContext.Default.Options, + }); AIAgent agent = chatClient.AsAIAgent( name: "agui-client", @@ -112,23 +113,25 @@ private static async Task HandleCommandsAsync(CancellationToken cancellationToke // Call RunStreamingAsync to get streaming updates bool isFirstUpdate = true; - string? sessionId = null; + string? threadId = null; var updates = new List(); await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session, cancellationToken: cancellationToken)) { // Use AsChatResponseUpdate to access ChatResponseUpdate properties ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate(); updates.Add(chatUpdate); - if (chatUpdate.ConversationId != null) + // AGUIChatClient is stateless and never surfaces a ConversationId; the thread + // id is carried on the AG-UI RUN_STARTED event's raw representation. + if (chatUpdate.RawRepresentation is RunStartedEvent runStarted) { - sessionId = chatUpdate.ConversationId; + threadId = runStarted.ThreadId; } // Display run started information from the first update - if (isFirstUpdate && sessionId != null && update.ResponseId != null) + if (isFirstUpdate && threadId != null && update.ResponseId != null) { Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"\n[Run Started - Session: {sessionId}, Run: {update.ResponseId}]"); + Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {update.ResponseId}]"); Console.ResetColor(); isFirstUpdate = false; } @@ -177,7 +180,7 @@ private static async Task HandleCommandsAsync(CancellationToken cancellationToke var lastUpdate = updates[^1]; Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine(); - Console.WriteLine($"[Run Ended - Session: {sessionId}, Run: {lastUpdate.ResponseId}]"); + Console.WriteLine($"[Run Ended - Thread: {threadId}, Run: {lastUpdate.ResponseId}]"); Console.ResetColor(); } messages.Clear(); diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs index 3f0032d4da9..298ba3a2174 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using AGUIDojoServer; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; @@ -17,7 +17,7 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default)); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, // make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user @@ -32,20 +32,20 @@ ChatClientAgentFactory.Initialize(app.Configuration); // Map the AG-UI agent endpoints for different scenarios -app.MapAGUI("/agentic_chat", ChatClientAgentFactory.CreateAgenticChat()); +app.MapAGUIServer("/agentic_chat", ChatClientAgentFactory.CreateAgenticChat()); -app.MapAGUI("/backend_tool_rendering", ChatClientAgentFactory.CreateBackendToolRendering()); +app.MapAGUIServer("/backend_tool_rendering", ChatClientAgentFactory.CreateBackendToolRendering()); -app.MapAGUI("/human_in_the_loop", ChatClientAgentFactory.CreateHumanInTheLoop()); +app.MapAGUIServer("/human_in_the_loop", ChatClientAgentFactory.CreateHumanInTheLoop()); -app.MapAGUI("/tool_based_generative_ui", ChatClientAgentFactory.CreateToolBasedGenerativeUI()); +app.MapAGUIServer("/tool_based_generative_ui", ChatClientAgentFactory.CreateToolBasedGenerativeUI()); var jsonOptions = app.Services.GetRequiredService>(); -app.MapAGUI("/agentic_generative_ui", ChatClientAgentFactory.CreateAgenticUI(jsonOptions.Value.SerializerOptions)); +app.MapAGUIServer("/agentic_generative_ui", ChatClientAgentFactory.CreateAgenticUI(jsonOptions.Value.SerializerOptions)); -app.MapAGUI("/shared_state", ChatClientAgentFactory.CreateSharedState(jsonOptions.Value.SerializerOptions)); +app.MapAGUIServer("/shared_state", ChatClientAgentFactory.CreateSharedState(jsonOptions.Value.SerializerOptions)); -app.MapAGUI("/predictive_state_updates", ChatClientAgentFactory.CreatePredictiveStateUpdates(jsonOptions.Value.SerializerOptions)); +app.MapAGUIServer("/predictive_state_updates", ChatClientAgentFactory.CreatePredictiveStateUpdates(jsonOptions.Value.SerializerOptions)); await app.RunAsync(); diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs index af1a54b1031..2e4bbf40e39 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs @@ -3,6 +3,8 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text.Json; +using AGUI.Abstractions; +using AGUI.Server; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -30,8 +32,9 @@ protected override async IAsyncEnumerable RunCoreStreamingA AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions || - !properties.TryGetValue("ag_ui_state", out JsonElement state)) + if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions } chatRunOptions || + !chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) || + agentInput.State is not { ValueKind: not JsonValueKind.Undefined } state) { await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) { diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj index c26e3eebad4..8aa91a3406b 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj @@ -15,7 +15,6 @@ - diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs index 575924255a1..5d6a9dd083c 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.ComponentModel; using AGUIServer; @@ -12,7 +12,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default)); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); @@ -63,6 +63,6 @@ WebApplication app = builder.Build(); // Map the AG-UI agent endpoint -app.MapAGUI(AgentName, "/"); +app.MapAGUIServer(AgentName, "/"); await app.RunAsync(); diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md index 788ae93d7d2..0501ec54694 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md @@ -114,7 +114,7 @@ User (:q or quit to exit): :q ### Server Side -The `AGUIServer` uses the `MapAGUI` extension method to expose an agent through the AG-UI protocol: +The `AGUIServer` uses the `MapAGUIServer` extension method to expose an agent through the AG-UI protocol: ```csharp AIAgent agent = new OpenAIClient(apiKey) @@ -123,7 +123,7 @@ AIAgent agent = new OpenAIClient(apiKey) instructions: "You are a helpful assistant.", name: "AGUIAssistant"); -app.MapAGUI("/", agent); +app.MapAGUIServer("/", agent); ``` This automatically handles: @@ -138,11 +138,7 @@ The `AGUIClient` uses the `AGUIChatClient` to connect to the remote server: ```csharp using HttpClient httpClient = new(); -var chatClient = new AGUIChatClient( - httpClient, - endpoint: serverUrl, - modelId: "agui-client", - jsonSerializerOptions: null); +var chatClient = new AGUIChatClient(new(httpClient, serverUrl)); AIAgent agent = chatClient.AsAIAgent( instructions: null, @@ -152,13 +148,21 @@ AIAgent agent = chatClient.AsAIAgent( bool isFirstUpdate = true; AgentResponseUpdate? currentUpdate = null; +string? threadId = null; await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, thread)) { + // AGUIChatClient is stateless and never surfaces a ConversationId; the thread id is + // carried on the AG-UI RUN_STARTED event's raw representation. + if (update.AsChatResponseUpdate().RawRepresentation is RunStartedEvent runStarted) + { + threadId = runStarted.ThreadId; + } + // First update indicates run started if (isFirstUpdate) { - Console.WriteLine($"[Run Started - Thread: {update.ConversationId}, Run: {update.ResponseId}]"); + Console.WriteLine($"[Run Started - Thread: {threadId}, Run: {update.ResponseId}]"); isFirstUpdate = false; } @@ -183,7 +187,7 @@ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, t // Last update indicates run finished if (currentUpdate != null) { - Console.WriteLine($"\n[Run Finished - Thread: {currentUpdate.ConversationId}, Run: {currentUpdate.ResponseId}]"); + Console.WriteLine($"\n[Run Finished - Thread: {threadId}, Run: {currentUpdate.ResponseId}]"); } ``` @@ -195,11 +199,11 @@ The `RunStreamingAsync` method: ## Key Concepts -- **Thread**: Represents a conversation context that persists across multiple runs (accessed via `ConversationId` property) +- **Thread**: Represents a conversation context that persists across multiple runs. `AGUIChatClient` is stateless and does not surface a `ConversationId`; the thread id is read from the `RUN_STARTED`/`RUN_FINISHED` event's raw representation (`RunStartedEvent.ThreadId`). Continuation is driven by resending the full message history (and, to branch from a prior run, setting `RunAgentInput.ThreadId`/`ParentRunId` via `ChatOptions.RawRepresentationFactory`). - **Run**: A single execution of the agent for a given set of messages (identified by `ResponseId` property) - **AgentResponseUpdate**: Contains the response data with: - `ResponseId`: The unique run identifier - - `ConversationId`: The thread/conversation identifier + - `RawRepresentation`: The underlying AG-UI event (e.g. `RunStartedEvent`), which carries wire-level fields such as the thread id - `Contents`: Collection of content items (TextContent, ErrorContent, etc.) - **Run Lifecycle**: - The **first** `AgentResponseUpdate` in a run indicates the run has started diff --git a/dotnet/samples/05-end-to-end/AGUIWebChat/Client/AGUIWebChatClient.csproj b/dotnet/samples/05-end-to-end/AGUIWebChat/Client/AGUIWebChatClient.csproj index fef0deb3ec3..21f128c05e3 100644 --- a/dotnet/samples/05-end-to-end/AGUIWebChat/Client/AGUIWebChatClient.csproj +++ b/dotnet/samples/05-end-to-end/AGUIWebChat/Client/AGUIWebChatClient.csproj @@ -8,7 +8,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/AGUIWebChat/Client/Program.cs b/dotnet/samples/05-end-to-end/AGUIWebChat/Client/Program.cs index ff5d1cacd76..c572ee87634 100644 --- a/dotnet/samples/05-end-to-end/AGUIWebChat/Client/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIWebChat/Client/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using AGUI.Client; using AGUIWebChatClient.Components; -using Microsoft.Agents.AI.AGUI; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -13,8 +13,8 @@ builder.Services.AddHttpClient("aguiserver", httpClient => httpClient.BaseAddress = new Uri(serverUrl)); -builder.Services.AddChatClient(sp => new AGUIChatClient( - sp.GetRequiredService().CreateClient("aguiserver"), "ag-ui")); +builder.Services.AddChatClient(sp => new AGUIChatClient(new( + sp.GetRequiredService().CreateClient("aguiserver"), "ag-ui"))); WebApplication app = builder.Build(); diff --git a/dotnet/samples/05-end-to-end/AGUIWebChat/README.md b/dotnet/samples/05-end-to-end/AGUIWebChat/README.md index 96a78e80ac0..78e30803115 100644 --- a/dotnet/samples/05-end-to-end/AGUIWebChat/README.md +++ b/dotnet/samples/05-end-to-end/AGUIWebChat/README.md @@ -79,7 +79,7 @@ ChatClientAgent agent = chatClient.AsAIAgent( instructions: "You are a helpful assistant."); // Map AG-UI endpoint -app.MapAGUI("/ag-ui", agent); +app.MapAGUIServer("/ag-ui", agent); ``` The server exposes the agent via the AG-UI protocol at `http://localhost:5100/ag-ui`. @@ -93,8 +93,8 @@ string serverUrl = builder.Configuration["AGUI_SERVER_URL"] ?? "http://localhost builder.Services.AddHttpClient("aguiserver", httpClient => httpClient.BaseAddress = new Uri(serverUrl)); -builder.Services.AddChatClient(sp => new AGUIChatClient( - sp.GetRequiredService().CreateClient("aguiserver"), "ag-ui")); +builder.Services.AddChatClient(sp => new AGUIChatClient(new( + sp.GetRequiredService().CreateClient("aguiserver"), "ag-ui"))); ``` The Blazor UI (`Client/Components/Pages/Chat/Chat.razor`) uses the `IChatClient` to: diff --git a/dotnet/samples/05-end-to-end/AGUIWebChat/Server/AGUIWebChatServer.csproj b/dotnet/samples/05-end-to-end/AGUIWebChat/Server/AGUIWebChatServer.csproj index 8d440791734..267ab5e4158 100644 --- a/dotnet/samples/05-end-to-end/AGUIWebChat/Server/AGUIWebChatServer.csproj +++ b/dotnet/samples/05-end-to-end/AGUIWebChat/Server/AGUIWebChatServer.csproj @@ -15,7 +15,6 @@ - diff --git a/dotnet/samples/05-end-to-end/AGUIWebChat/Server/Program.cs b/dotnet/samples/05-end-to-end/AGUIWebChat/Server/Program.cs index 06a138b8c3e..3e3fc37432a 100644 --- a/dotnet/samples/05-end-to-end/AGUIWebChat/Server/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIWebChat/Server/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. // This sample demonstrates a basic AG-UI server hosting a chat agent for the Blazor web client. @@ -10,7 +10,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); -builder.Services.AddAGUI(); +builder.Services.AddAGUIServer(); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, // make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user @@ -37,6 +37,6 @@ instructions: "You are a helpful assistant."); // Map the AG-UI agent endpoint -app.MapAGUI("/ag-ui", agent); +app.MapAGUIServer("/ag-ui", agent); await app.RunAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs deleted file mode 100644 index ddaf6bd5929..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs +++ /dev/null @@ -1,379 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.AGUI.Shared; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.AGUI; - -/// -/// Provides an implementation that communicates with an AG-UI compliant server. -/// -public sealed class AGUIChatClient : DelegatingChatClient -{ - /// - /// Initializes a new instance of the class. - /// - /// The HTTP client to use for communication with the AG-UI server. - /// The URL for the AG-UI server. - /// The to use for logging. - /// JSON serializer options for tool call argument serialization. If null, AGUIJsonSerializerContext.Default.Options will be used. - /// Optional service provider for resolving dependencies like ILogger. - public AGUIChatClient( - HttpClient httpClient, - string endpoint, - ILoggerFactory? loggerFactory = null, - JsonSerializerOptions? jsonSerializerOptions = null, - IServiceProvider? serviceProvider = null) : base(CreateInnerClient( - httpClient, - endpoint, - CombineJsonSerializerOptions(jsonSerializerOptions), - loggerFactory, - serviceProvider)) - { - } - - private static JsonSerializerOptions CombineJsonSerializerOptions(JsonSerializerOptions? jsonSerializerOptions) - { - if (jsonSerializerOptions == null) - { - return AGUIJsonSerializerContext.Default.Options; - } - - // Create a new JsonSerializerOptions based on the provided one - var combinedOptions = new JsonSerializerOptions(jsonSerializerOptions); - - // Add the AGUI context to the type info resolver chain if not already present - if (!combinedOptions.TypeInfoResolverChain.Any(r => r == AGUIJsonSerializerContext.Default)) - { - combinedOptions.TypeInfoResolverChain.Insert(0, AGUIJsonSerializerContext.Default); - } - - return combinedOptions; - } - - private static FunctionInvokingChatClient CreateInnerClient( - HttpClient httpClient, - string endpoint, - JsonSerializerOptions jsonSerializerOptions, - ILoggerFactory? loggerFactory, - IServiceProvider? serviceProvider) - { - Throw.IfNull(httpClient); - Throw.IfNull(endpoint); - var handler = new AGUIChatClientHandler(httpClient, endpoint, jsonSerializerOptions, serviceProvider); - return new FunctionInvokingChatClient(handler, loggerFactory, serviceProvider); - } - - /// - public override Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => - this.GetStreamingResponseAsync(messages, options, cancellationToken) - .ToChatResponseAsync(cancellationToken); - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ChatResponseUpdate? firstUpdate = null; - string? conversationId = null; - // AG-UI requires the full message history on every turn, so we clear the conversation id here - // and restore it for the caller. - var innerOptions = options; - if (options?.ConversationId != null) - { - conversationId = options.ConversationId; - - // Clone the options and set the conversation ID to null so the FunctionInvokingChatClient doesn't see it. - innerOptions = options.Clone(); - innerOptions.AdditionalProperties ??= []; - innerOptions.AdditionalProperties["agui_thread_id"] = options.ConversationId; - innerOptions.ConversationId = null; - } - - await foreach (var update in base.GetStreamingResponseAsync(messages, innerOptions, cancellationToken).ConfigureAwait(false)) - { - if (conversationId == null && firstUpdate == null) - { - firstUpdate = update; - if (firstUpdate.AdditionalProperties?.TryGetValue("agui_thread_id", out string? threadId) is true) - { - // Capture the session id from the first update to use as conversation id if none was provided - conversationId = threadId; - } - } - - // Cleanup any temporary approach we used by the handler to avoid issues with FunctionInvokingChatClient - for (var i = 0; i < update.Contents.Count; i++) - { - var content = update.Contents[i]; - if (content is FunctionCallContent functionCallContent) - { - functionCallContent.AdditionalProperties?.Remove("agui_thread_id"); - } - if (content is ServerFunctionCallContent serverFunctionCallContent) - { - update.Contents[i] = serverFunctionCallContent.FunctionCallContent; - } - } - - var finalUpdate = CopyResponseUpdate(update); - - finalUpdate.ConversationId = conversationId; - yield return finalUpdate; - } - } - - private static ChatResponseUpdate CopyResponseUpdate(ChatResponseUpdate source) - { - return new ChatResponseUpdate - { - AuthorName = source.AuthorName, - Role = source.Role, - Contents = source.Contents, - RawRepresentation = source.RawRepresentation, - AdditionalProperties = source.AdditionalProperties, - ResponseId = source.ResponseId, - MessageId = source.MessageId, - CreatedAt = source.CreatedAt, - }; - } - - private sealed class AGUIChatClientHandler : IChatClient - { - private static readonly MediaTypeHeaderValue s_json = new("application/json"); - - private readonly AGUIHttpService _httpService; - private readonly JsonSerializerOptions _jsonSerializerOptions; - private readonly ILogger _logger; - - public AGUIChatClientHandler( - HttpClient httpClient, - string endpoint, - JsonSerializerOptions? jsonSerializerOptions, - IServiceProvider? serviceProvider) - { - this._httpService = new AGUIHttpService(httpClient, endpoint); - this._jsonSerializerOptions = jsonSerializerOptions ?? AGUIJsonSerializerContext.Default.Options; - this._logger = serviceProvider?.GetService(typeof(ILogger)) as ILogger ?? NullLogger.Instance; - - // Use BaseAddress if endpoint is empty, otherwise parse as relative or absolute - Uri metadataUri = string.IsNullOrEmpty(endpoint) && httpClient.BaseAddress is not null - ? httpClient.BaseAddress - : new Uri(endpoint, UriKind.RelativeOrAbsolute); - this.Metadata = new ChatClientMetadata("ag-ui", metadataUri, null); - } - - public ChatClientMetadata Metadata { get; } - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - return this.GetStreamingResponseAsync(messages, options, cancellationToken) - .ToChatResponseAsync(cancellationToken); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - if (messages is null) - { - throw new ArgumentNullException(nameof(messages)); - } - - var runId = $"run_{Guid.NewGuid():N}"; - var messagesList = messages.ToList(); // Avoid triggering the enumerator multiple times. - var threadId = ExtractTemporaryThreadId(messagesList) ?? - ExtractThreadIdFromOptions(options) ?? $"thread_{Guid.NewGuid():N}"; - - // Extract state from the last message if it contains DataContent with application/json - JsonElement state = this.ExtractAndRemoveStateFromMessages(messagesList); - - // Create the input for the AGUI service - var input = new RunAgentInput - { - // AG-UI requires a thread ID to work, but for FunctionInvokingChatClient that - // implies the underlying client is managing the history. - ThreadId = threadId, - RunId = runId, - Messages = messagesList.AsAGUIMessages(this._jsonSerializerOptions), - State = state, - }; - - // Add tools if provided - if (options?.Tools is { Count: > 0 }) - { - input.Tools = options.Tools.AsAGUITools(); - - if (this._logger.IsEnabled(LogLevel.Debug)) - { - this._logger.LogDebug("[AGUIChatClient] Tool count: {ToolCount}", options.Tools.Count); - } - } - - var clientToolSet = new HashSet(); - foreach (var tool in options?.Tools ?? []) - { - clientToolSet.Add(tool.Name); - } - - ChatResponseUpdate? firstUpdate = null; - await foreach (var update in this._httpService.PostRunAsync(input, cancellationToken) - .AsChatResponseUpdatesAsync(this._jsonSerializerOptions, cancellationToken).ConfigureAwait(false)) - { - if (firstUpdate == null) - { - firstUpdate = update; - if (!string.IsNullOrEmpty(firstUpdate.ConversationId) && !string.Equals(firstUpdate.ConversationId, threadId, StringComparison.Ordinal)) - { - threadId = firstUpdate.ConversationId; - } - firstUpdate.AdditionalProperties ??= []; - firstUpdate.AdditionalProperties["agui_thread_id"] = threadId; - } - - if (update.Contents is { Count: 1 } && update.Contents[0] is FunctionCallContent fcc) - { - if (clientToolSet.Contains(fcc.Name)) - { - // Prepare to let the wrapping FunctionInvokingChatClient handle this function call. - // We want to retain the original thread id that either the server sent us or that we set - // in this turn on the next turn, but we can't make it visible to FunctionInvokeingChatClient - // because it would then not send the full history on the next turn as required by AG-UI. - // We store it on additional properties of the function call content, which will be passed down - // in the next turn. - fcc.AdditionalProperties ??= []; - fcc.AdditionalProperties["agui_thread_id"] = threadId; - } - else - { - // Hide the server result call from the FunctionInvokingChatClient. - // The wrapping client will unwrap it and present it as a normal function result. - update.Contents[0] = new ServerFunctionCallContent(fcc); - } - } - - // Remove the conversation id before yielding so that the wrapping FunctionInvokingChatClient - // sends the whole message history on every turn as per AG-UI requirements. - update.ConversationId = null; - yield return update; - } - } - - // Extract the session id from the options additional properties - private static string? ExtractThreadIdFromOptions(ChatOptions? options) - { - if (options?.AdditionalProperties is null || - !options.AdditionalProperties.TryGetValue("agui_thread_id", out string? threadId) || - string.IsNullOrEmpty(threadId)) - { - return null; - } - return threadId; - } - - // Extract the session id from the second last message's function call content additional properties - private static string? ExtractTemporaryThreadId(List messagesList) - { - if (messagesList.Count < 2) - { - return null; - } - var functionCall = messagesList[messagesList.Count - 2]; - if (functionCall.Contents.Count < 1 || functionCall.Contents[0] is not FunctionCallContent content) - { - return null; - } - - if (content.AdditionalProperties is null || - !content.AdditionalProperties.TryGetValue("agui_thread_id", out string? threadId) || - string.IsNullOrEmpty(threadId)) - { - return null; - } - - return threadId; - } - - // Extract state from the last message's DataContent with application/json media type - // and remove that message from the list - private JsonElement ExtractAndRemoveStateFromMessages(List messagesList) - { - if (messagesList.Count == 0) - { - return default; - } - - // Check the last message for state DataContent - ChatMessage lastMessage = messagesList[messagesList.Count - 1]; - for (int i = 0; i < lastMessage.Contents.Count; i++) - { - if (lastMessage.Contents[i] is DataContent dataContent && - MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && - mediaType.Equals(s_json)) - { - // Deserialize the state JSON directly from UTF-8 bytes - try - { - JsonElement stateElement = (JsonElement)JsonSerializer.Deserialize( - dataContent.Data.Span, - this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))!; - - // Remove the DataContent from the message contents - lastMessage.Contents.RemoveAt(i); - - // If no contents remain, remove the entire message - if (lastMessage.Contents.Count == 0) - { - messagesList.RemoveAt(messagesList.Count - 1); - } - - return stateElement; - } - catch (JsonException ex) - { - throw new InvalidOperationException($"Failed to deserialize state JSON from DataContent: {ex.Message}", ex); - } - } - } - - return default; - } - - public void Dispose() - { - // No resources to dispose - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - if (serviceType == typeof(ChatClientMetadata)) - { - return this.Metadata; - } - - return null; - } - } - - private sealed class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent - { - public FunctionCallContent FunctionCallContent { get; } = functionCall; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIHttpService.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIHttpService.cs deleted file mode 100644 index b81a933e928..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIHttpService.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Net.Http.Json; -using System.Net.ServerSentEvents; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.AGUI.Shared; - -namespace Microsoft.Agents.AI.AGUI; - -internal sealed class AGUIHttpService(HttpClient client, string endpoint) -{ - public async IAsyncEnumerable PostRunAsync( - RunAgentInput input, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - using HttpRequestMessage request = new(HttpMethod.Post, endpoint) - { - Content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput) - }; - - using HttpResponseMessage response = await client.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken).ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - -#if NET - Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); -#else - Stream responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); -#endif - var items = SseParser.Create(responseStream, ItemParser).EnumerateAsync(cancellationToken); - await foreach (var sseItem in items.ConfigureAwait(false)) - { - yield return sseItem.Data; - } - } - - private static BaseEvent ItemParser(string type, ReadOnlySpan data) - { - return JsonSerializer.Deserialize(data, AGUIJsonSerializerContext.Default.BaseEvent) ?? - throw new InvalidOperationException("Failed to deserialize SSE item."); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj deleted file mode 100644 index 7fbcfa5237c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - preview - - - - - - true - - - - - Microsoft Agent Framework AG-UI - Provides Microsoft Agent Framework support for Agent-User Interaction (AG-UI) protocol client functionality. - - - - - - - - - - - - - - - - - diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIAssistantMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIAssistantMessage.cs deleted file mode 100644 index 4bf1fdfef48..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIAssistantMessage.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 AGUIAssistantMessage : AGUIMessage -{ - public AGUIAssistantMessage() - { - this.Role = AGUIRoles.Assistant; - } - - [JsonPropertyName("name")] - public string? Name { get; set; } - - [JsonPropertyName("toolCalls")] - public AGUIToolCall[]? ToolCalls { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIChatMessageExtensions.cs deleted file mode 100644 index 5a8bc021f89..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIChatMessageExtensions.cs +++ /dev/null @@ -1,298 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using Microsoft.Extensions.AI; - -#if ASPNETCORE -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -#else -namespace Microsoft.Agents.AI.AGUI.Shared; -#endif - -internal static class AGUIChatMessageExtensions -{ - private static readonly ChatRole s_developerChatRole = new("developer"); - - public static IEnumerable AsChatMessages( - this IEnumerable aguiMessages, - JsonSerializerOptions jsonSerializerOptions) - { - // Coalesce consecutive AGUIAssistantMessages that carry tool_calls into a single - // ChatMessage. The AG-UI client (e.g. @ag-ui/client) creates a separate assistant - // message per tool call when ToolCallStartEvent.parentMessageId is empty, but - // OpenAI's chat-completion API requires every assistant message with tool_calls - // to be IMMEDIATELY followed by tool responses for each of its tool_call_ids. - // Sending two consecutive single-tool-call assistant messages before any tool - // result triggers HTTP 400 "tool_call_ids did not have response messages". - List? pendingContents = null; - string? pendingId = null; - - foreach (var message in aguiMessages) - { - bool isAssistantWithToolCalls = - message is AGUIAssistantMessage am && am.ToolCalls is { Length: > 0 }; - - if (pendingContents is not null && !isAssistantWithToolCalls) - { - yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId }; - pendingContents = null; - pendingId = null; - } - - var role = MapChatRole(message.Role); - - switch (message) - { - case AGUIToolMessage toolMessage: - { - object? result; - if (string.IsNullOrEmpty(toolMessage.Content)) - { - result = toolMessage.Content; - } - else - { - // Try to deserialize as JSON, but fall back to string if it fails - try - { - result = JsonSerializer.Deserialize(toolMessage.Content, AGUIJsonSerializerContext.Default.JsonElement); - } - catch (JsonException) - { - result = toolMessage.Content; - } - } - - yield return new ChatMessage( - role, - [ - new FunctionResultContent( - toolMessage.ToolCallId, - result) - ]); - break; - } - - case AGUIReasoningMessage reasoningMessage: - { - var contents = new List(); - - if (!string.IsNullOrEmpty(reasoningMessage.Content)) - { - contents.Add(new TextReasoningContent(reasoningMessage.Content) - { - ProtectedData = reasoningMessage.EncryptedValue - }); - } - else if (!string.IsNullOrEmpty(reasoningMessage.EncryptedValue)) - { - contents.Add(new TextReasoningContent("") - { - ProtectedData = reasoningMessage.EncryptedValue - }); - } - - yield return new ChatMessage(role, contents) - { - MessageId = message.Id - }; - break; - } - - case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }: - { - pendingContents ??= new List(); - pendingId ??= message.Id; - - if (!string.IsNullOrEmpty(assistantMessage.Content)) - { - pendingContents.Add(new TextContent(assistantMessage.Content)); - } - - foreach (var toolCall in assistantMessage.ToolCalls) - { - Dictionary? arguments = null; - if (!string.IsNullOrEmpty(toolCall.Function.Arguments)) - { - arguments = (Dictionary?)JsonSerializer.Deserialize( - toolCall.Function.Arguments, - jsonSerializerOptions.GetTypeInfo(typeof(Dictionary))); - } - - pendingContents.Add(new FunctionCallContent( - toolCall.Id, - toolCall.Function.Name, - arguments)); - } - - break; - } - - default: - { - string content = message switch - { - AGUIDeveloperMessage dev => dev.Content, - AGUISystemMessage sys => sys.Content, - AGUIUserMessage user => user.Content, - AGUIAssistantMessage asst => asst.Content, - _ => string.Empty - }; - - yield return new ChatMessage(role, content) - { - MessageId = message.Id - }; - break; - } - } - } - - // Flush remaining pending assistant-tool-call entry at end of stream. - if (pendingContents is not null) - { - yield return new ChatMessage(ChatRole.Assistant, pendingContents) { MessageId = pendingId }; - } - } - - public static IEnumerable AsAGUIMessages( - this IEnumerable chatMessages, - JsonSerializerOptions jsonSerializerOptions) - { - foreach (var message in chatMessages) - { - message.MessageId ??= Guid.NewGuid().ToString("N"); - if (message.Role == ChatRole.Tool) - { - foreach (var toolMessage in MapToolMessages(jsonSerializerOptions, message)) - { - yield return toolMessage; - } - } - else if (message.Role == ChatRole.Assistant) - { - var reasoningMessage = MapReasoningMessage(message); - if (reasoningMessage != null) - { - yield return reasoningMessage; - } - - var assistantMessage = MapAssistantMessage(jsonSerializerOptions, message); - if (assistantMessage != null) - { - yield return assistantMessage; - } - } - else - { - yield return message.Role.Value switch - { - AGUIRoles.Developer => new AGUIDeveloperMessage { Id = message.MessageId, Content = message.Text ?? string.Empty }, - AGUIRoles.System => new AGUISystemMessage { Id = message.MessageId, Content = message.Text ?? string.Empty }, - AGUIRoles.User => new AGUIUserMessage { Id = message.MessageId, Content = message.Text ?? string.Empty }, - _ => throw new InvalidOperationException($"Unknown role: {message.Role.Value}") - }; - } - } - } - - private static AGUIReasoningMessage? MapReasoningMessage(ChatMessage message) - { - var reasoning = message.Contents.OfType().FirstOrDefault(); - if (reasoning is null) - { - return null; - } - - var text = string.Join( - string.Empty, - message.Contents.OfType() - .Where(r => !string.IsNullOrEmpty(r.Text)) - .Select(r => r.Text)); - - var protectedData = message.Contents.OfType() - .Select(r => r.ProtectedData) - .LastOrDefault(p => !string.IsNullOrEmpty(p)); - - return new AGUIReasoningMessage - { - Id = message.MessageId, - Content = text, - EncryptedValue = protectedData, - }; - } - - private static AGUIAssistantMessage? MapAssistantMessage(JsonSerializerOptions jsonSerializerOptions, ChatMessage message) - { - List? toolCalls = null; - string? textContent = null; - - foreach (var content in message.Contents) - { - if (content is FunctionCallContent functionCall) - { - var argumentsJson = functionCall.Arguments is null ? - "{}" : - JsonSerializer.Serialize(functionCall.Arguments, jsonSerializerOptions.GetTypeInfo(typeof(IDictionary))); - toolCalls ??= []; - toolCalls.Add(new AGUIToolCall - { - Id = functionCall.CallId, - Type = "function", - Function = new AGUIFunctionCall - { - Name = functionCall.Name, - Arguments = argumentsJson - } - }); - } - else if (content is TextContent textContentItem) - { - textContent = textContentItem.Text; - } - } - - // Create message with tool calls and/or text content - if (toolCalls?.Count > 0 || !string.IsNullOrEmpty(textContent)) - { - return new AGUIAssistantMessage - { - Id = message.MessageId, - Content = textContent ?? string.Empty, - ToolCalls = toolCalls?.Count > 0 ? toolCalls.ToArray() : null - }; - } - - return null; - } - - private static IEnumerable MapToolMessages(JsonSerializerOptions jsonSerializerOptions, ChatMessage message) - { - foreach (var content in message.Contents) - { - if (content is FunctionResultContent functionResult) - { - yield return new AGUIToolMessage - { - Id = functionResult.CallId, - ToolCallId = functionResult.CallId, - Content = functionResult.Result is null ? - string.Empty : - JsonSerializer.Serialize(functionResult.Result, jsonSerializerOptions.GetTypeInfo(functionResult.Result.GetType())) - }; - } - } - } - - public static ChatRole MapChatRole(string role) => - string.Equals(role, AGUIRoles.System, StringComparison.OrdinalIgnoreCase) ? ChatRole.System : - string.Equals(role, AGUIRoles.User, StringComparison.OrdinalIgnoreCase) ? ChatRole.User : - string.Equals(role, AGUIRoles.Assistant, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant : - string.Equals(role, AGUIRoles.Developer, StringComparison.OrdinalIgnoreCase) ? s_developerChatRole : - string.Equals(role, AGUIRoles.Tool, StringComparison.OrdinalIgnoreCase) ? ChatRole.Tool : - string.Equals(role, AGUIRoles.Reasoning, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant : - throw new InvalidOperationException($"Unknown chat role: {role}"); -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIContextItem.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIContextItem.cs deleted file mode 100644 index 54be56f8806..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIContextItem.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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 AGUIContextItem -{ - [JsonPropertyName("description")] - public string Description { get; set; } = string.Empty; - - [JsonPropertyName("value")] - public string Value { get; set; } = string.Empty; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIDeveloperMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIDeveloperMessage.cs deleted file mode 100644 index e41f375b9c8..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIDeveloperMessage.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#if ASPNETCORE -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -#else -namespace Microsoft.Agents.AI.AGUI.Shared; -#endif - -internal sealed class AGUIDeveloperMessage : AGUIMessage -{ - public AGUIDeveloperMessage() - { - this.Role = AGUIRoles.Developer; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs deleted file mode 100644 index 045685b2a5b..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#if ASPNETCORE -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -#else -namespace Microsoft.Agents.AI.AGUI.Shared; -#endif - -internal static class AGUIEventTypes -{ - public const string RunStarted = "RUN_STARTED"; - - public const string RunFinished = "RUN_FINISHED"; - - public const string RunError = "RUN_ERROR"; - - public const string TextMessageStart = "TEXT_MESSAGE_START"; - - public const string TextMessageContent = "TEXT_MESSAGE_CONTENT"; - - public const string TextMessageEnd = "TEXT_MESSAGE_END"; - - public const string ToolCallStart = "TOOL_CALL_START"; - - public const string ToolCallArgs = "TOOL_CALL_ARGS"; - - public const string ToolCallEnd = "TOOL_CALL_END"; - - public const string ToolCallResult = "TOOL_CALL_RESULT"; - - 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/AGUIFunctionCall.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIFunctionCall.cs deleted file mode 100644 index f69dbcbac66..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIFunctionCall.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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 AGUIFunctionCall -{ - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("arguments")] - public string Arguments { get; set; } = string.Empty; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs deleted file mode 100644 index 260d6178007..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Text.Json.Serialization; - -#if ASPNETCORE -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; - -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; -#else -using Microsoft.Agents.AI.AGUI.Shared; - -namespace Microsoft.Agents.AI.AGUI; -#endif - -// All JsonSerializable attributes below are required for AG-UI functionality: -// - AG-UI message types (AGUIMessage, AGUIUserMessage, etc.) for protocol communication -// - Event types (BaseEvent, RunStartedEvent, etc.) for server-sent events streaming -// - Tool-related types (AGUITool, AGUIToolCall, AGUIFunctionCall) for tool calling support -// - Primitive and dictionary types (string, int, Dictionary, JsonElement) are required for -// serializing tool call parameters and results which can contain arbitrary data types -[JsonSourceGenerationOptions(WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.Never)] -[JsonSerializable(typeof(RunAgentInput))] -[JsonSerializable(typeof(AGUIMessage))] -[JsonSerializable(typeof(AGUIMessage[]))] -[JsonSerializable(typeof(AGUIDeveloperMessage))] -[JsonSerializable(typeof(AGUISystemMessage))] -[JsonSerializable(typeof(AGUIUserMessage))] -[JsonSerializable(typeof(AGUIAssistantMessage))] -[JsonSerializable(typeof(AGUIToolMessage))] -[JsonSerializable(typeof(AGUIReasoningMessage))] -[JsonSerializable(typeof(AGUITool))] -[JsonSerializable(typeof(AGUIToolCall))] -[JsonSerializable(typeof(AGUIToolCall[]))] -[JsonSerializable(typeof(AGUIFunctionCall))] -[JsonSerializable(typeof(BaseEvent))] -[JsonSerializable(typeof(BaseEvent[]))] -[JsonSerializable(typeof(RunStartedEvent))] -[JsonSerializable(typeof(RunFinishedEvent))] -[JsonSerializable(typeof(RunErrorEvent))] -[JsonSerializable(typeof(TextMessageStartEvent))] -[JsonSerializable(typeof(TextMessageContentEvent))] -[JsonSerializable(typeof(TextMessageEndEvent))] -[JsonSerializable(typeof(ToolCallStartEvent))] -[JsonSerializable(typeof(ToolCallArgsEvent))] -[JsonSerializable(typeof(ToolCallEndEvent))] -[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))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(System.Text.Json.JsonElement))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(string))] -[JsonSerializable(typeof(int))] -[JsonSerializable(typeof(long))] -[JsonSerializable(typeof(double))] -[JsonSerializable(typeof(float))] -[JsonSerializable(typeof(bool))] -[JsonSerializable(typeof(decimal))] -internal sealed partial class AGUIJsonSerializerContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessage.cs deleted file mode 100644 index 01ccb07b15c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessage.cs +++ /dev/null @@ -1,22 +0,0 @@ -// 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 - -[JsonConverter(typeof(AGUIMessageJsonConverter))] -internal abstract class AGUIMessage -{ - [JsonPropertyName("id")] - public string? Id { get; set; } - - [JsonPropertyName("role")] - public string Role { get; set; } = string.Empty; - - [JsonPropertyName("content")] - public string Content { get; set; } = string.Empty; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessageJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessageJsonConverter.cs deleted file mode 100644 index 9693eec07bd..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessageJsonConverter.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Text.Json; -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 AGUIMessageJsonConverter : JsonConverter -{ - private const string RoleDiscriminatorPropertyName = "role"; - - public override bool CanConvert(Type typeToConvert) => - typeof(AGUIMessage).IsAssignableFrom(typeToConvert); - - public override AGUIMessage Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options) - { - var jsonElementTypeInfo = options.GetTypeInfo(typeof(JsonElement)); - JsonElement jsonElement = (JsonElement)JsonSerializer.Deserialize(ref reader, jsonElementTypeInfo)!; - - // Try to get the discriminator property - if (!jsonElement.TryGetProperty(RoleDiscriminatorPropertyName, out JsonElement discriminatorElement)) - { - throw new JsonException($"Missing required property '{RoleDiscriminatorPropertyName}' for AGUIMessage deserialization"); - } - - string? discriminator = discriminatorElement.GetString(); - - // Map discriminator to concrete type and deserialize using type info from options - AGUIMessage? result = discriminator switch - { - AGUIRoles.Developer => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIDeveloperMessage))) as AGUIDeveloperMessage, - AGUIRoles.System => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUISystemMessage))) as AGUISystemMessage, - AGUIRoles.User => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIUserMessage))) as AGUIUserMessage, - AGUIRoles.Assistant => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIAssistantMessage))) as AGUIAssistantMessage, - AGUIRoles.Tool => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIToolMessage))) as AGUIToolMessage, - AGUIRoles.Reasoning => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIReasoningMessage))) as AGUIReasoningMessage, - _ => throw new JsonException($"Unknown AGUIMessage role discriminator: '{discriminator}'") - }; - - if (result == null) - { - throw new JsonException($"Failed to deserialize AGUIMessage with role discriminator: '{discriminator}'"); - } - - return result; - } - - public override void Write( - Utf8JsonWriter writer, - AGUIMessage value, - JsonSerializerOptions options) - { - // Serialize the concrete type directly using type info from options - switch (value) - { - case AGUIDeveloperMessage developer: - JsonSerializer.Serialize(writer, developer, options.GetTypeInfo(typeof(AGUIDeveloperMessage))); - break; - case AGUISystemMessage system: - JsonSerializer.Serialize(writer, system, options.GetTypeInfo(typeof(AGUISystemMessage))); - break; - case AGUIUserMessage user: - JsonSerializer.Serialize(writer, user, options.GetTypeInfo(typeof(AGUIUserMessage))); - break; - case AGUIAssistantMessage assistant: - JsonSerializer.Serialize(writer, assistant, options.GetTypeInfo(typeof(AGUIAssistantMessage))); - break; - case AGUIToolMessage tool: - JsonSerializer.Serialize(writer, tool, options.GetTypeInfo(typeof(AGUIToolMessage))); - break; - case AGUIReasoningMessage reasoning: - JsonSerializer.Serialize(writer, reasoning, options.GetTypeInfo(typeof(AGUIReasoningMessage))); - break; - default: - throw new JsonException($"Unknown AGUIMessage type: {value.GetType().Name}"); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIReasoningMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIReasoningMessage.cs deleted file mode 100644 index 366dc2b4ba6..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIReasoningMessage.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -#if ASPNETCORE -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -#else -namespace Microsoft.Agents.AI.AGUI.Shared; -#endif - -internal sealed class AGUIReasoningMessage : AGUIMessage -{ - public AGUIReasoningMessage() - { - this.Role = AGUIRoles.Reasoning; - } - - [JsonPropertyName("encryptedValue")] - public string? EncryptedValue { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIRoles.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIRoles.cs deleted file mode 100644 index 1d372d7900d..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIRoles.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#if ASPNETCORE -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -#else -namespace Microsoft.Agents.AI.AGUI.Shared; -#endif - -internal static class AGUIRoles -{ - public const string System = "system"; - - public const string User = "user"; - - public const string Assistant = "assistant"; - - public const string Developer = "developer"; - - public const string Tool = "tool"; - - public const string Reasoning = "reasoning"; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUISystemMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUISystemMessage.cs deleted file mode 100644 index f2d053c23e3..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUISystemMessage.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#if ASPNETCORE -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -#else -namespace Microsoft.Agents.AI.AGUI.Shared; -#endif - -internal sealed class AGUISystemMessage : AGUIMessage -{ - public AGUISystemMessage() - { - this.Role = AGUIRoles.System; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUITool.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUITool.cs deleted file mode 100644 index c42556dcb06..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUITool.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -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 AGUITool -{ - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("description")] - public string? Description { get; set; } - - [JsonPropertyName("parameters")] - public JsonElement Parameters { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolCall.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolCall.cs deleted file mode 100644 index ca28d956d38..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolCall.cs +++ /dev/null @@ -1,21 +0,0 @@ -// 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 AGUIToolCall -{ - [JsonPropertyName("id")] - public string Id { get; set; } = string.Empty; - - [JsonPropertyName("type")] - public string Type { get; set; } = "function"; - - [JsonPropertyName("function")] - public AGUIFunctionCall Function { get; set; } = new(); -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolMessage.cs deleted file mode 100644 index bcd49d2b6f0..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolMessage.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 AGUIToolMessage : AGUIMessage -{ - public AGUIToolMessage() - { - this.Role = AGUIRoles.Tool; - } - - [JsonPropertyName("toolCallId")] - public string ToolCallId { get; set; } = string.Empty; - - [JsonPropertyName("error")] - public string? Error { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIUserMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIUserMessage.cs deleted file mode 100644 index e8e9f2ed57a..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIUserMessage.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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 AGUIUserMessage : AGUIMessage -{ - public AGUIUserMessage() - { - this.Role = AGUIRoles.User; - } - - [JsonPropertyName("name")] - public string? Name { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AIToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AIToolExtensions.cs deleted file mode 100644 index 8952f38a28d..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AIToolExtensions.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Extensions.AI; - -#if ASPNETCORE -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -#else -namespace Microsoft.Agents.AI.AGUI.Shared; -#endif - -internal static class AIToolExtensions -{ - public static IEnumerable AsAGUITools(this IEnumerable tools) - { - if (tools is null) - { - yield break; - } - - foreach (var tool in tools) - { - // Convert both AIFunctionDeclaration and AIFunction (which extends it) to AGUITool - // For AIFunction, we send only the metadata (Name, Description, JsonSchema) - // The actual executable implementation stays on the client side - if (tool is AIFunctionDeclaration function) - { - yield return new AGUITool - { - Name = function.Name, - Description = function.Description, - Parameters = function.JsonSchema - }; - } - } - } - - public static IEnumerable AsAITools(this IEnumerable tools) - { - if (tools is null) - { - yield break; - } - - foreach (var tool in tools) - { - // Create a function declaration from the AG-UI tool definition - // Note: These are declaration-only and cannot be invoked, as the actual - // implementation exists on the client side - yield return AIFunctionFactory.CreateDeclaration( - name: tool.Name, - description: tool.Description, - jsonSchema: tool.Parameters); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEvent.cs deleted file mode 100644 index f68698a5c9d..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEvent.cs +++ /dev/null @@ -1,16 +0,0 @@ -// 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 - -[JsonConverter(typeof(BaseEventJsonConverter))] -internal abstract class BaseEvent -{ - [JsonPropertyName("type")] - public string Type { get; set; } = string.Empty; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs deleted file mode 100644 index 0dcddcf53e7..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Text.Json; -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 BaseEventJsonConverter : JsonConverter -{ - private const string TypeDiscriminatorPropertyName = "type"; - - public override bool CanConvert(Type typeToConvert) => - typeof(BaseEvent).IsAssignableFrom(typeToConvert); - - public override BaseEvent Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options) - { - var jsonElementTypeInfo = options.GetTypeInfo(typeof(JsonElement)); - JsonElement jsonElement = (JsonElement)JsonSerializer.Deserialize(ref reader, jsonElementTypeInfo)!; - - // Try to get the discriminator property - if (!jsonElement.TryGetProperty(TypeDiscriminatorPropertyName, out JsonElement discriminatorElement)) - { - throw new JsonException($"Missing required property '{TypeDiscriminatorPropertyName}' for BaseEvent deserialization"); - } - - string? discriminator = discriminatorElement.GetString(); - - // Map discriminator to concrete type and deserialize using type info from options - BaseEvent? result = discriminator switch - { - AGUIEventTypes.RunStarted => jsonElement.Deserialize(options.GetTypeInfo(typeof(RunStartedEvent))) as RunStartedEvent, - AGUIEventTypes.RunFinished => jsonElement.Deserialize(options.GetTypeInfo(typeof(RunFinishedEvent))) as RunFinishedEvent, - AGUIEventTypes.RunError => jsonElement.Deserialize(options.GetTypeInfo(typeof(RunErrorEvent))) as RunErrorEvent, - AGUIEventTypes.TextMessageStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(TextMessageStartEvent))) as TextMessageStartEvent, - AGUIEventTypes.TextMessageContent => jsonElement.Deserialize(options.GetTypeInfo(typeof(TextMessageContentEvent))) as TextMessageContentEvent, - AGUIEventTypes.TextMessageEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(TextMessageEndEvent))) as TextMessageEndEvent, - AGUIEventTypes.ToolCallStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallStartEvent))) as ToolCallStartEvent, - AGUIEventTypes.ToolCallArgs => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallArgsEvent))) as ToolCallArgsEvent, - 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}'") - }; - - if (result == null) - { - throw new JsonException($"Failed to deserialize BaseEvent with type discriminator: '{discriminator}'"); - } - - return result; - } - - public override void Write( - Utf8JsonWriter writer, - BaseEvent value, - JsonSerializerOptions options) - { - // Serialize the concrete type directly using type info from options - switch (value) - { - case RunStartedEvent runStarted: - JsonSerializer.Serialize(writer, runStarted, options.GetTypeInfo(typeof(RunStartedEvent))); - break; - case RunFinishedEvent runFinished: - JsonSerializer.Serialize(writer, runFinished, options.GetTypeInfo(typeof(RunFinishedEvent))); - break; - case RunErrorEvent runError: - JsonSerializer.Serialize(writer, runError, options.GetTypeInfo(typeof(RunErrorEvent))); - break; - case TextMessageStartEvent textStart: - JsonSerializer.Serialize(writer, textStart, options.GetTypeInfo(typeof(TextMessageStartEvent))); - break; - case TextMessageContentEvent textContent: - JsonSerializer.Serialize(writer, textContent, options.GetTypeInfo(typeof(TextMessageContentEvent))); - break; - case TextMessageEndEvent textEnd: - JsonSerializer.Serialize(writer, textEnd, options.GetTypeInfo(typeof(TextMessageEndEvent))); - break; - case ToolCallStartEvent toolCallStart: - JsonSerializer.Serialize(writer, toolCallStart, options.GetTypeInfo(typeof(ToolCallStartEvent))); - break; - case ToolCallArgsEvent toolCallArgs: - JsonSerializer.Serialize(writer, toolCallArgs, options.GetTypeInfo(typeof(ToolCallArgsEvent))); - break; - case ToolCallEndEvent toolCallEnd: - JsonSerializer.Serialize(writer, toolCallEnd, options.GetTypeInfo(typeof(ToolCallEndEvent))); - break; - case ToolCallResultEvent toolCallResult: - JsonSerializer.Serialize(writer, toolCallResult, options.GetTypeInfo(typeof(ToolCallResultEvent))); - break; - case StateSnapshotEvent stateSnapshot: - JsonSerializer.Serialize(writer, stateSnapshot, options.GetTypeInfo(typeof(StateSnapshotEvent))); - break; - 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 deleted file mode 100644 index d5451a9ff5c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs +++ /dev/null @@ -1,747 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Net.Http.Headers; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -#if ASPNETCORE -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -#else -namespace Microsoft.Agents.AI.AGUI.Shared; -#endif - -internal static class ChatResponseUpdateAGUIExtensions -{ - private static readonly MediaTypeHeaderValue? s_jsonPatchMediaType = new("application/json-patch+json"); - private static readonly MediaTypeHeaderValue? s_json = new("application/json"); - - public static async IAsyncEnumerable AsChatResponseUpdatesAsync( - this IAsyncEnumerable events, - JsonSerializerOptions jsonSerializerOptions, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - string? conversationId = null; - 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) - { - // Lifecycle events - case RunStartedEvent runStarted: - conversationId = runStarted.ThreadId; - responseId = runStarted.RunId; - toolCallAccumulator.SetConversationAndResponseIds(conversationId, responseId); - textMessageBuilder.SetConversationAndResponseIds(conversationId, responseId); - reasoningBuilder.SetConversationAndResponseIds(conversationId, responseId); - yield return ValidateAndEmitRunStart(runStarted); - break; - case RunFinishedEvent runFinished: - yield return ValidateAndEmitRunFinished(conversationId, responseId, runFinished); - break; - case RunErrorEvent runError: - yield return new ChatResponseUpdate(ChatRole.Assistant, [(new ErrorContent(runError.Message) { ErrorCode = runError.Code })]); - break; - - // Text events - case TextMessageStartEvent textStart: - textMessageBuilder.AddTextStart(textStart); - break; - case TextMessageContentEvent textContent: - yield return textMessageBuilder.EmitTextUpdate(textContent); - break; - case TextMessageEndEvent textEnd: - textMessageBuilder.EndCurrentMessage(textEnd); - break; - - // Tool call events - case ToolCallStartEvent toolCallStart: - toolCallAccumulator.AddToolCallStart(toolCallStart); - break; - case ToolCallArgsEvent toolCallArgs: - toolCallAccumulator.AddToolCallArgs(toolCallArgs, jsonSerializerOptions); - break; - case ToolCallEndEvent toolCallEnd: - yield return toolCallAccumulator.EmitToolCallUpdate(toolCallEnd, jsonSerializerOptions); - break; - case ToolCallResultEvent toolCallResult: - yield return toolCallAccumulator.EmitToolCallResult(toolCallResult, jsonSerializerOptions); - break; - - // State snapshot events - case StateSnapshotEvent stateSnapshot: - if (stateSnapshot.Snapshot.HasValue) - { - yield return CreateStateSnapshotUpdate(stateSnapshot, conversationId, responseId, jsonSerializerOptions); - } - break; - case StateDeltaEvent stateDelta: - if (stateDelta.Delta.HasValue) - { - 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; - } - } - } - - private static ChatResponseUpdate CreateStateSnapshotUpdate( - StateSnapshotEvent stateSnapshot, - string? conversationId, - string? responseId, - JsonSerializerOptions jsonSerializerOptions) - { - // Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload - byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes( - stateSnapshot.Snapshot!.Value, - jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))); - DataContent dataContent = new(jsonBytes, "application/json"); - - return new ChatResponseUpdate(ChatRole.Assistant, [dataContent]) - { - ConversationId = conversationId, - ResponseId = responseId, - CreatedAt = DateTimeOffset.UtcNow, - AdditionalProperties = new AdditionalPropertiesDictionary - { - ["is_state_snapshot"] = true - } - }; - } - - private static ChatResponseUpdate CreateStateDeltaUpdate( - StateDeltaEvent stateDelta, - string? conversationId, - string? responseId, - JsonSerializerOptions jsonSerializerOptions) - { - // Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload - byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes( - stateDelta.Delta!.Value, - jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))); - DataContent dataContent = new(jsonBytes, "application/json-patch+json"); - - return new ChatResponseUpdate(ChatRole.Assistant, [dataContent]) - { - ConversationId = conversationId, - ResponseId = responseId, - CreatedAt = DateTimeOffset.UtcNow, - AdditionalProperties = new AdditionalPropertiesDictionary - { - ["is_state_delta"] = true - } - }; - } - - private sealed class TextMessageBuilder() - { - private ChatRole _currentRole; - private string? _currentMessageId; - private string? _conversationId; - private string? _responseId; - - public void SetConversationAndResponseIds(string? conversationId, string? responseId) - { - this._conversationId = conversationId; - this._responseId = responseId; - } - - public void AddTextStart(TextMessageStartEvent textStart) - { - if (this._currentRole != default || this._currentMessageId != null) - { - throw new InvalidOperationException("Received TextMessageStartEvent while another message is being processed."); - } - - this._currentRole = AGUIChatMessageExtensions.MapChatRole(textStart.Role); - this._currentMessageId = textStart.MessageId; - } - - internal ChatResponseUpdate EmitTextUpdate(TextMessageContentEvent textContent) - { - return new ChatResponseUpdate( - this._currentRole, - textContent.Delta) - { - ConversationId = this._conversationId, - ResponseId = this._responseId, - MessageId = textContent.MessageId, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - internal void EndCurrentMessage(TextMessageEndEvent textEnd) - { - if (this._currentMessageId != textEnd.MessageId) - { - throw new InvalidOperationException("Received TextMessageEndEvent for a different message than the current one."); - } - this._currentRole = default; - this._currentMessageId = null; - } - } - - private static ChatResponseUpdate ValidateAndEmitRunStart(RunStartedEvent runStarted) - { - return new ChatResponseUpdate( - ChatRole.Assistant, - []) - { - ConversationId = runStarted.ThreadId, - ResponseId = runStarted.RunId, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - private static ChatResponseUpdate ValidateAndEmitRunFinished(string? conversationId, string? responseId, RunFinishedEvent runFinished) - { - if (!string.Equals(runFinished.ThreadId, conversationId, StringComparison.Ordinal)) - { - throw new InvalidOperationException($"The run finished event didn't match the run started event thread ID: {runFinished.ThreadId}, {conversationId}"); - } - if (!string.Equals(runFinished.RunId, responseId, StringComparison.Ordinal)) - { - throw new InvalidOperationException($"The run finished event didn't match the run started event run ID: {runFinished.RunId}, {responseId}"); - } - - return new ChatResponseUpdate( - ChatRole.Assistant, runFinished.Result?.GetRawText()) - { - ConversationId = conversationId, - ResponseId = responseId, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - private sealed class ToolCallBuilder - { - private string? _conversationId; - private string? _responseId; - private StringBuilder? _accumulatedArgs; - private FunctionCallContent? _currentFunctionCall; - - public void AddToolCallStart(ToolCallStartEvent toolCallStart) - { - if (this._currentFunctionCall != null) - { - throw new InvalidOperationException("Received ToolCallStartEvent while another tool call is being processed."); - } - this._accumulatedArgs ??= new StringBuilder(); - this._currentFunctionCall = new( - toolCallStart.ToolCallId, - toolCallStart.ToolCallName, - null); - } - - public void AddToolCallArgs(ToolCallArgsEvent toolCallArgs, JsonSerializerOptions options) - { - if (this._currentFunctionCall == null) - { - throw new InvalidOperationException("Received ToolCallArgsEvent without a current tool call."); - } - - if (!string.Equals(this._currentFunctionCall.CallId, toolCallArgs.ToolCallId, StringComparison.Ordinal)) - { - throw new InvalidOperationException("Received ToolCallArgsEvent for a different tool call than the current one."); - } - - Debug.Assert(this._accumulatedArgs != null, "Accumulated args should have been initialized in ToolCallStartEvent."); - this._accumulatedArgs.Append(toolCallArgs.Delta); - } - - internal ChatResponseUpdate EmitToolCallUpdate(ToolCallEndEvent toolCallEnd, JsonSerializerOptions jsonSerializerOptions) - { - if (this._currentFunctionCall == null) - { - throw new InvalidOperationException("Received ToolCallEndEvent without a current tool call."); - } - if (!string.Equals(this._currentFunctionCall.CallId, toolCallEnd.ToolCallId, StringComparison.Ordinal)) - { - throw new InvalidOperationException("Received ToolCallEndEvent for a different tool call than the current one."); - } - Debug.Assert(this._accumulatedArgs != null, "Accumulated args should have been initialized in ToolCallStartEvent."); - var arguments = DeserializeArgumentsIfAvailable(this._accumulatedArgs.ToString(), jsonSerializerOptions); - this._accumulatedArgs.Clear(); - this._currentFunctionCall.Arguments = arguments; - var invocation = this._currentFunctionCall; - this._currentFunctionCall = null; - return new ChatResponseUpdate( - ChatRole.Assistant, - [invocation]) - { - ConversationId = this._conversationId, - ResponseId = this._responseId, - MessageId = invocation.CallId, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - public ChatResponseUpdate EmitToolCallResult(ToolCallResultEvent toolCallResult, JsonSerializerOptions options) - { - return new ChatResponseUpdate( - ChatRole.Tool, - [new FunctionResultContent( - toolCallResult.ToolCallId, - DeserializeResultIfAvailable(toolCallResult, options))]) - { - ConversationId = this._conversationId, - ResponseId = this._responseId, - MessageId = toolCallResult.MessageId, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - internal void SetConversationAndResponseIds(string conversationId, string responseId) - { - this._conversationId = conversationId; - this._responseId = responseId; - } - } - - 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)) - { - return (IDictionary?)JsonSerializer.Deserialize( - argsJson, - options.GetTypeInfo(typeof(IDictionary))); - } - - return null; - } - - private static object? DeserializeResultIfAvailable(ToolCallResultEvent toolCallResult, JsonSerializerOptions options) - { - if (!string.IsNullOrEmpty(toolCallResult.Content)) - { - return JsonSerializer.Deserialize(toolCallResult.Content, options.GetTypeInfo(typeof(JsonElement))); - } - - return null; - } - - public static async IAsyncEnumerable AsAGUIEventStreamAsync( - this IAsyncEnumerable updates, - string threadId, - string runId, - JsonSerializerOptions jsonSerializerOptions, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - yield return new RunStartedEvent - { - ThreadId = threadId, - RunId = runId - }; - - string? currentMessageId = null; - string? textStreamingFallback = null; - bool textInFallback = false; - string? currentReasoningBaseId = null; - string? currentReasoningId = null; - string? currentReasoningMessageId = null; - await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - // The text-event surface (TextMessageStart/Content/End) requires a non-empty - // MessageId to be valid AGUI. Generate a fallback scoped to a contiguous run of - // null/empty-MessageId chunks (one logical text message). Leave the raw - // chatResponse.MessageId untouched so the tool-call surface below uses the raw - // provider value — collapsing parallel tool calls under a synthetic shared parent - // would make the FE render them as one assistant-message bubble instead of - // distinct rows. - string? textMessageId = chatResponse.MessageId; - if (string.IsNullOrWhiteSpace(textMessageId)) - { - textStreamingFallback ??= Guid.NewGuid().ToString("N"); - textMessageId = textStreamingFallback; - textInFallback = true; - } - else if (textInFallback) - { - textStreamingFallback = null; - textInFallback = false; - } - - if (chatResponse is { Contents.Count: > 0 } && - chatResponse.Contents[0] is TextContent && - !string.Equals(currentMessageId, textMessageId, StringComparison.Ordinal)) - { - // Close any open reasoning block before opening a text message, so AG-UI - // events are properly bracketed. MEAI providers share one MessageId across - // reasoning and text content, so the reasoning-block state alone wouldn't - // detect the transition. - if (currentReasoningMessageId is not null) - { - yield return new ReasoningMessageEndEvent - { - MessageId = currentReasoningMessageId - }; - yield return new ReasoningEndEvent - { - MessageId = currentReasoningId! - }; - currentReasoningBaseId = null; - currentReasoningId = null; - currentReasoningMessageId = null; - } - - // End the previous message if there was one - if (currentMessageId is not null) - { - yield return new TextMessageEndEvent - { - MessageId = currentMessageId - }; - } - - // Start the new message - yield return new TextMessageStartEvent - { - MessageId = textMessageId!, - Role = chatResponse.Role!.Value.Value - }; - - currentMessageId = textMessageId; - } - - // Emit text content if present - if (chatResponse is { Contents.Count: > 0 } && chatResponse.Contents[0] is TextContent textContent && - !string.IsNullOrEmpty(textContent.Text)) - { - yield return new TextMessageContentEvent - { - MessageId = currentMessageId!, - Delta = textContent.Text - }; - } - - // Emit tool call events and tool result events - if (chatResponse is { Contents.Count: > 0 }) - { - foreach (var content in chatResponse.Contents) - { - if (content is FunctionCallContent functionCallContent) - { - // Close any open reasoning block before emitting tool events. - if (currentReasoningMessageId is not null) - { - yield return new ReasoningMessageEndEvent - { - MessageId = currentReasoningMessageId - }; - yield return new ReasoningEndEvent - { - MessageId = currentReasoningId! - }; - currentReasoningBaseId = null; - currentReasoningId = null; - currentReasoningMessageId = null; - } - - yield return new ToolCallStartEvent - { - ToolCallId = functionCallContent.CallId, - ToolCallName = functionCallContent.Name, - ParentMessageId = chatResponse.MessageId - }; - - yield return new ToolCallArgsEvent - { - ToolCallId = functionCallContent.CallId, - Delta = JsonSerializer.Serialize( - functionCallContent.Arguments, - jsonSerializerOptions.GetTypeInfo(typeof(IDictionary))) - }; - - yield return new ToolCallEndEvent - { - ToolCallId = functionCallContent.CallId - }; - } - else if (content is FunctionResultContent functionResultContent) - { - // Close any open reasoning block before emitting tool result events. - if (currentReasoningMessageId is not null) - { - yield return new ReasoningMessageEndEvent - { - MessageId = currentReasoningMessageId - }; - yield return new ReasoningEndEvent - { - MessageId = currentReasoningId! - }; - currentReasoningBaseId = null; - currentReasoningId = null; - currentReasoningMessageId = null; - } - - // Each tool result is a distinct tool-role message on the AGUI wire. - // MEAI's FunctionInvokingChatClient shares one synthetic MessageId - // across all FunctionResultContent items, but the FE keys messages - // by id, so emitting them with the same id collapses them in React - // reconciliation. Derive a unique, deterministic per-result id from - // the (LLM-assigned) call id. - yield return new ToolCallResultEvent - { - MessageId = $"result-{functionResultContent.CallId}", - ToolCallId = functionResultContent.CallId, - Content = SerializeResultContent(functionResultContent, jsonSerializerOptions) ?? "", - Role = AGUIRoles.Tool - }; - } - else if (content is TextReasoningContent reasoningContent - && (!string.IsNullOrEmpty(reasoningContent.Text) || !string.IsNullOrEmpty(reasoningContent.ProtectedData))) - { - if (!string.Equals(currentReasoningBaseId, chatResponse.MessageId, StringComparison.Ordinal)) - { - if (currentReasoningMessageId is not null) - { - yield return new ReasoningMessageEndEvent - { - MessageId = currentReasoningMessageId - }; - yield return new ReasoningEndEvent - { - MessageId = currentReasoningId! - }; - } - - currentReasoningBaseId = chatResponse.MessageId; - currentReasoningId = Guid.NewGuid().ToString("N"); - currentReasoningMessageId = Guid.NewGuid().ToString("N"); - - yield return new ReasoningStartEvent - { - MessageId = currentReasoningId - }; - yield return new ReasoningMessageStartEvent - { - MessageId = currentReasoningMessageId - }; - } - - if (!string.IsNullOrEmpty(reasoningContent.Text)) - { - yield return new ReasoningMessageContentEvent - { - MessageId = currentReasoningMessageId!, - Delta = reasoningContent.Text - }; - } - - if (!string.IsNullOrEmpty(reasoningContent.ProtectedData)) - { - yield return new ReasoningEncryptedValueEvent - { - EntityId = currentReasoningMessageId!, - EncryptedValue = reasoningContent.ProtectedData - }; - } - } - else if (content is DataContent dataContent) - { - if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json)) - { - // State snapshot event - yield return new StateSnapshotEvent - { -#if !NET - Snapshot = (JsonElement?)JsonSerializer.Deserialize( - dataContent.Data.ToArray(), - jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) -#else - Snapshot = (JsonElement?)JsonSerializer.Deserialize( - dataContent.Data.Span, - jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) -#endif - }; - } - else if (mediaType is { } && mediaType.Equals(s_jsonPatchMediaType)) - { - // State snapshot patch event must be a valid JSON patch, - // but its not up to us to validate that here. - yield return new StateDeltaEvent - { -#if !NET - Delta = (JsonElement?)JsonSerializer.Deserialize( - dataContent.Data.ToArray(), - jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) -#else - Delta = (JsonElement?)JsonSerializer.Deserialize( - dataContent.Data.Span, - jsonSerializerOptions.GetTypeInfo(typeof(JsonElement))) -#endif - }; - } - else - { - // Text content event - yield return new TextMessageContentEvent - { - MessageId = textMessageId!, -#if !NET - Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray()) -#else - Delta = Encoding.UTF8.GetString(dataContent.Data.Span) -#endif - }; - } - } - } - } - } - - // End the last reasoning block if there was one - if (currentReasoningMessageId is not null) - { - yield return new ReasoningMessageEndEvent - { - MessageId = currentReasoningMessageId - }; - yield return new ReasoningEndEvent - { - MessageId = currentReasoningId! - }; - } - - // End the last message if there was one - if (currentMessageId is not null) - { - yield return new TextMessageEndEvent - { - MessageId = currentMessageId - }; - } - - yield return new RunFinishedEvent - { - ThreadId = threadId, - RunId = runId, - }; - } - - private static string? SerializeResultContent(FunctionResultContent functionResultContent, JsonSerializerOptions options) - { - return functionResultContent.Result switch - { - null => null, - string str => str, - JsonElement jsonElement => jsonElement.GetRawText(), - _ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())), - }; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEncryptedValueEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEncryptedValueEvent.cs deleted file mode 100644 index 8c3deff5f24..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEncryptedValueEvent.cs +++ /dev/null @@ -1,26 +0,0 @@ -// 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 deleted file mode 100644 index 2f70e5beeab..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningEndEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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 deleted file mode 100644 index 9afebd4e094..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageChunkEvent.cs +++ /dev/null @@ -1,25 +0,0 @@ -// 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 deleted file mode 100644 index 60461caf93d..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageContentEvent.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 deleted file mode 100644 index b07e8e96045..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageEndEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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 deleted file mode 100644 index c662fbb818c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningMessageStartEvent.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -#if ASPNETCORE -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -#else -namespace Microsoft.Agents.AI.AGUI.Shared; -#endif - -internal sealed class ReasoningMessageStartEvent : BaseEvent -{ - public ReasoningMessageStartEvent() - { - this.Type = AGUIEventTypes.ReasoningMessageStart; - } - - [JsonPropertyName("messageId")] - public string MessageId { get; set; } = string.Empty; - - [JsonPropertyName("role")] - public string Role { get; set; } = AGUIRoles.Reasoning; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningStartEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningStartEvent.cs deleted file mode 100644 index 13a96a67b25..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ReasoningStartEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs deleted file mode 100644 index f64177146fd..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Text.Json; -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 RunAgentInput -{ - [JsonPropertyName("threadId")] - public string ThreadId { get; set; } = string.Empty; - - [JsonPropertyName("runId")] - public string RunId { get; set; } = string.Empty; - - [JsonPropertyName("state")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public JsonElement State { get; set; } - - [JsonPropertyName("messages")] - public IEnumerable Messages { get; set; } = []; - - [JsonPropertyName("tools")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public IEnumerable? Tools { get; set; } - - [JsonPropertyName("context")] - public AGUIContextItem[] Context { get; set; } = []; - - [JsonPropertyName("forwardedProps")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public JsonElement ForwardedProperties { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunErrorEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunErrorEvent.cs deleted file mode 100644 index 078f22cc623..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunErrorEvent.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 RunErrorEvent : BaseEvent -{ - public RunErrorEvent() - { - this.Type = AGUIEventTypes.RunError; - } - - [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; - - [JsonPropertyName("code")] - public string? Code { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunFinishedEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunFinishedEvent.cs deleted file mode 100644 index 54aebaa3331..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunFinishedEvent.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -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 RunFinishedEvent : BaseEvent -{ - public RunFinishedEvent() - { - this.Type = AGUIEventTypes.RunFinished; - } - - [JsonPropertyName("threadId")] - public string ThreadId { get; set; } = string.Empty; - - [JsonPropertyName("runId")] - public string RunId { get; set; } = string.Empty; - - [JsonPropertyName("result")] - public JsonElement? Result { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunStartedEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunStartedEvent.cs deleted file mode 100644 index 2d0d2259bbc..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunStartedEvent.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 RunStartedEvent : BaseEvent -{ - public RunStartedEvent() - { - this.Type = AGUIEventTypes.RunStarted; - } - - [JsonPropertyName("threadId")] - public string ThreadId { get; set; } = string.Empty; - - [JsonPropertyName("runId")] - public string RunId { get; set; } = string.Empty; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateDeltaEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateDeltaEvent.cs deleted file mode 100644 index 98d3b168b34..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateDeltaEvent.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -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 StateDeltaEvent : BaseEvent -{ - public StateDeltaEvent() - { - this.Type = AGUIEventTypes.StateDelta; - } - - [JsonPropertyName("delta")] - public JsonElement? Delta { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateSnapshotEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateSnapshotEvent.cs deleted file mode 100644 index dc77e4ba466..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/StateSnapshotEvent.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; -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 StateSnapshotEvent : BaseEvent -{ - public StateSnapshotEvent() - { - this.Type = AGUIEventTypes.StateSnapshot; - } - - [JsonPropertyName("snapshot")] - public JsonElement? Snapshot { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageContentEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageContentEvent.cs deleted file mode 100644 index 7c0c3150555..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageContentEvent.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 TextMessageContentEvent : BaseEvent -{ - public TextMessageContentEvent() - { - this.Type = AGUIEventTypes.TextMessageContent; - } - - [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/TextMessageEndEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageEndEvent.cs deleted file mode 100644 index 0c12363859f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageEndEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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 TextMessageEndEvent : BaseEvent -{ - public TextMessageEndEvent() - { - this.Type = AGUIEventTypes.TextMessageEnd; - } - - [JsonPropertyName("messageId")] - public string MessageId { get; set; } = string.Empty; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageStartEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageStartEvent.cs deleted file mode 100644 index cd6fad7de90..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/TextMessageStartEvent.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 TextMessageStartEvent : BaseEvent -{ - public TextMessageStartEvent() - { - this.Type = AGUIEventTypes.TextMessageStart; - } - - [JsonPropertyName("messageId")] - public string MessageId { get; set; } = string.Empty; - - [JsonPropertyName("role")] - public string Role { get; set; } = string.Empty; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallArgsEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallArgsEvent.cs deleted file mode 100644 index 27b05936998..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallArgsEvent.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 ToolCallArgsEvent : BaseEvent -{ - public ToolCallArgsEvent() - { - this.Type = AGUIEventTypes.ToolCallArgs; - } - - [JsonPropertyName("toolCallId")] - public string ToolCallId { get; set; } = string.Empty; - - [JsonPropertyName("delta")] - public string Delta { get; set; } = string.Empty; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallEndEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallEndEvent.cs deleted file mode 100644 index e78e6b89d9e..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallEndEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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 ToolCallEndEvent : BaseEvent -{ - public ToolCallEndEvent() - { - this.Type = AGUIEventTypes.ToolCallEnd; - } - - [JsonPropertyName("toolCallId")] - public string ToolCallId { get; set; } = string.Empty; -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallResultEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallResultEvent.cs deleted file mode 100644 index e60265be688..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallResultEvent.cs +++ /dev/null @@ -1,29 +0,0 @@ -// 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 ToolCallResultEvent : BaseEvent -{ - public ToolCallResultEvent() - { - this.Type = AGUIEventTypes.ToolCallResult; - } - - [JsonPropertyName("messageId")] - public string? MessageId { get; set; } - - [JsonPropertyName("toolCallId")] - public string ToolCallId { get; set; } = string.Empty; - - [JsonPropertyName("content")] - public string Content { get; set; } = string.Empty; - - [JsonPropertyName("role")] - public string? Role { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallStartEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallStartEvent.cs deleted file mode 100644 index e2f7bed120d..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallStartEvent.cs +++ /dev/null @@ -1,26 +0,0 @@ -// 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 ToolCallStartEvent : BaseEvent -{ - public ToolCallStartEvent() - { - this.Type = AGUIEventTypes.ToolCallStart; - } - - [JsonPropertyName("toolCallId")] - public string ToolCallId { get; set; } = string.Empty; - - [JsonPropertyName("toolCallName")] - public string ToolCallName { get; set; } = string.Empty; - - [JsonPropertyName("parentMessageId")] - public string? ParentMessageId { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIChatResponseUpdateStreamExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIChatResponseUpdateStreamExtensions.cs deleted file mode 100644 index c824331f601..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIChatResponseUpdateStreamExtensions.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; - -internal static class AGUIChatResponseUpdateStreamExtensions -{ - public static async IAsyncEnumerable FilterServerToolsFromMixedToolInvocationsAsync( - this IAsyncEnumerable updates, - List? clientTools, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - if (clientTools is null || clientTools.Count == 0) - { - await foreach (var update in updates.WithCancellation(cancellationToken)) - { - yield return update; - } - yield break; - } - - var set = new HashSet(clientTools.Count); - foreach (var tool in clientTools) - { - set.Add(tool.Name); - } - - await foreach (var update in updates.WithCancellation(cancellationToken)) - { - if (update.FinishReason == ChatFinishReason.ToolCalls) - { - var containsClientTools = false; - var containsServerTools = false; - for (var i = update.Contents.Count - 1; i >= 0; i--) - { - var content = update.Contents[i]; - if (content is FunctionCallContent functionCallContent) - { - containsClientTools |= set.Contains(functionCallContent.Name); - containsServerTools |= !set.Contains(functionCallContent.Name); - if (containsClientTools && containsServerTools) - { - break; - } - } - } - - if (containsClientTools && containsServerTools) - { - var newContents = new List(); - for (var i = update.Contents.Count - 1; i >= 0; i--) - { - var content = update.Contents[i]; - if (content is not FunctionCallContent fcc || - set.Contains(fcc.Name)) - { - newContents.Add(content); - } - } - - yield return new ChatResponseUpdate(update.Role, newContents) - { - ConversationId = update.ConversationId, - ResponseId = update.ResponseId, - FinishReason = update.FinishReason, - AdditionalProperties = update.AdditionalProperties, - AuthorName = update.AuthorName, - CreatedAt = update.CreatedAt, - MessageId = update.MessageId, - ModelId = update.ModelId - }; - } - else - { - yield return update; - } - } - else - { - yield return update; - } - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index 0d4c390bbb8..d6f4210aa32 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -3,18 +3,20 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; +using AGUI.Abstractions; +using AGUI.Server; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +#if !NET10_0_OR_GREATER using Microsoft.Extensions.Logging; +#endif using Microsoft.Extensions.Options; namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; @@ -22,6 +24,12 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; /// /// Provides extension methods for mapping AG-UI agents to ASP.NET Core endpoints. /// +/// +/// The pipeline that converts streams into AG-UI events is provided by +/// the public AG-UI .NET SDK (ChatResponseUpdateAGUIExtensions.AsAGUIEventStreamAsync). +/// This class layers Agent Framework concerns (, , +/// ) on top of that pipeline. +/// public static class AGUIEndpointRouteBuilderExtensions { /// @@ -31,14 +39,14 @@ public static class AGUIEndpointRouteBuilderExtensions /// The hosted agent builder that identifies the agent registration. /// The URL pattern for the endpoint. /// An for the mapped endpoint. - public static IEndpointConventionBuilder MapAGUI( + public static IEndpointConventionBuilder MapAGUIServer( this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, [StringSyntax("route")] string pattern) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapAGUI(agentBuilder.Name, pattern); + return endpoints.MapAGUIServer(agentBuilder.Name, pattern); } /// @@ -48,7 +56,7 @@ public static IEndpointConventionBuilder MapAGUI( /// The name of the keyed agent registration to resolve from dependency injection. /// The URL pattern for the endpoint. /// An for the mapped endpoint. - public static IEndpointConventionBuilder MapAGUI( + public static IEndpointConventionBuilder MapAGUIServer( this IEndpointRouteBuilder endpoints, string agentName, [StringSyntax("route")] string pattern) @@ -57,7 +65,7 @@ public static IEndpointConventionBuilder MapAGUI( ArgumentNullException.ThrowIfNull(agentName); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapAGUI(pattern, agent); + return endpoints.MapAGUIServer(pattern, agent); } /// @@ -94,7 +102,7 @@ public static IEndpointConventionBuilder MapAGUI( /// multi-user hosts. /// /// - public static IEndpointConventionBuilder MapAGUI( + public static IEndpointConventionBuilder MapAGUIServer( this IEndpointRouteBuilder endpoints, [StringSyntax("route")] string pattern, AIAgent aiAgent) @@ -114,58 +122,48 @@ public static IEndpointConventionBuilder MapAGUI( var hostAgent = new AIHostAgent(aiAgent, agentSessionStore); - return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) => + return endpoints.MapPost(pattern, async ( + [FromBody] RunAgentInput? input, + [FromServices] IOptions jsonOptions, + HttpContext context, + CancellationToken cancellationToken) => { if (input is null) { return Results.BadRequest(); } - var jsonOptions = context.RequestServices.GetRequiredService>(); var jsonSerializerOptions = jsonOptions.Value.SerializerOptions; + var streamOptions = context.GetEndpoint()?.Metadata.GetMetadata() + ?? context.RequestServices.GetService>()?.Value; - var messages = input.Messages.AsChatMessages(jsonSerializerOptions); - var clientTools = input.Tools?.AsAITools().ToList(); + var ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions); - // Create run options with AG-UI context in AdditionalProperties - var runOptions = new ChatClientAgentRunOptions - { - ChatOptions = new ChatOptions - { - Tools = clientTools, - AdditionalProperties = new AdditionalPropertiesDictionary - { - ["ag_ui_state"] = input.State, - ["ag_ui_context"] = input.Context?.Select(c => new KeyValuePair(c.Description, c.Value)).ToArray(), - ["ag_ui_forwarded_properties"] = input.ForwardedProperties, - ["ag_ui_thread_id"] = input.ThreadId, - ["ag_ui_run_id"] = input.RunId - } - } - }; - - var threadId = string.IsNullOrWhiteSpace(input.ThreadId) ? Guid.NewGuid().ToString("N") : input.ThreadId; + var threadId = string.IsNullOrWhiteSpace(ctx.Input.ThreadId) ? Guid.NewGuid().ToString("N") : ctx.Input.ThreadId; var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false); - // Run the agent and convert to AG-UI events - var events = hostAgent.RunStreamingAsync( - messages, - session: session, - options: runOptions, - cancellationToken: cancellationToken) + var events = hostAgent + .RunStreamingAsync( + ctx.Messages, + session: session, + options: new ChatClientAgentRunOptions { ChatOptions = ctx.ChatOptions }, + cancellationToken: cancellationToken) .AsChatResponseUpdatesAsync() - .FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken) - .AsAGUIEventStreamAsync( - threadId, - input.RunId, - jsonSerializerOptions, - cancellationToken); - - // Wrap the event stream to save the session after streaming completes + .AsAGUIEventStreamAsync(ctx, cancellationToken); + + // Wrap the event stream to save the session after streaming completes. var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken); +#if NET10_0_OR_GREATER + // On net10+ the framework provides first-class SSE result that flows through the + // configured ASP.NET Core JsonSerializerOptions (which AddAGUIServer() augments with + // AGUIJsonSerializerContext via the resolver chain). + return TypedResults.ServerSentEvents(eventsWithSessionSave); +#else + // On older TFMs we ship a small polyfill that emulates TypedResults.ServerSentEvents. var sseLogger = context.RequestServices.GetRequiredService>(); return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger); +#endif }); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIJsonSerializerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIJsonSerializerOptions.cs deleted file mode 100644 index 822f6f27e7f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIJsonSerializerOptions.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json; - -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; - -/// -/// Extension methods for JSON serialization. -/// -internal static class AGUIJsonSerializerOptions -{ - /// - /// Gets the default JSON serializer options. - /// - public static JsonSerializerOptions Default { get; } = Create(); - - private static JsonSerializerOptions Create() - { - JsonSerializerOptions options = new(AGUIJsonSerializerContext.Default.Options); - options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); - options.MakeReadOnly(); - return options; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIServerSentEventsResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIServerSentEventsResult.cs index 95642771ffa..e0f96369e6a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIServerSentEventsResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIServerSentEventsResult.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#if !NET10_0_OR_GREATER + using System; using System.Buffers; using System.Collections.Generic; @@ -8,12 +10,18 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; +using AGUI.Abstractions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +/// +/// Streams an to the client as a Server-Sent Events +/// response. Polyfill for TypedResults.ServerSentEvents on target frameworks older than +/// net10.0; on net10.0+ the framework API is used directly from +/// . +/// internal sealed partial class AGUIServerSentEventsResult : IResult, IDisposable { private readonly IAsyncEnumerable _events; @@ -28,10 +36,7 @@ internal AGUIServerSentEventsResult(IAsyncEnumerable events, ILogger< public async Task ExecuteAsync(HttpContext httpContext) { - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } + ArgumentNullException.ThrowIfNull(httpContext); httpContext.Response.ContentType = "text/event-stream"; httpContext.Response.Headers.CacheControl = "no-cache,no-store"; @@ -51,13 +56,15 @@ await SseFormatter.WriteAsync( catch (Exception ex) when (ex is not OperationCanceledException) { LogStreamingError(this._logger, ex); - // If an error occurs during streaming, try to send an error event before closing try { var errorEvent = new RunErrorEvent { Code = "StreamingError", - Message = ex.Message + + // Do not surface the raw exception message to the client; it can leak internal + // details. The full exception is recorded server-side via LogStreamingError above. + Message = "An error occurred while streaming the agent response.", }; await SseFormatter.WriteAsync( WrapEventsAsSseItemsAsync([errorEvent]), @@ -67,7 +74,6 @@ await SseFormatter.WriteAsync( } catch (Exception sendErrorEx) { - // If we can't send the error event, just let the connection close LogSendErrorEventFailed(this._logger, sendErrorEx); } } @@ -92,11 +98,13 @@ private static async IAsyncEnumerable> WrapEventsAsSseItemsAs { yield return new SseItem(evt); } + + await Task.CompletedTask.ConfigureAwait(false); } private void SerializeEvent(SseItem item, IBufferWriter writer) { - if (this._jsonWriter == null) + if (this._jsonWriter is null) { this._jsonWriter = new Utf8JsonWriter(writer); } @@ -104,6 +112,7 @@ private void SerializeEvent(SseItem item, IBufferWriter writer) { this._jsonWriter.Reset(writer); } + JsonSerializer.Serialize(this._jsonWriter, item.Data, AGUIJsonSerializerContext.Default.BaseEvent); } @@ -124,3 +133,5 @@ public void Dispose() SkipEnabledCheck = true)] private static partial void LogSendErrorEventFailed(ILogger logger, Exception exception); } + +#endif diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIServerServiceCollectionExtensions.cs similarity index 62% rename from dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ServiceCollectionExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIServerServiceCollectionExtensions.cs index e159c0727ef..7548bf784e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIServerServiceCollectionExtensions.cs @@ -1,28 +1,30 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace Microsoft.Extensions.DependencyInjection; /// /// Extension methods for to configure AG-UI support. /// -public static class MicrosoftAgentAIHostingAGUIServiceCollectionExtensions +public static class AGUIServerServiceCollectionExtensions { /// /// Adds support for exposing instances via AG-UI. /// /// The to configure. /// The for method chaining. - public static IServiceCollection AddAGUI(this IServiceCollection services) + public static IServiceCollection AddAGUIServer(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - services.Configure(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIJsonSerializerOptions.Default.TypeInfoResolver!)); + services.TryAddEnumerable(ServiceDescriptor.Transient, ConfigureAGUIJsonOptions>()); return services; } -} +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs new file mode 100644 index 00000000000..e5675d0c36b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AGUI.Abstractions; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Options; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; + +/// +/// Configures the ASP.NET Core used to (de)serialize AG-UI requests and +/// responses so that the AG-UI wire types and the Agent Framework abstractions are resolvable. +/// +internal sealed class ConfigureAGUIJsonOptions : IConfigureOptions +{ + public void Configure(JsonOptions options) + { + var chain = options.SerializerOptions.TypeInfoResolverChain; + + // Agent Framework abstractions first to ensure M.E.AI types are handled via its resolver, + // followed by the AG-UI wire-format resolver for protocol types (the AG-UI context is needed + // on the net10 TypedResults.ServerSentEvents path, which serializes events through the + // configured ASP.NET Core JsonSerializerOptions). + chain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + chain.Add(AGUIJsonSerializerContext.Default.Options.TypeInfoResolver!); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index 1565977149b..3a46871daad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -4,7 +4,6 @@ $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.AGUI.AspNetCore preview - $(DefineConstants);ASPNETCORE $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated true @@ -20,17 +19,14 @@ + + - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs deleted file mode 100644 index d5890bb5f20..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs +++ /dev/null @@ -1,1782 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.AGUI.Shared; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.AGUI.UnitTests; - -public sealed class AGUIAgentTests -{ - [Fact] - public async Task RunAsync_AggregatesStreamingUpdates_ReturnsCompleteMessagesAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageContentEvent { MessageId = "msg1", Delta = " World" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - AgentResponse response = await agent.RunAsync(messages); - - // Assert - Assert.NotNull(response); - Assert.NotEmpty(response.Messages); - ChatMessage message = response.Messages.First(); - Assert.Equal(ChatRole.Assistant, message.Role); - Assert.Equal("Hello World", message.Text); - } - - [Fact] - public async Task RunAsync_WithEmptyUpdateStream_ContainsOnlyMetadataMessagesAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - AgentResponse response = await agent.RunAsync(messages); - - // Assert - Assert.NotNull(response); - // RunStarted and RunFinished events are aggregated into messages by ToChatResponse() - Assert.NotEmpty(response.Messages); - Assert.All(response.Messages, m => Assert.Equal(ChatRole.Assistant, m.Role)); - } - - [Fact] - public async Task RunAsync_WithNullMessages_ThrowsArgumentNullExceptionAsync() - { - // Arrange - using HttpClient httpClient = new(); - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: "Test agent", name: "agent1"); - - // Act & Assert - await Assert.ThrowsAsync(() => agent.RunAsync(messages: null!)); - } - - [Fact] - public async Task RunAsync_WithNullSession_CreatesNewSessionAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: "Test agent", name: "agent1"); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - AgentResponse response = await agent.RunAsync(messages, session: null); - - // Assert - Assert.NotNull(response); - } - - [Fact] - public async Task RunStreamingAsync_YieldsAllEvents_FromServerStreamAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: "Test agent", name: "agent1"); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages)) - { - // Consume the stream - updates.Add(update); - } - - // Assert - Assert.NotEmpty(updates); - Assert.Contains(updates, u => u.ResponseId != null); // RunStarted sets ResponseId - Assert.Contains(updates, u => u.Contents.Any(c => c is TextContent)); - Assert.Contains(updates, u => u.Contents.Count == 0 && u.ResponseId != null); // RunFinished has no text content - } - - [Fact] - public async Task RunStreamingAsync_WithNullMessages_ThrowsArgumentNullExceptionAsync() - { - // Arrange - using HttpClient httpClient = new(); - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: "Test agent", name: "agent1"); - - // Act & Assert - await Assert.ThrowsAsync(async () => - { - await foreach (var _ in agent.RunStreamingAsync(messages: null!)) - { - // Intentionally empty - consuming stream to trigger exception - } - }); - } - - [Fact] - public async Task RunStreamingAsync_WithNullSession_CreatesNewSessionAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: "Test agent", name: "agent1"); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session: null)) - { - // Consume the stream - updates.Add(update); - } - - // Assert - Assert.NotEmpty(updates); - } - - [Fact] - public async Task RunStreamingAsync_GeneratesUniqueRunId_ForEachInvocationAsync() - { - // Arrange - var handler = new TestDelegatingHandler(); - handler.AddResponseWithCapture( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - handler.AddResponseWithCapture( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - await foreach (var _ in agent.RunStreamingAsync(messages)) - { - // Consume the stream - } - await foreach (var _ in agent.RunStreamingAsync(messages)) - { - // Consume the stream - } - - // Assert - Assert.Equal(2, handler.CapturedRunIds.Count); - Assert.NotEqual(handler.CapturedRunIds[0], handler.CapturedRunIds[1]); - } - - [Fact] - public async Task RunStreamingAsync_ReturnsStreamingUpdates_AfterCompletionAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []); - AgentSession session = await agent.CreateSessionAsync(); - List messages = [new ChatMessage(ChatRole.User, "Hello")]; - - // Act - List updates = []; - await foreach (var update in agent.RunStreamingAsync(messages, session)) - { - updates.Add(update); - } - - // Assert - Verify streaming updates were received - Assert.NotEmpty(updates); - Assert.Contains(updates, u => u.Text == "Hello"); - } - - [Fact] - public async Task RunStreamingAsync_WithSession_SendsFullHistoryAfterThreadIdIsSetAsync() - { - // Arrange - var captureHandler = new StateCapturingTestDelegatingHandler(); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "First response" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Second response" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(captureHandler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []); - AgentSession session = await agent.CreateSessionAsync(); - - // Act - await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "First")], session)) - { - } - - await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Second")], session)) - { - } - - // Assert - Assert.Equal([1, 3], captureHandler.CapturedMessageCounts); - } - - [Fact] - public async Task DeserializeSession_WithValidState_ReturnsChatClientAgentSessionAsync() - { - // Arrange - using var httpClient = new HttpClient(); - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []); - AgentSession originalSession = await agent.CreateSessionAsync(); - JsonElement serialized = await agent.SerializeSessionAsync(originalSession); - - // Act - AgentSession deserialized = await agent.DeserializeSessionAsync(serialized); - - // Assert - Assert.NotNull(deserialized); - Assert.IsType(deserialized); - } - - private HttpClient CreateMockHttpClient(BaseEvent[] events) - { - var handler = new TestDelegatingHandler(); - handler.AddResponse(events); - return new HttpClient(handler); - } - - [Fact] - public async Task RunStreamingAsync_InvokesTools_WhenFunctionCallsReturnedAsync() - { - // Arrange - bool toolInvoked = false; - AIFunction testTool = AIFunctionFactory.Create( - (string location) => - { - toolInvoked = true; - return $"Weather in {location}: Sunny, 72°F"; - }, - "GetWeather", - "Gets the current weather for a location"); - - using HttpClient httpClient = this.CreateMockHttpClientForToolCalls( - firstResponse: - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "GetWeather", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"location\":\"Seattle\"}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ], - secondResponse: - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "The weather is nice!" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [testTool]); - List messages = [new ChatMessage(ChatRole.User, "What's the weather?")]; - - // Act - List allUpdates = []; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages)) - { - allUpdates.Add(update); - } - - // Assert - Assert.True(toolInvoked, "Tool should have been invoked"); - Assert.NotEmpty(allUpdates); - // Should have updates from both the tool call and the final response - Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionCallContent)); - Assert.Contains(allUpdates, u => u.Contents.Any(c => c is TextContent)); - } - - [Fact] - public async Task RunStreamingAsync_DoesNotInvokeTools_WhenSomeToolsNotAvailableAsync() - { - // Arrange - bool tool1Invoked = false; - AIFunction tool1 = AIFunctionFactory.Create( - () => { tool1Invoked = true; return "Result1"; }, - "Tool1"); - - // FunctionInvokingChatClient makes two calls: first gets tool calls, second returns final response - // When not all tools are available, it invokes the ones that ARE available - var handler = new TestDelegatingHandler(); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Response" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [tool1]); // Only tool1, not tool2 - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List allUpdates = []; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages)) - { - allUpdates.Add(update); - } - - // Assert - // FunctionInvokingChatClient invokes Tool1 since it's available, even though Tool2 is not - Assert.True(tool1Invoked, "Tool1 should be invoked even though Tool2 is not available"); - // Should have tool call results for Tool1 and an error result for Tool2 - Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "call_1")); - } - - [Fact] - public async Task RunStreamingAsync_HandlesToolInvocationErrors_GracefullyAsync() - { - // Arrange - AIFunction faultyTool = AIFunctionFactory.Create( - () => - { - throw new InvalidOperationException("Tool failed!"); -#pragma warning disable CS0162 // Unreachable code detected - return string.Empty; -#pragma warning restore CS0162 // Unreachable code detected - }, - "FaultyTool"); - - using HttpClient httpClient = this.CreateMockHttpClientForToolCalls( - firstResponse: - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "FaultyTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ], - secondResponse: - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "I encountered an error." }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [faultyTool]); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List allUpdates = []; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages)) - { - allUpdates.Add(update); - } - - // Assert - should complete without throwing - Assert.NotEmpty(allUpdates); - } - - [Fact] - public async Task RunStreamingAsync_InvokesMultipleTools_InSingleTurnAsync() - { - // Arrange - int tool1CallCount = 0; - int tool2CallCount = 0; - AIFunction tool1 = AIFunctionFactory.Create(() => { tool1CallCount++; return "Result1"; }, "Tool1"); - AIFunction tool2 = AIFunctionFactory.Create(() => { tool2CallCount++; return "Result2"; }, "Tool2"); - - using HttpClient httpClient = this.CreateMockHttpClientForToolCalls( - firstResponse: - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ], - secondResponse: - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [tool1, tool2]); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - await foreach (var _ in agent.RunStreamingAsync(messages)) - { - } - - // Assert - Assert.Equal(1, tool1CallCount); - Assert.Equal(1, tool2CallCount); - } - - [Fact] - public async Task RunStreamingAsync_UpdatesSessionWithToolMessages_AfterCompletionAsync() - { - // Arrange - AIFunction testTool = AIFunctionFactory.Create(() => "Result", "TestTool"); - - using HttpClient httpClient = this.CreateMockHttpClientForToolCalls( - firstResponse: - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "TestTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ], - secondResponse: - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Complete" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [testTool]); - AgentSession session = await agent.CreateSessionAsync(); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in agent.RunStreamingAsync(messages, session)) - { - updates.Add(update); - } - - // Assert - Verify we received updates including tool calls - Assert.NotEmpty(updates); - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent)); - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionResultContent)); - Assert.Contains(updates, u => u.Text == "Complete"); - } - - private HttpClient CreateMockHttpClientForToolCalls(BaseEvent[] firstResponse, BaseEvent[] secondResponse) - { - var handler = new TestDelegatingHandler(); - handler.AddResponse(firstResponse); - handler.AddResponse(secondResponse); - return new HttpClient(handler); - } - - [Fact] - public async Task GetStreamingResponseAsync_WrapsServerFunctionCalls_InServerFunctionCallContentAsync() - { - // Arrange - Server returns a function call for a tool not in the client tool set - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ServerTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"arg\":\"value\"}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - // No tools provided - any function call from server is a "server function" - var options = new ChatOptions(); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - updates.Add(update); - } - - // Assert - Server function call should be presented as FunctionCallContent (unwrapped) - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool")); - // Should NOT contain ServerFunctionCallContent (it's internal and unwrapped before yielding) - Assert.DoesNotContain(updates, u => u.Contents.Any(c => c.GetType().Name == "ServerFunctionCallContent")); - } - - [Fact] - public async Task GetStreamingResponseAsync_DoesNotWrapClientFunctionCalls_WhenToolInClientSetAsync() - { - // Arrange - AIFunction clientTool = AIFunctionFactory.Create(() => "Result", "ClientTool"); - - var handler = new TestDelegatingHandler(); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ClientTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { Tools = [clientTool] }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - updates.Add(update); - } - - // Assert - Should have function call and result (FunctionInvokingChatClient processed it) - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ClientTool")); - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "call_1")); - } - - [Fact] - public async Task GetStreamingResponseAsync_HandlesMixedClientAndServerFunctions_InSameResponseAsync() - { - // Arrange - AIFunction clientTool = AIFunctionFactory.Create(() => "ClientResult", "ClientTool"); - - var handler = new TestDelegatingHandler(); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ClientTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "ServerTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { Tools = [clientTool] }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - updates.Add(update); - } - - // Assert - Should have both client and server function calls - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ClientTool")); - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool")); - // Client tool should have result - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "call_1")); - } - - [Fact] - public async Task GetStreamingResponseAsync_PreservesConversationId_AcrossMultipleTurnsAsync() - { - // Arrange - var handler = new TestDelegatingHandler(); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "First" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Second" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { ConversationId = "my-conversation-123" }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - First turn - List updates1 = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - updates1.Add(update); - } - - // Second turn with same conversation ID - List updates2 = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - updates2.Add(update); - } - - // Assert - Both turns should preserve the conversation ID - Assert.All(updates1, u => Assert.Equal("my-conversation-123", u.ConversationId)); - Assert.All(updates2, u => Assert.Equal("my-conversation-123", u.ConversationId)); - } - - [Fact] - public async Task GetStreamingResponseAsync_ExtractsThreadId_FromServerResponseAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "server-session-456", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "server-session-456", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - // No conversation ID provided - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null)) - { - updates.Add(update); - } - - // Assert - Should use session ID from server - Assert.All(updates, u => Assert.Equal("server-session-456", u.ConversationId)); - } - - [Fact] - public async Task GetStreamingResponseAsync_GeneratesThreadId_WhenNoneProvidedAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null)) - { - updates.Add(update); - } - - // Assert - Should have a conversation ID (either from server or generated) - Assert.All(updates, u => Assert.NotNull(u.ConversationId)); - Assert.All(updates, u => Assert.NotEmpty(u.ConversationId!)); - } - - [Fact] - public async Task GetStreamingResponseAsync_RemovesThreadIdFromFunctionCallProperties_BeforeYieldingAsync() - { - // Arrange - AIFunction clientTool = AIFunctionFactory.Create(() => "Result", "ClientTool"); - - var handler = new TestDelegatingHandler(); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ClientTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { Tools = [clientTool] }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - updates.Add(update); - } - - // Assert - Function call content should not have agui_thread_id in additional properties - var functionCallUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is FunctionCallContent)); - Assert.NotNull(functionCallUpdate); - var fcc = functionCallUpdate.Contents.OfType().First(); - Assert.True(fcc.AdditionalProperties?.ContainsKey("agui_thread_id") != true); - } - - [Fact] - public async Task GetResponseAsync_PreservesConversationId_ThroughStreamingPathAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { ConversationId = "my-conversation-456" }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - ChatResponse response = await chatClient.GetResponseAsync(messages, options); - - // Assert - Assert.Equal("my-conversation-456", response.ConversationId); - } - - [Fact] - public async Task GetStreamingResponseAsync_UsesServerThreadId_WhenDifferentFromClientAsync() - { - // Arrange - Server returns different session ID - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "server-generated-session", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "server-generated-session", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { ConversationId = "client-session-123" }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - updates.Add(update); - } - - // Assert - Should use client's conversation ID (we provided it explicitly) - Assert.All(updates, u => Assert.Equal("client-session-123", u.ConversationId)); - } - - [Fact] - public async Task GetStreamingResponseAsync_FullConversationFlow_WithMixedFunctionsAsync() - { - // Arrange - AIFunction clientTool = AIFunctionFactory.Create(() => "ClientResult", "ClientTool"); - - var handler = new TestDelegatingHandler(); - // First response: client function call (FunctionInvokingChatClient will handle this) - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_client", ToolCallName = "ClientTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_client", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_client" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - // Second response: after client function execution, return final text - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Complete" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { Tools = [clientTool] }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - string? conversationId = null; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - updates.Add(update); - conversationId ??= update.ConversationId; - } - - // Assert - // Should have client function call and result - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ClientTool")); - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "call_client")); - // Should have final text response - Assert.Contains(updates, u => u.Contents.Any(c => c is TextContent)); - // All updates should have consistent conversation ID - Assert.NotNull(conversationId); - Assert.All(updates, u => Assert.Equal(conversationId, u.ConversationId)); - } - - [Fact] - public async Task GetStreamingResponseAsync_ExtractsThreadIdFromFunctionCall_OnSubsequentTurnsAsync() - { - // Arrange - AIFunction clientTool = AIFunctionFactory.Create(() => "Result", "ClientTool"); - - var handler = new TestDelegatingHandler(); - // First turn: client function call - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ClientTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - // FunctionInvokingChatClient automatically calls again after function execution - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "First done" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - // Third turn: user makes another request with conversation history - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run3" }, - new TextMessageStartEvent { MessageId = "msg3", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg3", Delta = "Second done" }, - new TextMessageEndEvent { MessageId = "msg3" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run3" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { Tools = [clientTool] }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - First turn - List conversation = [.. messages]; - string? conversationId = null; - await foreach (var update in chatClient.GetStreamingResponseAsync(conversation, options)) - { - conversationId ??= update.ConversationId; - // Collect all updates to build the conversation history - foreach (var content in update.Contents) - { - if (content is FunctionCallContent fcc) - { - conversation.Add(new ChatMessage(ChatRole.Assistant, [fcc])); - } - else if (content is FunctionResultContent frc) - { - conversation.Add(new ChatMessage(ChatRole.Tool, [frc])); - } - else if (content is TextContent tc) - { - var existingAssistant = conversation.LastOrDefault(m => m.Role == ChatRole.Assistant && m.Contents.Any(c => c is TextContent)); - if (existingAssistant == null) - { - conversation.Add(new ChatMessage(ChatRole.Assistant, [tc])); - } - } - } - } - - // Act - Second turn with conversation history including function call - // The session ID should be extracted from the function call in the conversation history - options.ConversationId = conversationId; - List secondTurnUpdates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(conversation, options)) - { - secondTurnUpdates.Add(update); - } - - // Assert - Second turn should maintain the same conversation ID - Assert.NotNull(conversationId); - Assert.All(secondTurnUpdates, u => Assert.Equal(conversationId, u.ConversationId)); - Assert.Contains(secondTurnUpdates, u => u.Contents.Any(c => c is TextContent)); - } - - [Fact] - public async Task GetStreamingResponseAsync_MaintainsConsistentThreadId_AcrossMultipleTurnsAsync() - { - // Arrange - var handler = new TestDelegatingHandler(); - // Turn 1 - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Response 1" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - // Turn 2 - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Response 2" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - // Turn 3 - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run3" }, - new TextMessageStartEvent { MessageId = "msg3", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg3", Delta = "Response 3" }, - new TextMessageEndEvent { MessageId = "msg3" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run3" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { ConversationId = "my-conversation" }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - Execute 3 turns - string? conversationId = null; - for (int i = 0; i < 3; i++) - { - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - conversationId ??= update.ConversationId; - Assert.Equal("my-conversation", update.ConversationId); - } - } - - // Assert - Assert.Equal("my-conversation", conversationId); - } - - [Fact] - public async Task GetStreamingResponseAsync_HandlesEmptyThreadId_GracefullyAsync() - { - // Arrange - Server returns empty session ID - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = string.Empty, RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = string.Empty, RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null)) - { - updates.Add(update); - } - - // Assert - Should generate a conversation ID even with empty server session ID - Assert.NotEmpty(updates); - Assert.All(updates, u => Assert.NotNull(u.ConversationId)); - Assert.All(updates, u => Assert.NotEmpty(u.ConversationId!)); - } - - [Fact] - public async Task GetStreamingResponseAsync_AdaptsToServerThreadIdChange_MidConversationAsync() - { - // Arrange - var handler = new TestDelegatingHandler(); - // First turn: server returns session-A - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "session-A", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "First" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "session-A", RunId = "run1" } - ]); - // Second turn: provide session-A but server returns session-B - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "session-B", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Second" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "session-B", RunId = "run2" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - First turn - string? firstConversationId = null; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null)) - { - firstConversationId ??= update.ConversationId; - } - - // Second turn - provide the conversation ID from first turn - var options = new ChatOptions { ConversationId = firstConversationId }; - string? secondConversationId = null; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - secondConversationId ??= update.ConversationId; - } - - // Assert - Should use client-provided conversation ID, not server's changed ID - Assert.Equal("session-A", firstConversationId); - Assert.Equal("session-A", secondConversationId); // Client overrides server's session-B - } - - [Fact] - public async Task GetStreamingResponseAsync_PresentsServerFunctionResults_AsRegularFunctionResultsAsync() - { - // Arrange - Server function (not in client tool set) - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ServerTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"arg\":\"value\"}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null)) - { - updates.Add(update); - } - - // Assert - Server function should be presented as FunctionCallContent (unwrapped from ServerFunctionCallContent) - Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool")); - // Verify it's NOT a ServerFunctionCallContent (internal type should be unwrapped) - Assert.All(updates, u => Assert.DoesNotContain(u.Contents, c => c.GetType().Name == "ServerFunctionCallContent")); - } - - [Fact] - public async Task GetStreamingResponseAsync_HandlesMultipleServerFunctions_InSequenceAsync() - { - // Arrange - var handler = new TestDelegatingHandler(); - // Turn 1: Server function 1 - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ServerTool1", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - // Turn 2: Server function 2 - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "ServerTool2", ParentMessageId = "msg2" }, - new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - // Turn 3: Final response - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run3" }, - new TextMessageStartEvent { MessageId = "msg3", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg3", Delta = "Complete" }, - new TextMessageEndEvent { MessageId = "msg3" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run3" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { ConversationId = "conv1" }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - Execute all 3 turns - List allUpdates = []; - for (int i = 0; i < 3; i++) - { - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - allUpdates.Add(update); - } - } - - // Assert - Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool1")); - Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool2")); - Assert.Contains(allUpdates, u => u.Contents.Any(c => c is TextContent)); - Assert.All(allUpdates, u => Assert.Equal("conv1", u.ConversationId)); - } - - [Fact] - public async Task GetStreamingResponseAsync_MaintainsThreadIdConsistency_WithOnlyServerFunctionsAsync() - { - // Arrange - Full conversation with only server functions - var handler = new TestDelegatingHandler(); - // Turn 1: Server function - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ServerTool", ParentMessageId = "msg1" }, - new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "call_1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - // Turn 2: Final response - handler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" }, - new TextMessageEndEvent { MessageId = "msg2" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(handler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - string? conversationId = null; - List allUpdates = []; - for (int i = 0; i < 2; i++) - { - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null)) - { - conversationId ??= update.ConversationId; - allUpdates.Add(update); - } - } - - // Assert - Thread ID should be consistent without client function invocations - Assert.NotNull(conversationId); - Assert.All(allUpdates, u => Assert.Equal(conversationId, u.ConversationId)); - Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionCallContent)); - Assert.Contains(allUpdates, u => u.Contents.Any(c => c is TextContent)); - } - - [Fact] - public async Task GetStreamingResponseAsync_StoresConversationIdInAdditionalProperties_WithoutMutatingOptionsAsync() - { - // Arrange - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { ConversationId = "my-conversation-123" }; - var originalConversationId = options.ConversationId; - var originalAdditionalProperties = options.AdditionalProperties; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options)) - { - // Just consume the stream - } - - // Assert - Original options should not be mutated - Assert.Equal(originalConversationId, options.ConversationId); - Assert.Equal(originalAdditionalProperties, options.AdditionalProperties); - } - - [Fact] - public async Task GetStreamingResponseAsync_EnsuresConversationIdIsNull_ForInnerClientAsync() - { - // Arrange - Use a custom handler to capture what's sent to the inner layer - var captureHandler = new CapturingTestDelegatingHandler(); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - using HttpClient httpClient = new(captureHandler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - var options = new ChatOptions { ConversationId = "my-conversation-123" }; - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, options)) - { - // Just consume the stream - } - - // Assert - The inner handler should see the full message history being sent - // This is implicitly tested by the fact that all messages are sent in the request - // AG-UI requirement: full history on every turn (which happens when ConversationId is null for FunctionInvokingChatClient) - Assert.True(captureHandler.RequestWasMade); - } - - [Fact] - public async Task GetStreamingResponseAsync_ExtractsStateFromDataContent_AndRemovesStateMessageAsync() - { - // Arrange - var stateData = new { counter = 42, status = "active" }; - string stateJson = JsonSerializer.Serialize(stateData); - byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson); - var dataContent = new DataContent(stateBytes, "application/json"); - - var captureHandler = new StateCapturingTestDelegatingHandler(); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Response" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - using HttpClient httpClient = new(captureHandler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = - [ - new ChatMessage(ChatRole.User, "Hello"), - new ChatMessage(ChatRole.System, [dataContent]) - ]; - - // Act - await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null)) - { - // Just consume the stream - } - - // Assert - Assert.True(captureHandler.RequestWasMade); - Assert.NotNull(captureHandler.CapturedState); - Assert.Equal(42, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32()); - Assert.Equal("active", captureHandler.CapturedState.Value.GetProperty("status").GetString()); - - // Verify state message was removed - only user message should be in the request - Assert.Equal(1, captureHandler.CapturedMessageCount); - } - - [Fact] - public async Task GetStreamingResponseAsync_WithNoStateDataContent_SendsEmptyStateAsync() - { - // Arrange - var captureHandler = new StateCapturingTestDelegatingHandler(); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Response" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - using HttpClient httpClient = new(captureHandler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = [new ChatMessage(ChatRole.User, "Hello")]; - - // Act - await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null)) - { - // Just consume the stream - } - - // Assert - Assert.True(captureHandler.RequestWasMade); - Assert.Null(captureHandler.CapturedState); - } - - [Fact] - public async Task GetStreamingResponseAsync_WithMalformedStateJson_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - byte[] invalidJson = System.Text.Encoding.UTF8.GetBytes("{invalid json"); - var dataContent = new DataContent(invalidJson, "application/json"); - - using HttpClient httpClient = this.CreateMockHttpClient([]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = - [ - new ChatMessage(ChatRole.User, "Hello"), - new ChatMessage(ChatRole.System, [dataContent]) - ]; - - // Act & Assert - InvalidOperationException ex = await Assert.ThrowsAsync(async () => - { - await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null)) - { - // Just consume the stream - } - }); - - Assert.Contains("Failed to deserialize state JSON", ex.Message); - } - - [Fact] - public async Task GetStreamingResponseAsync_WithEmptyStateObject_SendsEmptyObjectAsync() - { - // Arrange - var emptyState = new { }; - string stateJson = JsonSerializer.Serialize(emptyState); - byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson); - var dataContent = new DataContent(stateBytes, "application/json"); - - var captureHandler = new StateCapturingTestDelegatingHandler(); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - using HttpClient httpClient = new(captureHandler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = - [ - new ChatMessage(ChatRole.User, "Hello"), - new ChatMessage(ChatRole.System, [dataContent]) - ]; - - // Act - await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null)) - { - // Just consume the stream - } - - // Assert - Assert.True(captureHandler.RequestWasMade); - Assert.NotNull(captureHandler.CapturedState); - Assert.Equal(JsonValueKind.Object, captureHandler.CapturedState.Value.ValueKind); - } - - [Fact] - public async Task GetStreamingResponseAsync_OnlyProcessesDataContentFromLastMessage_IgnoresEarlierOnesAsync() - { - // Arrange - var oldState = new { counter = 10 }; - string oldStateJson = JsonSerializer.Serialize(oldState); - byte[] oldStateBytes = System.Text.Encoding.UTF8.GetBytes(oldStateJson); - var oldDataContent = new DataContent(oldStateBytes, "application/json"); - - var newState = new { counter = 20 }; - string newStateJson = JsonSerializer.Serialize(newState); - byte[] newStateBytes = System.Text.Encoding.UTF8.GetBytes(newStateJson); - var newDataContent = new DataContent(newStateBytes, "application/json"); - - var captureHandler = new StateCapturingTestDelegatingHandler(); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - using HttpClient httpClient = new(captureHandler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = - [ - new ChatMessage(ChatRole.User, "First message"), - new ChatMessage(ChatRole.System, [oldDataContent]), - new ChatMessage(ChatRole.User, "Second message"), - new ChatMessage(ChatRole.System, [newDataContent]) - ]; - - // Act - await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null)) - { - // Just consume the stream - } - - // Assert - Assert.True(captureHandler.RequestWasMade); - Assert.NotNull(captureHandler.CapturedState); - // Should use the new state from the last message - Assert.Equal(20, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32()); - - // Should have removed only the last state message - Assert.Equal(3, captureHandler.CapturedMessageCount); - } - - [Fact] - public async Task GetStreamingResponseAsync_WithNonJsonMediaType_IgnoresDataContentAsync() - { - // Arrange - byte[] imageData = System.Text.Encoding.UTF8.GetBytes("fake image data"); - var dataContent = new DataContent(imageData, "image/png"); - - var captureHandler = new StateCapturingTestDelegatingHandler(); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - using HttpClient httpClient = new(captureHandler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = - [ - new ChatMessage(ChatRole.User, [new TextContent("Hello"), dataContent]) - ]; - - // Act - await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null)) - { - // Just consume the stream - } - - // Assert - Assert.True(captureHandler.RequestWasMade); - Assert.Null(captureHandler.CapturedState); - // Message should not be removed since it's not state - Assert.Equal(1, captureHandler.CapturedMessageCount); - } - - [Fact] - public async Task GetStreamingResponseAsync_RoundTripState_PreservesJsonStructureAsync() - { - // Arrange - Server returns state snapshot - var returnedState = new { counter = 100, nested = new { value = "test" } }; - JsonElement stateSnapshot = JsonSerializer.SerializeToElement(returnedState); - - var captureHandler = new StateCapturingTestDelegatingHandler(); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new StateSnapshotEvent { Snapshot = stateSnapshot }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - captureHandler.AddResponse( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run2" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Done" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" } - ]); - using HttpClient httpClient = new(captureHandler); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = [new ChatMessage(ChatRole.User, "Hello")]; - - // Act - First turn: receive state - DataContent? receivedStateContent = null; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null)) - { - if (update.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")) - { - receivedStateContent = (DataContent)update.Contents.First(c => c is DataContent); - } - } - - // Second turn: send the received state back - Assert.NotNull(receivedStateContent); - messages.Add(new ChatMessage(ChatRole.System, [receivedStateContent])); - await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null)) - { - // Just consume the stream - } - - // Assert - Verify the round-tripped state - Assert.NotNull(captureHandler.CapturedState); - Assert.Equal(100, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32()); - Assert.Equal("test", captureHandler.CapturedState.Value.GetProperty("nested").GetProperty("value").GetString()); - } - - [Fact] - public async Task GetStreamingResponseAsync_ReceivesStateSnapshot_AsDataContentWithAdditionalPropertiesAsync() - { - // Arrange - var state = new { sessionId = "abc123", step = 5 }; - JsonElement stateSnapshot = JsonSerializer.SerializeToElement(state); - - using HttpClient httpClient = this.CreateMockHttpClient( - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new StateSnapshotEvent { Snapshot = stateSnapshot }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]); - - var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options); - List messages = [new ChatMessage(ChatRole.User, "Test")]; - - // Act - List updates = []; - await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null)) - { - updates.Add(update); - } - - // Assert - ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent)); - Assert.NotNull(stateUpdate.AdditionalProperties); - Assert.True((bool)stateUpdate.AdditionalProperties!["is_state_snapshot"]!); - - DataContent dataContent = (DataContent)stateUpdate.Contents[0]; - Assert.Equal("application/json", dataContent.MediaType); - - string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray()); - JsonElement deserializedState = JsonElement.Parse(jsonText); - Assert.Equal("abc123", deserializedState.GetProperty("sessionId").GetString()); - Assert.Equal(5, deserializedState.GetProperty("step").GetInt32()); - } -} - -internal sealed class TestDelegatingHandler : DelegatingHandler -{ - private readonly Queue>> _responseFactories = new(); - private readonly List _capturedRunIds = []; - - public IReadOnlyList CapturedRunIds => this._capturedRunIds; - - public void AddResponse(BaseEvent[] events) - { - this._responseFactories.Enqueue(_ => Task.FromResult(CreateResponse(events))); - } - - public void AddResponseWithCapture(BaseEvent[] events) - { - this._responseFactories.Enqueue(async request => - { - await this.CaptureRunIdAsync(request); - return CreateResponse(events); - }); - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - if (this._responseFactories.Count == 0) - { - // Log request count for debugging - throw new InvalidOperationException($"No more responses configured for TestDelegatingHandler. Total requests made: {this._capturedRunIds.Count}"); - } - - var factory = this._responseFactories.Dequeue(); - return await factory(request); - } - - private static HttpResponseMessage CreateResponse(BaseEvent[] events) - { - string sseContent = string.Join("", events.Select(e => - $"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n")); - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(sseContent) - }; - } - - private async Task CaptureRunIdAsync(HttpRequestMessage request) - { - string requestBody = await request.Content!.ReadAsStringAsync().ConfigureAwait(false); - RunAgentInput? input = JsonSerializer.Deserialize(requestBody, AGUIJsonSerializerContext.Default.RunAgentInput); - if (input != null) - { - this._capturedRunIds.Add(input.RunId); - } - } -} - -internal sealed class CapturingTestDelegatingHandler : DelegatingHandler -{ - private readonly Queue>> _responseFactories = new(); - - public bool RequestWasMade { get; private set; } - - public void AddResponse(BaseEvent[] events) - { - this._responseFactories.Enqueue(_ => Task.FromResult(CreateResponse(events))); - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - this.RequestWasMade = true; - - if (this._responseFactories.Count == 0) - { - throw new InvalidOperationException("No more responses configured for CapturingTestDelegatingHandler."); - } - - var factory = this._responseFactories.Dequeue(); - return await factory(request); - } - - private static HttpResponseMessage CreateResponse(BaseEvent[] events) - { - string sseContent = string.Join("", events.Select(e => - $"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n")); - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(sseContent) - }; - } -} - -internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler -{ - private readonly Queue>> _responseFactories = new(); - private readonly List _capturedMessageCounts = []; - - public bool RequestWasMade { get; private set; } - public JsonElement? CapturedState { get; private set; } - public int CapturedMessageCount { get; private set; } - public IReadOnlyList CapturedMessageCounts => this._capturedMessageCounts; - - public void AddResponse(BaseEvent[] events) - { - this._responseFactories.Enqueue(_ => Task.FromResult(CreateResponse(events))); - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - this.RequestWasMade = true; - - // Capture the state and message count from the request -#if !NET - string requestBody = await request.Content!.ReadAsStringAsync().ConfigureAwait(false); -#else - string requestBody = await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); -#endif - RunAgentInput? input = JsonSerializer.Deserialize(requestBody, AGUIJsonSerializerContext.Default.RunAgentInput); - if (input != null) - { - if (input.State.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null) - { - this.CapturedState = input.State; - } - this.CapturedMessageCount = input.Messages.Count(); - this._capturedMessageCounts.Add(this.CapturedMessageCount); - } - - if (this._responseFactories.Count == 0) - { - throw new InvalidOperationException("No more responses configured for StateCapturingTestDelegatingHandler."); - } - - var factory = this._responseFactories.Dequeue(); - return await factory(request); - } - - private static HttpResponseMessage CreateResponse(BaseEvent[] events) - { - string sseContent = string.Join("", events.Select(e => - $"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n")); - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(sseContent) - }; - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs deleted file mode 100644 index c0a5d721820..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs +++ /dev/null @@ -1,1060 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json.Serialization; -using Microsoft.Agents.AI.AGUI.Shared; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.AGUI.UnitTests; - -// Custom complex type for testing tool call parameters -public sealed class WeatherRequest -{ - public string Location { get; set; } = string.Empty; - public string Units { get; set; } = "celsius"; - public bool IncludeForecast { get; set; } -} - -// Custom complex type for testing tool call results -public sealed class WeatherResponse -{ - public double Temperature { get; set; } - public string Conditions { get; set; } = string.Empty; - public DateTime Timestamp { get; set; } -} - -// Custom JsonSerializerContext for the custom types -[JsonSerializable(typeof(WeatherRequest))] -[JsonSerializable(typeof(WeatherResponse))] -[JsonSerializable(typeof(Dictionary))] -internal sealed partial class CustomTypesContext : JsonSerializerContext; - -/// -/// Unit tests for the class. -/// -public sealed class AGUIChatMessageExtensionsTests -{ - [Fact] - public void AsChatMessages_WithEmptyCollection_ReturnsEmptyList() - { - // Arrange - List aguiMessages = []; - - // Act - IEnumerable chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options); - - // Assert - Assert.NotNull(chatMessages); - Assert.Empty(chatMessages); - } - - [Fact] - public void AsChatMessages_WithSingleMessage_ConvertsToChatMessageCorrectly() - { - // Arrange - List aguiMessages = - [ - new AGUIUserMessage - { - Id = "msg1", - Content = "Hello" - } - ]; - - // Act - IEnumerable chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options); - - // Assert - ChatMessage message = Assert.Single(chatMessages); - Assert.Equal(ChatRole.User, message.Role); - Assert.Equal("Hello", message.Text); - } - - [Fact] - public void AsChatMessages_WithMultipleMessages_PreservesOrder() - { - // Arrange - List aguiMessages = - [ - new AGUIUserMessage { Id = "msg1", Content = "First" }, - new AGUIAssistantMessage { Id = "msg2", Content = "Second" }, - new AGUIUserMessage { Id = "msg3", Content = "Third" } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - Assert.Equal(3, chatMessages.Count); - Assert.Equal("First", chatMessages[0].Text); - Assert.Equal("Second", chatMessages[1].Text); - Assert.Equal("Third", chatMessages[2].Text); - } - - [Fact] - public void AsChatMessages_MapsAllSupportedRoleTypes_Correctly() - { - // Arrange - List aguiMessages = - [ - new AGUISystemMessage { Id = "msg1", Content = "System message" }, - new AGUIUserMessage { Id = "msg2", Content = "User message" }, - new AGUIAssistantMessage { Id = "msg3", Content = "Assistant message" }, - new AGUIDeveloperMessage { Id = "msg4", Content = "Developer message" }, - new AGUIReasoningMessage { Id = "msg5", Content = "Reasoning message" } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - Assert.Equal(5, chatMessages.Count); - Assert.Equal(ChatRole.System, chatMessages[0].Role); - Assert.Equal(ChatRole.User, chatMessages[1].Role); - Assert.Equal(ChatRole.Assistant, chatMessages[2].Role); - Assert.Equal("developer", chatMessages[3].Role.Value); - Assert.Equal(ChatRole.Assistant, chatMessages[4].Role); - } - - [Fact] - public void AsAGUIMessages_WithEmptyCollection_ReturnsEmptyList() - { - // Arrange - List chatMessages = []; - - // Act - IEnumerable aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options); - - // Assert - Assert.NotNull(aguiMessages); - Assert.Empty(aguiMessages); - } - - [Fact] - public void AsAGUIMessages_WithSingleMessage_ConvertsToAGUIMessageCorrectly() - { - // Arrange - List chatMessages = - [ - new ChatMessage(ChatRole.User, "Hello") { MessageId = "msg1" } - ]; - - // Act - IEnumerable aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options); - - // Assert - AGUIMessage message = Assert.Single(aguiMessages); - Assert.Equal("msg1", message.Id); - Assert.Equal(AGUIRoles.User, message.Role); - Assert.Equal("Hello", ((AGUIUserMessage)message).Content); - } - - [Fact] - public void AsAGUIMessages_WithMultipleMessages_PreservesOrder() - { - // Arrange - List chatMessages = - [ - new ChatMessage(ChatRole.User, "First"), - new ChatMessage(ChatRole.Assistant, "Second"), - new ChatMessage(ChatRole.User, "Third") - ]; - - // Act - List aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - Assert.Equal(3, aguiMessages.Count); - Assert.Equal("First", ((AGUIUserMessage)aguiMessages[0]).Content); - Assert.Equal("Second", ((AGUIAssistantMessage)aguiMessages[1]).Content); - Assert.Equal("Third", ((AGUIUserMessage)aguiMessages[2]).Content); - } - - [Fact] - public void AsAGUIMessages_PreservesMessageId_WhenPresent() - { - // Arrange - List chatMessages = - [ - new ChatMessage(ChatRole.User, "Hello") { MessageId = "msg123" } - ]; - - // Act - IEnumerable aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options); - - // Assert - AGUIMessage message = Assert.Single(aguiMessages); - Assert.Equal("msg123", message.Id); - } - - [Theory] - [InlineData(AGUIRoles.System, "system")] - [InlineData(AGUIRoles.User, "user")] - [InlineData(AGUIRoles.Assistant, "assistant")] - [InlineData(AGUIRoles.Developer, "developer")] - public void MapChatRole_WithValidRole_ReturnsCorrectChatRole(string aguiRole, string expectedRoleValue) - { - // Arrange & Act - ChatRole role = AGUIChatMessageExtensions.MapChatRole(aguiRole); - - // Assert - Assert.Equal(expectedRoleValue, role.Value); - } - - [Fact] - public void MapChatRole_WithUnknownRole_ThrowsInvalidOperationException() - { - // Arrange & Act & Assert - Assert.Throws(() => AGUIChatMessageExtensions.MapChatRole("unknown")); - } - - [Fact] - public void AsAGUIMessages_WithToolResultMessage_SerializesResultCorrectly() - { - // Arrange - var result = new Dictionary { ["temperature"] = 72, ["condition"] = "Sunny" }; - FunctionResultContent toolResult = new("call_123", result); - ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]); - List messages = [toolMessage]; - - // Act - List aguiMessages = messages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - AGUIMessage aguiMessage = Assert.Single(aguiMessages); - Assert.Equal(AGUIRoles.Tool, aguiMessage.Role); - Assert.Equal("call_123", ((AGUIToolMessage)aguiMessage).ToolCallId); - Assert.NotEmpty(((AGUIToolMessage)aguiMessage).Content); - // Content should be serialized JSON - Assert.Contains("temperature", ((AGUIToolMessage)aguiMessage).Content); - Assert.Contains("72", ((AGUIToolMessage)aguiMessage).Content); - } - - [Fact] - public void AsAGUIMessages_WithNullToolResult_HandlesGracefully() - { - // Arrange - FunctionResultContent toolResult = new("call_456", null); - ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]); - List messages = [toolMessage]; - - // Act - List aguiMessages = messages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - AGUIMessage aguiMessage = Assert.Single(aguiMessages); - Assert.Equal(AGUIRoles.Tool, aguiMessage.Role); - Assert.Equal("call_456", ((AGUIToolMessage)aguiMessage).ToolCallId); - Assert.Equal(string.Empty, ((AGUIToolMessage)aguiMessage).Content); - } - - [Fact] - public void AsAGUIMessages_WithoutTypeInfoResolver_ThrowsInvalidOperationException() - { - // Arrange - FunctionResultContent toolResult = new("call_789", "Result"); - ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]); - List messages = [toolMessage]; - System.Text.Json.JsonSerializerOptions optionsWithoutResolver = new(); - - // Act & Assert - NotSupportedException ex = Assert.Throws(() => messages.AsAGUIMessages(optionsWithoutResolver).ToList()); - Assert.Contains("JsonTypeInfo", ex.Message); - } - - [Fact] - public void AsChatMessages_WithToolMessage_DeserializesResultCorrectly() - { - // Arrange - const string JsonContent = "{\"status\":\"success\",\"value\":42}"; - List aguiMessages = - [ - new AGUIToolMessage - { - Id = "msg1", - Content = JsonContent, - ToolCallId = "call_abc" - } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - ChatMessage message = Assert.Single(chatMessages); - Assert.Equal(ChatRole.Tool, message.Role); - FunctionResultContent result = Assert.IsType(message.Contents[0]); - Assert.Equal("call_abc", result.CallId); - Assert.NotNull(result.Result); - } - - [Fact] - public void AsChatMessages_WithEmptyToolContent_CreatesNullResult() - { - // Arrange - List aguiMessages = - [ - new AGUIToolMessage - { - Id = "msg1", - Content = string.Empty, - ToolCallId = "call_def" - } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - ChatMessage message = Assert.Single(chatMessages); - FunctionResultContent result = Assert.IsType(message.Contents[0]); - Assert.Equal("call_def", result.CallId); - Assert.Equal(string.Empty, result.Result); - } - - [Fact] - public void AsChatMessages_WithToolMessageWithoutCallId_TreatsAsRegularMessage() - { - // Arrange - use valid JSON for Content - List aguiMessages = - [ - new AGUIToolMessage - { - Id = "msg1", - Content = "{\"result\":\"Some content\"}", - ToolCallId = string.Empty - } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - ChatMessage message = Assert.Single(chatMessages); - Assert.Equal(ChatRole.Tool, message.Role); - var resultContent = Assert.IsType(message.Contents.First()); - Assert.Equal(string.Empty, resultContent.CallId); - } - - [Fact] - public void RoundTrip_ToolResultMessage_PreservesData() - { - // Arrange - var resultData = new Dictionary { ["location"] = "Seattle", ["temperature"] = 68, ["forecast"] = "Partly cloudy" }; - FunctionResultContent originalResult = new("call_roundtrip", resultData); - ChatMessage originalMessage = new(ChatRole.Tool, [originalResult]); - - // Act - Convert to AGUI and back - List originalList = [originalMessage]; - AGUIMessage aguiMessage = originalList.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).Single(); - List aguiList = [aguiMessage]; - ChatMessage reconstructedMessage = aguiList.AsChatMessages(AGUIJsonSerializerContext.Default.Options).Single(); - - // Assert - Assert.Equal(ChatRole.Tool, reconstructedMessage.Role); - FunctionResultContent reconstructedResult = Assert.IsType(reconstructedMessage.Contents[0]); - Assert.Equal("call_roundtrip", reconstructedResult.CallId); - Assert.NotNull(reconstructedResult.Result); - } - - [Fact] - public void MapChatRole_WithToolRole_ReturnsToolChatRole() - { - // Arrange & Act - ChatRole role = AGUIChatMessageExtensions.MapChatRole(AGUIRoles.Tool); - - // Assert - Assert.Equal(ChatRole.Tool, role); - } - - [Fact] - public void AsChatMessages_WithReasoningMessage_ConvertsToTextReasoningContent() - { - // Arrange - List aguiMessages = - [ - new AGUIReasoningMessage - { - Id = "reason1", - Content = "I need to consider the user's request.", - EncryptedValue = "ErgDCkgIDB..." - } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - ChatMessage message = Assert.Single(chatMessages); - Assert.Equal(ChatRole.Assistant, message.Role); - Assert.Equal("reason1", message.MessageId); - var reasoningContent = Assert.IsType(message.Contents[0]); - Assert.Equal("I need to consider the user's request.", reasoningContent.Text); - Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData); - } - - [Fact] - public void AsChatMessages_WithReasoningMessageWithoutEncryptedValue_ConvertsToTextReasoningContent() - { - // Arrange - List aguiMessages = - [ - new AGUIReasoningMessage - { - Id = "reason1", - Content = "Thinking about this problem." - } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - ChatMessage message = Assert.Single(chatMessages); - Assert.Equal(ChatRole.Assistant, message.Role); - var reasoningContent = Assert.IsType(message.Contents[0]); - Assert.Equal("Thinking about this problem.", reasoningContent.Text); - Assert.Null(reasoningContent.ProtectedData); - } - - [Fact] - public void AsChatMessages_WithReasoningMessageWithOnlyEncryptedValue_ConvertsToTextReasoningContent() - { - // Arrange - List aguiMessages = - [ - new AGUIReasoningMessage - { - Id = "reason1", - Content = string.Empty, - EncryptedValue = "ErgDCkgIDB..." - } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - ChatMessage message = Assert.Single(chatMessages); - var reasoningContent = Assert.IsType(message.Contents[0]); - Assert.Equal("", reasoningContent.Text); - Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData); - } - - [Fact] - public void AsChatMessages_WithEmptyReasoningMessage_ProducesEmptyContents() - { - // Arrange - List aguiMessages = - [ - new AGUIReasoningMessage - { - Id = "reason1", - Content = string.Empty - } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - ChatMessage message = Assert.Single(chatMessages); - Assert.Equal(ChatRole.Assistant, message.Role); - Assert.Empty(message.Contents); - } - - [Fact] - public void MapChatRole_WithReasoningRole_ReturnsAssistantChatRole() - { - // Arrange & Act - ChatRole role = AGUIChatMessageExtensions.MapChatRole(AGUIRoles.Reasoning); - - // Assert - Assert.Equal(ChatRole.Assistant, role); - } - - [Fact] - public void AsChatMessages_WithMixedMessagesIncludingReasoning_PreservesOrder() - { - // Arrange - List aguiMessages = - [ - new AGUIUserMessage { Id = "msg1", Content = "What is 2+2?" }, - new AGUIReasoningMessage { Id = "msg2", Content = "I need to add 2 and 2.", EncryptedValue = "tok-123" }, - new AGUIAssistantMessage { Id = "msg3", Content = "The answer is 4." } - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - Assert.Equal(3, chatMessages.Count); - Assert.Equal(ChatRole.User, chatMessages[0].Role); - Assert.Equal(ChatRole.Assistant, chatMessages[1].Role); - Assert.IsType(chatMessages[1].Contents[0]); - Assert.Equal(ChatRole.Assistant, chatMessages[2].Role); - Assert.Equal("The answer is 4.", chatMessages[2].Text); - } - - [Fact] - public void AsAGUIMessages_WithReasoningContent_ProducesReasoningMessage() - { - // Arrange - List chatMessages = - [ - new ChatMessage(ChatRole.Assistant, [ - new TextReasoningContent("I need to think about this.") { ProtectedData = "encrypted-tok-1" } - ]) { MessageId = "reason-1" } - ]; - - // Act - List aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - AGUIMessage message = Assert.Single(aguiMessages); - var reasoningMessage = Assert.IsType(message); - Assert.Equal("reason-1", reasoningMessage.Id); - Assert.Equal(AGUIRoles.Reasoning, reasoningMessage.Role); - Assert.Equal("I need to think about this.", reasoningMessage.Content); - Assert.Equal("encrypted-tok-1", reasoningMessage.EncryptedValue); - } - - [Fact] - public void AsAGUIMessages_WithReasoningContentWithoutProtectedData_ProducesReasoningMessage() - { - // Arrange - List chatMessages = - [ - new ChatMessage(ChatRole.Assistant, [ - new TextReasoningContent("Just thinking.") - ]) { MessageId = "reason-2" } - ]; - - // Act - List aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - AGUIMessage message = Assert.Single(aguiMessages); - var reasoningMessage = Assert.IsType(message); - Assert.Equal("Just thinking.", reasoningMessage.Content); - Assert.Null(reasoningMessage.EncryptedValue); - } - - [Fact] - public void AsAGUIMessages_WithMultipleReasoningChunksInOneMessage_ConcatenatesText() - { - // Arrange - List chatMessages = - [ - new ChatMessage(ChatRole.Assistant, [ - new TextReasoningContent("First part. "), - new TextReasoningContent("Second part.") { ProtectedData = "final-token" } - ]) { MessageId = "reason-3" } - ]; - - // Act - List aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - AGUIMessage message = Assert.Single(aguiMessages); - var reasoningMessage = Assert.IsType(message); - Assert.Equal("First part. Second part.", reasoningMessage.Content); - Assert.Equal("final-token", reasoningMessage.EncryptedValue); - } - - [Fact] - public void AsAGUIMessages_WithMixedReasoningAndTextContent_EmitsBothMessages() - { - // Arrange - List chatMessages = - [ - new ChatMessage(ChatRole.Assistant, [ - new TextReasoningContent("Thinking about the answer.") { ProtectedData = "enc-tok" }, - new TextContent("The answer is 42.") - ]) { MessageId = "msg-mixed" } - ]; - - // Act - List aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - Assert.Equal(2, aguiMessages.Count); - var reasoningMessage = Assert.IsType(aguiMessages[0]); - Assert.Equal("msg-mixed", reasoningMessage.Id); - Assert.Equal("Thinking about the answer.", reasoningMessage.Content); - Assert.Equal("enc-tok", reasoningMessage.EncryptedValue); - var assistantMessage = Assert.IsType(aguiMessages[1]); - Assert.Equal("msg-mixed", assistantMessage.Id); - Assert.Equal("The answer is 42.", assistantMessage.Content); - } - - [Fact] - public void AsAGUIMessages_WithReasoningAndToolCallInSameMessage_EmitsBothMessages() - { - // Arrange - var arguments = new Dictionary { ["location"] = "Seattle" }; - List chatMessages = - [ - new ChatMessage(ChatRole.Assistant, [ - new TextReasoningContent("I should look up the weather."), - new FunctionCallContent("call-1", "GetWeather", arguments) - ]) { MessageId = "msg-toolcall" } - ]; - - // Act - List aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert - Assert.Equal(2, aguiMessages.Count); - var reasoningMessage = Assert.IsType(aguiMessages[0]); - Assert.Equal("I should look up the weather.", reasoningMessage.Content); - var assistantMessage = Assert.IsType(aguiMessages[1]); - Assert.NotNull(assistantMessage.ToolCalls); - var toolCall = Assert.Single(assistantMessage.ToolCalls); - Assert.Equal("call-1", toolCall.Id); - Assert.Equal("GetWeather", toolCall.Function.Name); - } - - [Fact] - public void RoundTrip_ReasoningMessage_PreservesData() - { - // Arrange - List originalMessages = - [ - new ChatMessage(ChatRole.Assistant, [ - new TextReasoningContent("Thinking about the problem.") { ProtectedData = "ErgDCkgIDB..." } - ]) { MessageId = "reason-rt" } - ]; - - // Act - Convert to AGUI and back - AGUIMessage aguiMessage = originalMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).Single(); - List aguiList = [aguiMessage]; - ChatMessage reconstructed = aguiList.AsChatMessages(AGUIJsonSerializerContext.Default.Options).Single(); - - // Assert - Assert.Equal(ChatRole.Assistant, reconstructed.Role); - var reasoningContent = Assert.IsType(reconstructed.Contents[0]); - Assert.Equal("Thinking about the problem.", reasoningContent.Text); - Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData); - } - - #region Custom Type Serialization Tests - - [Fact] - public void AsChatMessages_WithFunctionCallContainingCustomType_SerializesCorrectly() - { - // Arrange - var customRequest = new WeatherRequest { Location = "Seattle", Units = "fahrenheit", IncludeForecast = true }; - var parameters = new Dictionary - { - ["location"] = customRequest.Location, - ["units"] = customRequest.Units, - ["includeForecast"] = customRequest.IncludeForecast - }; - - List aguiMessages = - [ - new AGUIAssistantMessage - { - Id = "msg1", - ToolCalls = - [ - new AGUIToolCall - { - Id = "call_1", - Function = new AGUIFunctionCall - { - Name = "GetWeather", - Arguments = System.Text.Json.JsonSerializer.Serialize(parameters, AGUIJsonSerializerContext.Default.Options) - } - } - ] - } - ]; - - // Combine contexts for serialization - var combinedOptions = new System.Text.Json.JsonSerializerOptions - { - TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine( - AGUIJsonSerializerContext.Default, - CustomTypesContext.Default) - }; - - // Act - IEnumerable chatMessages = aguiMessages.AsChatMessages(combinedOptions); - - // Assert - ChatMessage message = Assert.Single(chatMessages); - Assert.Equal(ChatRole.Assistant, message.Role); - var toolCallContent = Assert.IsType(message.Contents.First()); - Assert.Equal("call_1", toolCallContent.CallId); - Assert.Equal("GetWeather", toolCallContent.Name); - Assert.NotNull(toolCallContent.Arguments); - // Compare as strings since deserialization produces JsonElement objects - Assert.Equal("Seattle", ((System.Text.Json.JsonElement)toolCallContent.Arguments["location"]!).GetString()); - Assert.Equal("fahrenheit", ((System.Text.Json.JsonElement)toolCallContent.Arguments["units"]!).GetString()); - Assert.True(toolCallContent.Arguments["includeForecast"] is System.Text.Json.JsonElement j && j.GetBoolean()); - } - - [Fact] - public void AsAGUIMessages_WithFunctionResultContainingCustomType_SerializesCorrectly() - { - // Arrange - var customResponse = new WeatherResponse { Temperature = 72.5, Conditions = "Sunny", Timestamp = DateTime.UtcNow }; - var resultObject = new Dictionary - { - ["temperature"] = customResponse.Temperature, - ["conditions"] = customResponse.Conditions, - ["timestamp"] = customResponse.Timestamp.ToString("O") - }; - - var resultJson = System.Text.Json.JsonSerializer.Serialize(resultObject, AGUIJsonSerializerContext.Default.Options); - var functionResult = new FunctionResultContent("call_1", System.Text.Json.JsonSerializer.Deserialize(resultJson, AGUIJsonSerializerContext.Default.Options)); - List chatMessages = - [ - new ChatMessage(ChatRole.Tool, [functionResult]) - ]; - - // Combine contexts for serialization - var combinedOptions = new System.Text.Json.JsonSerializerOptions - { - TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine( - AGUIJsonSerializerContext.Default, - CustomTypesContext.Default) - }; - - // Act - IEnumerable aguiMessages = chatMessages.AsAGUIMessages(combinedOptions); - - // Assert - AGUIMessage message = Assert.Single(aguiMessages); - var toolMessage = Assert.IsType(message); - Assert.Equal("call_1", toolMessage.ToolCallId); - Assert.NotNull(toolMessage.Content); - - // Verify the content can be deserialized back - var deserializedResult = System.Text.Json.JsonSerializer.Deserialize>( - toolMessage.Content, - combinedOptions); - Assert.NotNull(deserializedResult); - Assert.Equal(72.5, deserializedResult["temperature"].GetDouble()); - Assert.Equal("Sunny", deserializedResult["conditions"].GetString()); - } - - [Fact] - public void RoundTrip_WithCustomTypesInFunctionCallAndResult_PreservesData() - { - // Arrange - var customRequest = new WeatherRequest { Location = "New York", Units = "celsius", IncludeForecast = false }; - var parameters = new Dictionary - { - ["location"] = customRequest.Location, - ["units"] = customRequest.Units, - ["includeForecast"] = customRequest.IncludeForecast - }; - - var customResponse = new WeatherResponse { Temperature = 22.3, Conditions = "Cloudy", Timestamp = DateTime.UtcNow }; - var resultObject = new Dictionary - { - ["temperature"] = customResponse.Temperature, - ["conditions"] = customResponse.Conditions, - ["timestamp"] = customResponse.Timestamp.ToString("O") - }; - - var resultJson = System.Text.Json.JsonSerializer.Serialize(resultObject, AGUIJsonSerializerContext.Default.Options); - var resultElement = System.Text.Json.JsonSerializer.Deserialize(resultJson, AGUIJsonSerializerContext.Default.Options); - - List originalChatMessages = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_1", "GetWeather", parameters)]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call_1", resultElement)]) - ]; - - // Combine contexts for serialization - var combinedOptions = new System.Text.Json.JsonSerializerOptions - { - TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine( - AGUIJsonSerializerContext.Default, - CustomTypesContext.Default) - }; - - // Act - Convert to AGUI messages and back - IEnumerable aguiMessages = originalChatMessages.AsAGUIMessages(combinedOptions); - List roundTrippedChatMessages = aguiMessages.AsChatMessages(combinedOptions).ToList(); - - // Assert - Assert.Equal(2, roundTrippedChatMessages.Count); - - // Verify function call - ChatMessage callMessage = roundTrippedChatMessages[0]; - Assert.Equal(ChatRole.Assistant, callMessage.Role); - var functionCall = Assert.IsType(callMessage.Contents.First()); - Assert.Equal("call_1", functionCall.CallId); - Assert.Equal("GetWeather", functionCall.Name); - Assert.NotNull(functionCall.Arguments); - // Compare string values from JsonElement - Assert.Equal(customRequest.Location, functionCall.Arguments["location"]?.ToString()); - Assert.Equal(customRequest.Units, functionCall.Arguments["units"]?.ToString()); - - // Verify function result - ChatMessage resultMessage = roundTrippedChatMessages[1]; - Assert.Equal(ChatRole.Tool, resultMessage.Role); - var functionResultContent = Assert.IsType(resultMessage.Contents.First()); - Assert.Equal("call_1", functionResultContent.CallId); - Assert.NotNull(functionResultContent.Result); - } - - [Fact] - public void AsAGUIMessages_WithNestedCustomObjects_HandlesComplexSerialization() - { - // Arrange - nested custom types - var nestedParameters = new Dictionary - { - ["request"] = new Dictionary - { - ["location"] = "Boston", - ["options"] = new Dictionary - { - ["units"] = "fahrenheit", - ["includeHumidity"] = true, - ["daysAhead"] = 5 - } - } - }; - - var functionCall = new FunctionCallContent("call_nested", "GetDetailedWeather", nestedParameters); - List chatMessages = - [ - new ChatMessage(ChatRole.Assistant, [functionCall]) - ]; - - // Combine contexts for serialization - var combinedOptions = new System.Text.Json.JsonSerializerOptions - { - TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine( - AGUIJsonSerializerContext.Default, - CustomTypesContext.Default) - }; - - // Act - IEnumerable aguiMessages = chatMessages.AsAGUIMessages(combinedOptions); - - // Assert - AGUIMessage message = Assert.Single(aguiMessages); - var assistantMessage = Assert.IsType(message); - Assert.NotNull(assistantMessage.ToolCalls); - var toolCall = Assert.Single(assistantMessage.ToolCalls); - Assert.Equal("call_nested", toolCall.Id); - Assert.Equal("GetDetailedWeather", toolCall.Function?.Name); - - // Verify nested structure is preserved - var deserializedArgs = System.Text.Json.JsonSerializer.Deserialize>( - toolCall.Function?.Arguments ?? "{}", - combinedOptions); - Assert.NotNull(deserializedArgs); - Assert.True(deserializedArgs.ContainsKey("request")); - } - - [Fact] - public void AsAGUIMessages_WithDictionaryContainingCustomTypes_SerializesDirectly() - { - // Arrange - Create a dictionary with custom type values (not flattened) - var customRequest = new WeatherRequest { Location = "Tokyo", Units = "celsius", IncludeForecast = true }; - var parameters = new Dictionary - { - ["customRequest"] = customRequest, // Custom type as value - ["simpleString"] = "test", - ["simpleNumber"] = 42 - }; - - List chatMessages = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_custom", "ProcessWeather", parameters)]) - ]; - - // Combine contexts for serialization - var combinedOptions = new System.Text.Json.JsonSerializerOptions - { - TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine( - AGUIJsonSerializerContext.Default, - CustomTypesContext.Default) - }; - - // Act - IEnumerable aguiMessages = chatMessages.AsAGUIMessages(combinedOptions); - - // Assert - AGUIMessage message = Assert.Single(aguiMessages); - var assistantMessage = Assert.IsType(message); - Assert.NotNull(assistantMessage.ToolCalls); - var toolCall = Assert.Single(assistantMessage.ToolCalls); - Assert.Equal("call_custom", toolCall.Id); - Assert.Equal("ProcessWeather", toolCall.Function?.Name); - - // Verify custom type was serialized correctly without flattening - var deserializedArgs = System.Text.Json.JsonSerializer.Deserialize>( - toolCall.Function?.Arguments ?? "{}", - combinedOptions); - Assert.NotNull(deserializedArgs); - Assert.True(deserializedArgs.ContainsKey("customRequest")); - Assert.True(deserializedArgs.ContainsKey("simpleString")); - Assert.True(deserializedArgs.ContainsKey("simpleNumber")); - - // Verify the custom type properties are accessible - var customRequestElement = deserializedArgs["customRequest"]; - Assert.Equal("Tokyo", customRequestElement.GetProperty("Location").GetString()); - Assert.Equal("celsius", customRequestElement.GetProperty("Units").GetString()); - Assert.True(customRequestElement.GetProperty("IncludeForecast").GetBoolean()); - - // Verify simple types - Assert.Equal("test", deserializedArgs["simpleString"].GetString()); - Assert.Equal(42, deserializedArgs["simpleNumber"].GetInt32()); - } - - #endregion - - #region Consecutive Assistant-Tool-Call Coalescing - - /// - /// Bug #3 reproduction: consecutive AGUIAssistantMessages with ToolCalls should - /// be coalesced into a single ChatMessage with multiple FunctionCallContent - /// entries. Without coalescing, Azure OpenAI rejects the history with HTTP 400. - /// - [Fact] - public void AsChatMessages_ConsecutiveAssistantToolCallMessages_CoalesceIntoOneChatMessage() - { - // Arrange — 3 consecutive assistant messages with tool calls (no intervening tool msg) - List aguiMessages = - [ - new AGUIUserMessage { Id = "user-1", Content = "Run 3 queries" }, - new AGUIAssistantMessage - { - Id = "asst-1", - Content = "", - ToolCalls = - [ - new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"1\"}" } } - ] - }, - new AGUIAssistantMessage - { - Id = "asst-2", - Content = "", - ToolCalls = - [ - new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"2\"}" } } - ] - }, - new AGUIAssistantMessage - { - Id = "asst-3", - Content = "", - ToolCalls = - [ - new AGUIToolCall { Id = "call_C", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{\"q\":\"3\"}" } } - ] - }, - new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"result1\"" }, - new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"result2\"" }, - new AGUIToolMessage { Id = "tool-3", ToolCallId = "call_C", Content = "\"result3\"" }, - new AGUIUserMessage { Id = "user-2", Content = "Run it again" }, - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert — the 3 consecutive assistant-tool-call messages should coalesce into 1 - List assistantWithToolCalls = chatMessages - .Where(m => m.Role == ChatRole.Assistant && m.Contents.OfType().Any()) - .ToList(); - - Assert.Single(assistantWithToolCalls); - - // The single coalesced message should contain all 3 FunctionCallContent entries - List functionCalls = assistantWithToolCalls[0].Contents - .OfType().ToList(); - Assert.Equal(3, functionCalls.Count); - Assert.Equal("call_A", functionCalls[0].CallId); - Assert.Equal("call_B", functionCalls[1].CallId); - Assert.Equal("call_C", functionCalls[2].CallId); - - // MessageId should be from the first message in the coalesced group - Assert.Equal("asst-1", assistantWithToolCalls[0].MessageId); - - // Total messages: user + coalesced assistant + 3 tools + user = 6 - Assert.Equal(6, chatMessages.Count); - } - - /// - /// A single assistant message with tool calls (not consecutive) should still - /// produce one ChatMessage — no behavior change from coalescing logic. - /// - [Fact] - public void AsChatMessages_SingleAssistantToolCallMessage_ProducesOneChatMessage() - { - // Arrange - List aguiMessages = - [ - new AGUIAssistantMessage - { - Id = "asst-1", - Content = "Here are the results", - ToolCalls = - [ - new AGUIToolCall { Id = "call_A", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } }, - new AGUIToolCall { Id = "call_B", Type = "function", Function = new AGUIFunctionCall { Name = "query", Arguments = "{}" } }, - ] - }, - new AGUIToolMessage { Id = "tool-1", ToolCallId = "call_A", Content = "\"r1\"" }, - new AGUIToolMessage { Id = "tool-2", ToolCallId = "call_B", Content = "\"r2\"" }, - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert — single assistant message, not coalesced from multiple - Assert.Equal(3, chatMessages.Count); - Assert.Equal(ChatRole.Assistant, chatMessages[0].Role); - List calls = chatMessages[0].Contents.OfType().ToList(); - Assert.Equal(2, calls.Count); - Assert.Equal("asst-1", chatMessages[0].MessageId); - } - - /// - /// When consecutive assistant-tool-call messages are at the END of the stream - /// (no subsequent non-tool-call message to trigger flush), they should still - /// be coalesced and flushed. - /// - [Fact] - public void AsChatMessages_ConsecutiveAssistantToolCallsAtEndOfStream_FlushesCorrectly() - { - // Arrange — stream ends with consecutive assistant tool-call messages - List aguiMessages = - [ - new AGUIUserMessage { Id = "user-1", Content = "Do things" }, - new AGUIAssistantMessage - { - Id = "asst-1", - ToolCalls = [new AGUIToolCall { Id = "call_X", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }] - }, - new AGUIAssistantMessage - { - Id = "asst-2", - ToolCalls = [new AGUIToolCall { Id = "call_Y", Type = "function", Function = new AGUIFunctionCall { Name = "fn", Arguments = "{}" } }] - }, - ]; - - // Act - List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList(); - - // Assert — should be user + 1 coalesced assistant = 2 messages - Assert.Equal(2, chatMessages.Count); - Assert.Equal(ChatRole.User, chatMessages[0].Role); - Assert.Equal(ChatRole.Assistant, chatMessages[1].Role); - Assert.Equal(2, chatMessages[1].Contents.OfType().Count()); - } - - #endregion -} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs deleted file mode 100644 index b06913c8373..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.AGUI.Shared; -using Moq; -using Moq.Protected; - -namespace Microsoft.Agents.AI.AGUI.UnitTests; - -/// -/// Unit tests for the class. -/// -public sealed class AGUIHttpServiceTests -{ - [Fact] - public async Task PostRunAsync_SendsRequestAndParsesSSEStream_SuccessfullyAsync() - { - // Arrange - BaseEvent[] events = - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant }, - new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }, - new TextMessageEndEvent { MessageId = "msg1" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" } - ]; - - HttpClient httpClient = CreateMockHttpClient(events, HttpStatusCode.OK); - AGUIHttpService service = new(httpClient, "http://localhost/agent"); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - - // Act - List resultEvents = []; - await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None)) - { - resultEvents.Add(evt); - } - - // Assert - Assert.Equal(5, resultEvents.Count); - Assert.IsType(resultEvents[0]); - Assert.IsType(resultEvents[1]); - Assert.IsType(resultEvents[2]); - Assert.IsType(resultEvents[3]); - Assert.IsType(resultEvents[4]); - } - - [Fact] - public async Task PostRunAsync_WithNonSuccessStatusCode_ThrowsHttpRequestExceptionAsync() - { - // Arrange - HttpClient httpClient = CreateMockHttpClient([], HttpStatusCode.InternalServerError); - AGUIHttpService service = new(httpClient, "http://localhost/agent"); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - - // Act & Assert - await Assert.ThrowsAsync(async () => - { - await foreach (var _ in service.PostRunAsync(input, CancellationToken.None)) - { - // Consume the stream - } - }); - } - - [Fact] - public async Task PostRunAsync_DeserializesMultipleEventTypes_CorrectlyAsync() - { - // Arrange - BaseEvent[] events = - [ - new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }, - new RunErrorEvent { Message = "Error occurred", Code = "ERR001" }, - new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonElement.Parse("\"Success\"") } - ]; - - HttpClient httpClient = CreateMockHttpClient(events, HttpStatusCode.OK); - AGUIHttpService service = new(httpClient, "http://localhost/agent"); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - - // Act - List resultEvents = []; - await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None)) - { - resultEvents.Add(evt); - } - - // Assert - Assert.Equal(3, resultEvents.Count); - RunStartedEvent startedEvent = Assert.IsType(resultEvents[0]); - Assert.Equal("thread1", startedEvent.ThreadId); - RunErrorEvent errorEvent = Assert.IsType(resultEvents[1]); - Assert.Equal("Error occurred", errorEvent.Message); - RunFinishedEvent finishedEvent = Assert.IsType(resultEvents[2]); - Assert.Equal("Success", finishedEvent.Result?.GetString()); - } - - [Fact] - public async Task PostRunAsync_WithEmptyEventStream_CompletesSuccessfullyAsync() - { - // Arrange - HttpClient httpClient = CreateMockHttpClient([], HttpStatusCode.OK); - AGUIHttpService service = new(httpClient, "http://localhost/agent"); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - - // Act - List resultEvents = []; - await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None)) - { - resultEvents.Add(evt); - } - - // Assert - Assert.Empty(resultEvents); - } - - [Fact] - public async Task PostRunAsync_WithCancellationToken_CancelsRequestAsync() - { - // Arrange - CancellationTokenSource cts = new(); - cts.Cancel(); - - Mock handlerMock = new(MockBehavior.Strict); - handlerMock - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ThrowsAsync(new TaskCanceledException()); - - HttpClient httpClient = new(handlerMock.Object); - AGUIHttpService service = new(httpClient, "http://localhost/agent"); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - - // Act & Assert - await Assert.ThrowsAsync(async () => - { - await foreach (var _ in service.PostRunAsync(input, cts.Token)) - { - // Intentionally empty - consuming stream to trigger cancellation - } - }); - } - - private static HttpClient CreateMockHttpClient(BaseEvent[] events, HttpStatusCode statusCode) - { - string sseContent = string.Concat(events.Select(e => - $"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n")); - - Mock handlerMock = new(MockBehavior.Strict); - handlerMock - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage - { - StatusCode = statusCode, - Content = new StringContent(sseContent) - }); - - return new HttpClient(handlerMock.Object); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs deleted file mode 100644 index 333e80e8278..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs +++ /dev/null @@ -1,1373 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using Microsoft.Agents.AI.AGUI.Shared; - -namespace Microsoft.Agents.AI.AGUI.UnitTests; - -/// -/// Unit tests for the class and JSON serialization. -/// -public sealed class AGUIJsonSerializerContextTests -{ - [Fact] - public void RunAgentInput_Serializes_WithAllRequiredFields() - { - // Arrange - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - - // Act - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - JsonElement jsonElement = JsonElement.Parse(json); - - // Assert - Assert.True(jsonElement.TryGetProperty("threadId", out JsonElement threadIdProp)); - Assert.Equal("thread1", threadIdProp.GetString()); - Assert.True(jsonElement.TryGetProperty("runId", out JsonElement runIdProp)); - Assert.Equal("run1", runIdProp.GetString()); - Assert.True(jsonElement.TryGetProperty("messages", out JsonElement messagesProp)); - Assert.Equal(JsonValueKind.Array, messagesProp.ValueKind); - } - - [Fact] - public void RunAgentInput_Deserializes_FromJsonWithRequiredFields() - { - // Arrange - const string Json = """ - { - "threadId": "thread1", - "runId": "run1", - "messages": [ - { - "id": "m1", - "role": "user", - "content": "Test" - } - ] - } - """; - - // Act - RunAgentInput? input = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.RunAgentInput); - - // Assert - Assert.NotNull(input); - Assert.Equal("thread1", input.ThreadId); - Assert.Equal("run1", input.RunId); - Assert.Single(input.Messages); - } - - [Fact] - public void RunAgentInput_Deserializes_FromJsonWithReasoningMessages() - { - // Arrange - const string Json = """ - { - "threadId": "thread1", - "runId": "run1", - "messages": [ - { - "id": "m1", - "role": "user", - "content": "Hello" - }, - { - "id": "m2", - "role": "reasoning", - "content": "I need to consider this.", - "encryptedValue": "ErgDCkgIDB..." - }, - { - "id": "m3", - "role": "assistant", - "content": "Here is my answer." - } - ] - } - """; - - // Act - RunAgentInput? input = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.RunAgentInput); - - // Assert - Assert.NotNull(input); - var messages = input.Messages.ToList(); - Assert.Equal(3, messages.Count); - Assert.IsType(messages[0]); - var reasoningMessage = Assert.IsType(messages[1]); - Assert.Equal("I need to consider this.", reasoningMessage.Content); - Assert.Equal("ErgDCkgIDB...", reasoningMessage.EncryptedValue); - Assert.IsType(messages[2]); - } - - [Fact] - public void RunAgentInput_HandlesOptionalFields_StateContextAndForwardedProperties() - { - // Arrange - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }], - State = JsonSerializer.SerializeToElement(new { key = "value" }), - Context = [new AGUIContextItem { Description = "ctx1", Value = "value1" }], - ForwardedProperties = JsonSerializer.SerializeToElement(new { prop1 = "val1" }) - }; - - // Act - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - RunAgentInput? deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.RunAgentInput); - - // Assert - Assert.NotNull(deserialized); - Assert.NotEqual(JsonValueKind.Undefined, deserialized.State.ValueKind); - Assert.Single(deserialized.Context); - Assert.NotEqual(JsonValueKind.Undefined, deserialized.ForwardedProperties.ValueKind); - } - - [Fact] - public void RunAgentInput_ValidatesMinimumMessageCount_MinLengthOne() - { - // Arrange - const string Json = """ - { - "threadId": "thread1", - "runId": "run1", - "messages": [] - } - """; - - // Act - RunAgentInput? input = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.RunAgentInput); - - // Assert - Assert.NotNull(input); - Assert.Empty(input.Messages); - } - - [Fact] - public void RunAgentInput_RoundTrip_PreservesAllData() - { - // Arrange - RunAgentInput original = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = - [ - new AGUIUserMessage { Id = "m1", Content = "First" }, - new AGUIAssistantMessage { Id = "m2", Content = "Second" } - ], - Context = [ - new AGUIContextItem { Description = "key1", Value = "value1" }, - new AGUIContextItem { Description = "key2", Value = "value2" } - ] - }; - - // Act - string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.RunAgentInput); - RunAgentInput? deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.RunAgentInput); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.ThreadId, deserialized.ThreadId); - Assert.Equal(original.RunId, deserialized.RunId); - Assert.Equal(2, deserialized.Messages.Count()); - Assert.Equal(2, deserialized.Context.Length); - } - - [Fact] - public void RunStartedEvent_Serializes_WithCorrectEventType() - { - // Arrange - RunStartedEvent evt = new() { ThreadId = "thread1", RunId = "run1" }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunStartedEvent); - - // Assert - var jsonElement = JsonElement.Parse(json); - Assert.Equal(AGUIEventTypes.RunStarted, jsonElement.GetProperty("type").GetString()); - } - - [Fact] - public void RunStartedEvent_Includes_ThreadIdAndRunIdInOutput() - { - // Arrange - RunStartedEvent evt = new() { ThreadId = "thread1", RunId = "run1" }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunStartedEvent); - JsonElement jsonElement = JsonElement.Parse(json); - - // Assert - Assert.True(jsonElement.TryGetProperty("threadId", out JsonElement threadIdProp)); - Assert.Equal("thread1", threadIdProp.GetString()); - Assert.True(jsonElement.TryGetProperty("runId", out JsonElement runIdProp)); - Assert.Equal("run1", runIdProp.GetString()); - } - - [Fact] - public void RunStartedEvent_Deserializes_FromJsonCorrectly() - { - // Arrange - const string Json = """ - { - "type": "RUN_STARTED", - "threadId": "thread1", - "runId": "run1" - } - """; - - // Act - RunStartedEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.RunStartedEvent); - - // Assert - Assert.NotNull(evt); - Assert.Equal("thread1", evt.ThreadId); - Assert.Equal("run1", evt.RunId); - } - - [Fact] - public void RunStartedEvent_RoundTrip_PreservesData() - { - // Arrange - RunStartedEvent original = new() { ThreadId = "thread123", RunId = "run456" }; - - // Act - string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.RunStartedEvent); - RunStartedEvent? deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.RunStartedEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.ThreadId, deserialized.ThreadId); - Assert.Equal(original.RunId, deserialized.RunId); - Assert.Equal(original.Type, deserialized.Type); - } - - [Fact] - public void RunFinishedEvent_Serializes_WithCorrectEventType() - { - // Arrange - RunFinishedEvent evt = new() { ThreadId = "thread1", RunId = "run1" }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunFinishedEvent); - - // Assert - var jsonElement = JsonElement.Parse(json); - Assert.Equal(AGUIEventTypes.RunFinished, jsonElement.GetProperty("type").GetString()); - } - - [Fact] - public void RunFinishedEvent_Includes_ThreadIdRunIdAndOptionalResult() - { - // Arrange - RunFinishedEvent evt = new() { ThreadId = "thread1", RunId = "run1", Result = JsonElement.Parse("\"Success\"") }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunFinishedEvent); - JsonElement jsonElement = JsonElement.Parse(json); - - // Assert - Assert.True(jsonElement.TryGetProperty("threadId", out JsonElement threadIdProp)); - Assert.Equal("thread1", threadIdProp.GetString()); - Assert.True(jsonElement.TryGetProperty("runId", out JsonElement runIdProp)); - Assert.Equal("run1", runIdProp.GetString()); - Assert.True(jsonElement.TryGetProperty("result", out JsonElement resultProp)); - Assert.Equal("Success", resultProp.GetString()); - } - - [Fact] - public void RunFinishedEvent_Deserializes_FromJsonCorrectly() - { - // Arrange - const string Json = """ - { - "type": "RUN_FINISHED", - "threadId": "thread1", - "runId": "run1", - "result": "Complete" - } - """; - - // Act - RunFinishedEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.RunFinishedEvent); - - // Assert - Assert.NotNull(evt); - Assert.Equal("thread1", evt.ThreadId); - Assert.Equal("run1", evt.RunId); - Assert.Equal("Complete", evt.Result?.GetString()); - } - - [Fact] - public void RunFinishedEvent_RoundTrip_PreservesData() - { - // Arrange - RunFinishedEvent original = new() { ThreadId = "thread1", RunId = "run1", Result = JsonElement.Parse("\"Done\"") }; - - // Act - string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.RunFinishedEvent); - RunFinishedEvent? deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.RunFinishedEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.ThreadId, deserialized.ThreadId); - Assert.Equal(original.RunId, deserialized.RunId); - Assert.Equal(original.Result?.GetString(), deserialized.Result?.GetString()); - } - - [Fact] - public void RunErrorEvent_Serializes_WithCorrectEventType() - { - // Arrange - RunErrorEvent evt = new() { Message = "Error occurred" }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunErrorEvent); - - // Assert - var jsonElement = JsonElement.Parse(json); - Assert.Equal(AGUIEventTypes.RunError, jsonElement.GetProperty("type").GetString()); - } - - [Fact] - public void RunErrorEvent_Includes_MessageAndOptionalCode() - { - // Arrange - RunErrorEvent evt = new() { Message = "Error occurred", Code = "ERR001" }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunErrorEvent); - JsonElement jsonElement = JsonElement.Parse(json); - - // Assert - Assert.True(jsonElement.TryGetProperty("message", out JsonElement messageProp)); - Assert.Equal("Error occurred", messageProp.GetString()); - Assert.True(jsonElement.TryGetProperty("code", out JsonElement codeProp)); - Assert.Equal("ERR001", codeProp.GetString()); - } - - [Fact] - public void RunErrorEvent_Deserializes_FromJsonCorrectly() - { - // Arrange - const string Json = """ - { - "type": "RUN_ERROR", - "message": "Something went wrong", - "code": "ERR123" - } - """; - - // Act - RunErrorEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.RunErrorEvent); - - // Assert - Assert.NotNull(evt); - Assert.Equal("Something went wrong", evt.Message); - Assert.Equal("ERR123", evt.Code); - } - - [Fact] - public void RunErrorEvent_RoundTrip_PreservesData() - { - // Arrange - RunErrorEvent original = new() { Message = "Test error", Code = "TEST001" }; - - // Act - string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.RunErrorEvent); - RunErrorEvent? deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.RunErrorEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.Message, deserialized.Message); - Assert.Equal(original.Code, deserialized.Code); - } - - [Fact] - public void TextMessageStartEvent_Serializes_WithCorrectEventType() - { - // Arrange - TextMessageStartEvent evt = new() { MessageId = "msg1", Role = AGUIRoles.Assistant }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageStartEvent); - - // Assert - var jsonElement = JsonElement.Parse(json); - Assert.Equal(AGUIEventTypes.TextMessageStart, jsonElement.GetProperty("type").GetString()); - } - - [Fact] - public void TextMessageStartEvent_Includes_MessageIdAndRole() - { - // Arrange - TextMessageStartEvent evt = new() { MessageId = "msg1", Role = AGUIRoles.Assistant }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageStartEvent); - JsonElement jsonElement = JsonElement.Parse(json); - - // Assert - Assert.True(jsonElement.TryGetProperty("messageId", out JsonElement msgIdProp)); - Assert.Equal("msg1", msgIdProp.GetString()); - Assert.True(jsonElement.TryGetProperty("role", out JsonElement roleProp)); - Assert.Equal(AGUIRoles.Assistant, roleProp.GetString()); - } - - [Fact] - public void TextMessageStartEvent_Deserializes_FromJsonCorrectly() - { - // Arrange - const string Json = """ - { - "type": "TEXT_MESSAGE_START", - "messageId": "msg1", - "role": "assistant" - } - """; - - // Act - TextMessageStartEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.TextMessageStartEvent); - - // Assert - Assert.NotNull(evt); - Assert.Equal("msg1", evt.MessageId); - Assert.Equal(AGUIRoles.Assistant, evt.Role); - } - - [Fact] - public void TextMessageStartEvent_RoundTrip_PreservesData() - { - // Arrange - TextMessageStartEvent original = new() { MessageId = "msg123", Role = AGUIRoles.User }; - - // Act - string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.TextMessageStartEvent); - TextMessageStartEvent? deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.TextMessageStartEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.MessageId, deserialized.MessageId); - Assert.Equal(original.Role, deserialized.Role); - } - - [Fact] - public void TextMessageContentEvent_Serializes_WithCorrectEventType() - { - // Arrange - TextMessageContentEvent evt = new() { MessageId = "msg1", Delta = "Hello" }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageContentEvent); - - // Assert - var jsonElement = JsonElement.Parse(json); - Assert.Equal(AGUIEventTypes.TextMessageContent, jsonElement.GetProperty("type").GetString()); - } - - [Fact] - public void TextMessageContentEvent_Includes_MessageIdAndDelta() - { - // Arrange - TextMessageContentEvent evt = new() { MessageId = "msg1", Delta = "Hello World" }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageContentEvent); - JsonElement jsonElement = JsonElement.Parse(json); - - // Assert - Assert.True(jsonElement.TryGetProperty("messageId", out JsonElement msgIdProp)); - Assert.Equal("msg1", msgIdProp.GetString()); - Assert.True(jsonElement.TryGetProperty("delta", out JsonElement deltaProp)); - Assert.Equal("Hello World", deltaProp.GetString()); - } - - [Fact] - public void TextMessageContentEvent_Deserializes_FromJsonCorrectly() - { - // Arrange - const string Json = """ - { - "type": "TEXT_MESSAGE_CONTENT", - "messageId": "msg1", - "delta": "Test content" - } - """; - - // Act - TextMessageContentEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.TextMessageContentEvent); - - // Assert - Assert.NotNull(evt); - Assert.Equal("msg1", evt.MessageId); - Assert.Equal("Test content", evt.Delta); - } - - [Fact] - public void TextMessageContentEvent_RoundTrip_PreservesData() - { - // Arrange - TextMessageContentEvent original = new() { MessageId = "msg456", Delta = "Sample text" }; - - // Act - string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.TextMessageContentEvent); - TextMessageContentEvent? deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.TextMessageContentEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.MessageId, deserialized.MessageId); - Assert.Equal(original.Delta, deserialized.Delta); - } - - [Fact] - public void TextMessageEndEvent_Serializes_WithCorrectEventType() - { - // Arrange - TextMessageEndEvent evt = new() { MessageId = "msg1" }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageEndEvent); - - // Assert - var jsonElement = JsonElement.Parse(json); - Assert.Equal(AGUIEventTypes.TextMessageEnd, jsonElement.GetProperty("type").GetString()); - } - - [Fact] - public void TextMessageEndEvent_Includes_MessageId() - { - // Arrange - TextMessageEndEvent evt = new() { MessageId = "msg1" }; - - // Act - string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageEndEvent); - JsonElement jsonElement = JsonElement.Parse(json); - - // Assert - Assert.True(jsonElement.TryGetProperty("messageId", out JsonElement msgIdProp)); - Assert.Equal("msg1", msgIdProp.GetString()); - } - - [Fact] - public void TextMessageEndEvent_Deserializes_FromJsonCorrectly() - { - // Arrange - const string Json = """ - { - "type": "TEXT_MESSAGE_END", - "messageId": "msg1" - } - """; - - // Act - TextMessageEndEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.TextMessageEndEvent); - - // Assert - Assert.NotNull(evt); - Assert.Equal("msg1", evt.MessageId); - } - - [Fact] - public void TextMessageEndEvent_RoundTrip_PreservesData() - { - // Arrange - TextMessageEndEvent original = new() { MessageId = "msg789" }; - - // Act - string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.TextMessageEndEvent); - TextMessageEndEvent? deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.TextMessageEndEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.MessageId, deserialized.MessageId); - } - - [Fact] - public void AGUIMessage_Serializes_WithIdRoleAndContent() - { - // Arrange - AGUIMessage message = new AGUIUserMessage() { Id = "m1", Content = "Hello" }; - - // Act - string json = JsonSerializer.Serialize(message, AGUIJsonSerializerContext.Default.AGUIMessage); - JsonElement jsonElement = JsonElement.Parse(json); - - // Assert - Assert.True(jsonElement.TryGetProperty("id", out JsonElement idProp)); - Assert.Equal("m1", idProp.GetString()); - Assert.True(jsonElement.TryGetProperty("role", out JsonElement roleProp)); - Assert.Equal(AGUIRoles.User, roleProp.GetString()); - Assert.True(jsonElement.TryGetProperty("content", out JsonElement contentProp)); - Assert.Equal("Hello", contentProp.GetString()); - } - - [Fact] - public void AGUIMessage_Deserializes_FromJsonCorrectly() - { - // Arrange - const string Json = """ - { - "id": "m1", - "role": "user", - "content": "Test message" - } - """; - - // Act - AGUIMessage? message = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.AGUIMessage); - - // Assert - Assert.NotNull(message); - Assert.Equal("m1", message.Id); - Assert.Equal(AGUIRoles.User, message.Role); - Assert.Equal("Test message", ((AGUIUserMessage)message).Content); - } - - [Fact] - public void AGUIMessage_RoundTrip_PreservesData() - { - // Arrange - AGUIMessage original = new AGUIAssistantMessage() { Id = "msg123", Content = "Response text" }; - - // Act - string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.AGUIMessage); - AGUIMessage? deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIMessage); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.Id, deserialized.Id); - Assert.Equal(original.Role, deserialized.Role); - Assert.Equal(((AGUIAssistantMessage)original).Content, ((AGUIAssistantMessage)deserialized).Content); - } - - [Fact] - public void AGUIMessage_Validates_RequiredFields() - { - // Arrange - const string Json = """ - { - "id": "m1", - "role": "user", - "content": "Test" - } - """; - - // Act - AGUIMessage? message = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.AGUIMessage); - - // Assert - Assert.NotNull(message); - Assert.NotNull(message.Id); - Assert.NotNull(message.Role); - Assert.NotNull(((AGUIUserMessage)message).Content); - } - - [Fact] - public void BaseEvent_Deserializes_RunStartedEventAsBaseEvent() - { - // Arrange - const string Json = """ - { - "type": "RUN_STARTED", - "threadId": "thread1", - "runId": "run1" - } - """; - - // Act - BaseEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.BaseEvent); - - // Assert - Assert.NotNull(evt); - Assert.IsType(evt); - } - - [Fact] - public void BaseEvent_Deserializes_RunFinishedEventAsBaseEvent() - { - // Arrange - const string Json = """ - { - "type": "RUN_FINISHED", - "threadId": "thread1", - "runId": "run1" - } - """; - - // Act - BaseEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.BaseEvent); - - // Assert - Assert.NotNull(evt); - Assert.IsType(evt); - } - - [Fact] - public void BaseEvent_Deserializes_RunErrorEventAsBaseEvent() - { - // Arrange - const string Json = """ - { - "type": "RUN_ERROR", - "message": "Error" - } - """; - - // Act - BaseEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.BaseEvent); - - // Assert - Assert.NotNull(evt); - Assert.IsType(evt); - } - - [Fact] - public void BaseEvent_Deserializes_TextMessageStartEventAsBaseEvent() - { - // Arrange - const string Json = """ - { - "type": "TEXT_MESSAGE_START", - "messageId": "msg1", - "role": "assistant" - } - """; - - // Act - BaseEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.BaseEvent); - - // Assert - Assert.NotNull(evt); - Assert.IsType(evt); - } - - [Fact] - public void BaseEvent_Deserializes_TextMessageContentEventAsBaseEvent() - { - // Arrange - const string Json = """ - { - "type": "TEXT_MESSAGE_CONTENT", - "messageId": "msg1", - "delta": "Hello" - } - """; - - // Act - BaseEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.BaseEvent); - - // Assert - Assert.NotNull(evt); - Assert.IsType(evt); - } - - [Fact] - public void BaseEvent_Deserializes_TextMessageEndEventAsBaseEvent() - { - // Arrange - const string Json = """ - { - "type": "TEXT_MESSAGE_END", - "messageId": "msg1" - } - """; - - // Act - BaseEvent? evt = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.BaseEvent); - - // Assert - Assert.NotNull(evt); - Assert.IsType(evt); - } - - [Fact] - public void BaseEvent_DistinguishesEventTypes_BasedOnTypeField() - { - // Arrange - string[] jsonEvents = - [ - "{\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}", - "{\"type\":\"RUN_FINISHED\",\"threadId\":\"t1\",\"runId\":\"r1\"}", - "{\"type\":\"RUN_ERROR\",\"message\":\"err\"}", - "{\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"m1\",\"role\":\"user\"}", - "{\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"hi\"}", - "{\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"m1\"}" - ]; - - // Act - List events = []; - foreach (string json in jsonEvents) - { - BaseEvent? evt = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.BaseEvent); - if (evt != null) - { - events.Add(evt); - } - } - - // Assert - Assert.Equal(6, events.Count); - Assert.IsType(events[0]); - Assert.IsType(events[1]); - Assert.IsType(events[2]); - Assert.IsType(events[3]); - Assert.IsType(events[4]); - Assert.IsType(events[5]); - } - - #region Comprehensive Message Serialization Tests - - [Fact] - public void AGUIUserMessage_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalMessage = new AGUIUserMessage - { - Id = "user1", - Content = "Hello, assistant!" - }; - - // Act - string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIUserMessage); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIUserMessage); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("user1", deserialized.Id); - Assert.Equal("Hello, assistant!", deserialized.Content); - } - - [Fact] - public void AGUISystemMessage_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalMessage = new AGUISystemMessage - { - Id = "sys1", - Content = "You are a helpful assistant." - }; - - // Act - string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUISystemMessage); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUISystemMessage); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("sys1", deserialized.Id); - Assert.Equal("You are a helpful assistant.", deserialized.Content); - } - - [Fact] - public void AGUIDeveloperMessage_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalMessage = new AGUIDeveloperMessage - { - Id = "dev1", - Content = "Developer instructions here." - }; - - // Act - string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIDeveloperMessage); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIDeveloperMessage); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("dev1", deserialized.Id); - Assert.Equal("Developer instructions here.", deserialized.Content); - } - - [Fact] - public void AGUIAssistantMessage_WithTextOnly_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalMessage = new AGUIAssistantMessage - { - Id = "asst1", - Content = "I can help you with that." - }; - - // Act - string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIAssistantMessage); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIAssistantMessage); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("asst1", deserialized.Id); - Assert.Equal("I can help you with that.", deserialized.Content); - Assert.Null(deserialized.ToolCalls); - } - - [Fact] - public void AGUIAssistantMessage_WithToolCallsAndParameters_SerializesAndDeserializes_Correctly() - { - // Arrange - var parameters = new Dictionary - { - ["location"] = "Seattle", - ["units"] = "fahrenheit", - ["days"] = 5 - }; - string argumentsJson = JsonSerializer.Serialize(parameters, AGUIJsonSerializerContext.Default.Options); - - var originalMessage = new AGUIAssistantMessage - { - Id = "asst2", - Content = "Let me check the weather for you.", - ToolCalls = - [ - new AGUIToolCall - { - Id = "call_123", - Type = "function", - Function = new AGUIFunctionCall - { - Name = "GetWeather", - Arguments = argumentsJson - } - } - ] - }; - - // Act - string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIAssistantMessage); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIAssistantMessage); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("asst2", deserialized.Id); - Assert.Equal("Let me check the weather for you.", deserialized.Content); - Assert.NotNull(deserialized.ToolCalls); - Assert.Single(deserialized.ToolCalls); - - var toolCall = deserialized.ToolCalls[0]; - Assert.Equal("call_123", toolCall.Id); - Assert.Equal("function", toolCall.Type); - Assert.NotNull(toolCall.Function); - Assert.Equal("GetWeather", toolCall.Function.Name); - - // Verify parameters can be deserialized - var deserializedParams = JsonSerializer.Deserialize>( - toolCall.Function.Arguments, - AGUIJsonSerializerContext.Default.Options); - Assert.NotNull(deserializedParams); - Assert.Equal("Seattle", deserializedParams["location"].GetString()); - Assert.Equal("fahrenheit", deserializedParams["units"].GetString()); - Assert.Equal(5, deserializedParams["days"].GetInt32()); - } - - [Fact] - public void AGUIToolMessage_WithResults_SerializesAndDeserializes_Correctly() - { - // Arrange - var result = new Dictionary - { - ["temperature"] = 72.5, - ["conditions"] = "Sunny", - ["humidity"] = 45 - }; - string contentJson = JsonSerializer.Serialize(result, AGUIJsonSerializerContext.Default.Options); - - var originalMessage = new AGUIToolMessage - { - Id = "tool1", - ToolCallId = "call_123", - Content = contentJson - }; - - // Act - string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIToolMessage); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIToolMessage); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("tool1", deserialized.Id); - Assert.Equal("call_123", deserialized.ToolCallId); - Assert.NotNull(deserialized.Content); - - // Verify result content can be deserialized - var deserializedResult = JsonSerializer.Deserialize>( - deserialized.Content, - AGUIJsonSerializerContext.Default.Options); - Assert.NotNull(deserializedResult); - Assert.Equal(72.5, deserializedResult["temperature"].GetDouble()); - Assert.Equal("Sunny", deserializedResult["conditions"].GetString()); - Assert.Equal(45, deserializedResult["humidity"].GetInt32()); - } - - [Fact] - public void AGUIReasoningMessage_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalMessage = new AGUIReasoningMessage - { - Id = "reason1", - Content = "I need to consider the user's request carefully.", - EncryptedValue = "ErgDCkgIDB..." - }; - - // Act - string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIReasoningMessage); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIReasoningMessage); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("reason1", deserialized.Id); - Assert.Equal("I need to consider the user's request carefully.", deserialized.Content); - Assert.Equal("ErgDCkgIDB...", deserialized.EncryptedValue); - Assert.Equal(AGUIRoles.Reasoning, deserialized.Role); - } - - [Fact] - public void AGUIReasoningMessage_WithoutEncryptedValue_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalMessage = new AGUIReasoningMessage - { - Id = "reason2", - Content = "Thinking about this problem." - }; - - // Act - string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIReasoningMessage); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIReasoningMessage); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("reason2", deserialized.Id); - Assert.Equal("Thinking about this problem.", deserialized.Content); - Assert.Null(deserialized.EncryptedValue); - } - - [Fact] - public void AGUIReasoningMessage_DeserializesViaPolymorphicConverter_Correctly() - { - // Arrange - const string Json = """ - { - "id": "reason1", - "role": "reasoning", - "content": "Let me think about this.", - "encryptedValue": "tok-encrypted" - } - """; - - // Act - AGUIMessage? message = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.AGUIMessage); - - // Assert - Assert.NotNull(message); - var reasoningMessage = Assert.IsType(message); - Assert.Equal("reason1", reasoningMessage.Id); - Assert.Equal(AGUIRoles.Reasoning, reasoningMessage.Role); - Assert.Equal("Let me think about this.", reasoningMessage.Content); - Assert.Equal("tok-encrypted", reasoningMessage.EncryptedValue); - } - - [Fact] - public void AllSixMessageTypes_SerializeAsPolymorphicArray_Correctly() - { - // Arrange - AGUIMessage[] messages = - [ - new AGUISystemMessage { Id = "1", Content = "System message" }, - new AGUIDeveloperMessage { Id = "2", Content = "Developer message" }, - new AGUIUserMessage { Id = "3", Content = "User message" }, - new AGUIAssistantMessage { Id = "4", Content = "Assistant message" }, - new AGUIToolMessage { Id = "5", ToolCallId = "call_1", Content = "{\"result\":\"success\"}" }, - new AGUIReasoningMessage { Id = "6", Content = "Reasoning message", EncryptedValue = "tok-123" } - ]; - - // Act - string json = JsonSerializer.Serialize(messages, AGUIJsonSerializerContext.Default.AGUIMessageArray); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIMessageArray); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(6, 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]); - } - - #endregion - - #region Tool-Related Event Type Tests - - [Fact] - public void ToolCallStartEvent_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalEvent = new ToolCallStartEvent - { - ParentMessageId = "msg1", - ToolCallId = "call_123", - ToolCallName = "GetWeather" - }; - - // Act - string json = JsonSerializer.Serialize(originalEvent, AGUIJsonSerializerContext.Default.ToolCallStartEvent); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.ToolCallStartEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("msg1", deserialized.ParentMessageId); - Assert.Equal("call_123", deserialized.ToolCallId); - Assert.Equal("GetWeather", deserialized.ToolCallName); - Assert.Equal(AGUIEventTypes.ToolCallStart, deserialized.Type); - } - - [Fact] - public void ToolCallArgsEvent_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalEvent = new ToolCallArgsEvent - { - ToolCallId = "call_123", - Delta = "{\"location\":\"Seattle\",\"units\":\"fahrenheit\"}" - }; - - // Act - string json = JsonSerializer.Serialize(originalEvent, AGUIJsonSerializerContext.Default.ToolCallArgsEvent); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.ToolCallArgsEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("call_123", deserialized.ToolCallId); - Assert.Equal("{\"location\":\"Seattle\",\"units\":\"fahrenheit\"}", deserialized.Delta); - Assert.Equal(AGUIEventTypes.ToolCallArgs, deserialized.Type); - } - - [Fact] - public void ToolCallEndEvent_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalEvent = new ToolCallEndEvent - { - ToolCallId = "call_123" - }; - - // Act - string json = JsonSerializer.Serialize(originalEvent, AGUIJsonSerializerContext.Default.ToolCallEndEvent); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.ToolCallEndEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("call_123", deserialized.ToolCallId); - Assert.Equal(AGUIEventTypes.ToolCallEnd, deserialized.Type); - } - - [Fact] - public void ToolCallResultEvent_SerializesAndDeserializes_Correctly() - { - // Arrange - var originalEvent = new ToolCallResultEvent - { - MessageId = "msg1", - ToolCallId = "call_123", - Content = "{\"temperature\":72.5,\"conditions\":\"Sunny\"}", - Role = "tool" - }; - - // Act - string json = JsonSerializer.Serialize(originalEvent, AGUIJsonSerializerContext.Default.ToolCallResultEvent); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.ToolCallResultEvent); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal("msg1", deserialized.MessageId); - Assert.Equal("call_123", deserialized.ToolCallId); - Assert.Equal("{\"temperature\":72.5,\"conditions\":\"Sunny\"}", deserialized.Content); - Assert.Equal("tool", deserialized.Role); - Assert.Equal(AGUIEventTypes.ToolCallResult, deserialized.Type); - } - - [Fact] - public void AllToolEventTypes_SerializeAsPolymorphicBaseEvent_Correctly() - { - // Arrange - BaseEvent[] events = - [ - new RunStartedEvent { ThreadId = "t1", RunId = "r1" }, - new ToolCallStartEvent { ParentMessageId = "m1", ToolCallId = "c1", ToolCallName = "Tool1" }, - new ToolCallArgsEvent { ToolCallId = "c1", Delta = "{}" }, - new ToolCallEndEvent { ToolCallId = "c1" }, - new ToolCallResultEvent { MessageId = "m2", ToolCallId = "c1", Content = "{}", Role = "tool" }, - new RunFinishedEvent { ThreadId = "t1", RunId = "r1" } - ]; - - // Act - string json = JsonSerializer.Serialize(events, AGUIJsonSerializerContext.Default.Options); - var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.Options); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(6, 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]); - } - - #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/AGUIStreamingMessageIdTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs deleted file mode 100644 index b767573c167..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIStreamingMessageIdTests.cs +++ /dev/null @@ -1,401 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.AGUI.Shared; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.AGUI.UnitTests; - -/// -/// Tests for AGUI streaming behavior when MessageId is null or missing from -/// ChatResponseUpdate objects (e.g., providers like Google GenAI/Vertex AI -/// that don't supply MessageId on streaming chunks). -/// -public sealed class AGUIStreamingMessageIdTests -{ - /// - /// When ChatResponseUpdate objects with null MessageId are fed directly to - /// AsAGUIEventStreamAsync, the AGUI layer generates a fallback MessageId so - /// that events are valid regardless of agent type or provider. - /// - [Fact] - public async Task TextStreaming_NullMessageId_GeneratesFallbackInAGUILayerAsync() - { - // Arrange - Simulate a provider that does NOT set MessageId - List providerUpdates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "Hello"), - new ChatResponseUpdate(ChatRole.Assistant, " world"), - new ChatResponseUpdate(ChatRole.Assistant, "!") - ]; - - // Act - List aguiEvents = []; - await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() - .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) - { - aguiEvents.Add(evt); - } - - // Assert - AGUI layer should generate a fallback MessageId - List startEvents = aguiEvents.OfType().ToList(); - List contentEvents = aguiEvents.OfType().ToList(); - - Assert.Single(startEvents); - Assert.False(string.IsNullOrEmpty(startEvents[0].MessageId)); - - Assert.Equal(3, contentEvents.Count); - Assert.All(contentEvents, e => Assert.False(string.IsNullOrEmpty(e.MessageId))); - - // All events should share the same generated MessageId - string?[] distinctIds = contentEvents.Select(e => e.MessageId).Distinct().ToArray(); - Assert.Single(distinctIds); - Assert.Equal(startEvents[0].MessageId, distinctIds[0]); - } - - /// - /// Full pipeline: ChatClientAgent → AsChatResponseUpdatesAsync → AsAGUIEventStreamAsync - /// with a provider that returns null MessageId. Verifies that fallback MessageId - /// generation ensures valid AGUI events. - /// - [Fact] - public async Task FullPipeline_NullProviderMessageId_ProducesValidAGUIEventsAsync() - { - // Arrange - ChatClientAgent with a mock client that omits MessageId - IChatClient mockChatClient = new NullMessageIdChatClient(); - ChatClientAgent agent = new(mockChatClient, name: "test-agent"); - - ChatMessage userMessage = new(ChatRole.User, "tell me about agents"); - - // Act - Run the full pipeline exactly as MapAGUI does - List aguiEvents = []; - await foreach (BaseEvent evt in agent - .RunStreamingAsync([userMessage]) - .AsChatResponseUpdatesAsync() - .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) - { - aguiEvents.Add(evt); - } - - // Assert — The pipeline should produce AGUI events with valid messageId - List startEvents = aguiEvents.OfType().ToList(); - List contentEvents = aguiEvents.OfType().ToList(); - - Assert.NotEmpty(startEvents); - Assert.NotEmpty(contentEvents); - - foreach (TextMessageStartEvent startEvent in startEvents) - { - Assert.False( - string.IsNullOrEmpty(startEvent.MessageId), - "TextMessageStartEvent.MessageId should not be null/empty when provider omits it"); - } - - foreach (TextMessageContentEvent contentEvent in contentEvents) - { - Assert.False( - string.IsNullOrEmpty(contentEvent.MessageId), - "TextMessageContentEvent.MessageId should not be null/empty when provider omits it"); - } - - // All content events should share the same messageId - string?[] distinctMessageIds = contentEvents.Select(e => e.MessageId).Distinct().ToArray(); - Assert.Single(distinctMessageIds); - } - - /// - /// When ChatResponseUpdate has empty string MessageId, the AGUI layer passes - /// through the raw provider value for ToolCallStartEvent.ParentMessageId. - /// Tool-call chunks should NOT receive the text-event fallback GUID — that - /// would collapse parallel tool calls into one assistant message in the FE. - /// - [Fact] - public async Task ToolCalls_EmptyMessageId_DoesNotGenerateFallbackParentMessageIdAsync() - { - // Arrange - ChatResponseUpdate with a tool call but empty MessageId - FunctionCallContent functionCall = new("call_abc123", "GetWeather") - { - Arguments = new Dictionary { ["location"] = "San Francisco" } - }; - - List providerUpdates = - [ - new ChatResponseUpdate - { - Role = ChatRole.Assistant, - MessageId = "", - Contents = [functionCall] - } - ]; - - // Act - List aguiEvents = []; - await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() - .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) - { - aguiEvents.Add(evt); - } - - // Assert — ParentMessageId should be empty (raw provider value, no synthetic fallback) - ToolCallStartEvent? toolCallStart = aguiEvents.OfType().FirstOrDefault(); - Assert.NotNull(toolCallStart); - Assert.Equal("call_abc123", toolCallStart.ToolCallId); - Assert.Equal("GetWeather", toolCallStart.ToolCallName); - Assert.True( - string.IsNullOrEmpty(toolCallStart.ParentMessageId), - "ParentMessageId should be empty when provider omits MessageId (raw pass-through)"); - } - - /// - /// Tool results are separate tool-role messages, so their fallback IDs must not - /// collide with the assistant message that requested the tool call. - /// - [Fact] - public async Task ToolResults_NullMessageId_GeneratesDistinctMessageIdAsync() - { - FunctionCallContent functionCall = new("call_abc123", "GetWeather") - { - Arguments = new Dictionary { ["location"] = "San Francisco" } - }; - - List providerUpdates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"), - new ChatResponseUpdate - { - Role = ChatRole.Assistant, - Contents = [functionCall] - }, - new ChatResponseUpdate(ChatRole.Tool, [new FunctionResultContent("call_abc123", "72F and sunny")]) - ]; - - List aguiEvents = []; - await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() - .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) - { - aguiEvents.Add(evt); - } - - TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType()); - ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType()); - ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType()); - - // Tool-call ParentMessageId should NOT leak the text fallback GUID - Assert.NotEqual(textStart.MessageId, toolCallStart.ParentMessageId); - Assert.Equal("call_abc123", toolCallResult.ToolCallId); - Assert.False(string.IsNullOrEmpty(toolCallResult.MessageId)); - Assert.NotEqual(textStart.MessageId, toolCallResult.MessageId); - // Result MessageId should be deterministic based on CallId - Assert.Equal("result-call_abc123", toolCallResult.MessageId); - } - - [Fact] - public async Task ToolResults_WithTextContent_GeneratesDistinctMessageIdAsync() - { - FunctionCallContent functionCall = new("call_abc123", "GetWeather") - { - Arguments = new Dictionary { ["location"] = "San Francisco" } - }; - - List providerUpdates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "Checking the weather"), - new ChatResponseUpdate - { - Role = ChatRole.Assistant, - Contents = [functionCall] - }, - new ChatResponseUpdate - { - Role = ChatRole.Tool, - Contents = - [ - new TextContent("Tool says: "), - new FunctionResultContent("call_abc123", "72F and sunny") - ] - } - ]; - - List aguiEvents = []; - await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() - .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) - { - aguiEvents.Add(evt); - } - - TextMessageStartEvent[] textStarts = aguiEvents.OfType().ToArray(); - TextMessageContentEvent toolText = Assert.Single( - aguiEvents.OfType(), - content => content.Delta == "Tool says: "); - ToolCallStartEvent toolCallStart = Assert.Single(aguiEvents.OfType()); - ToolCallResultEvent toolCallResult = Assert.Single(aguiEvents.OfType()); - - // Tool-call ParentMessageId should NOT leak the text fallback GUID - Assert.NotEqual(textStarts[0].MessageId, toolCallStart.ParentMessageId); - Assert.NotEqual(textStarts[0].MessageId, toolCallResult.MessageId); - // Result MessageId should be deterministic based on CallId - Assert.Equal("result-call_abc123", toolCallResult.MessageId); - } - - /// - /// When a provider properly sets MessageId (e.g., OpenAI), the AGUI pipeline - /// produces valid events with correct messageId values. - /// - [Fact] - public async Task TextStreaming_WithProviderMessageId_ProducesValidAGUIEventsAsync() - { - // Arrange — Provider that properly sets MessageId - List providerUpdates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "Hello") - { - MessageId = "chatcmpl-abc123" - }, - new ChatResponseUpdate(ChatRole.Assistant, " world") - { - MessageId = "chatcmpl-abc123" - } - ]; - - // Act - List aguiEvents = []; - await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() - .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) - { - aguiEvents.Add(evt); - } - - // Assert - List startEvents = aguiEvents.OfType().ToList(); - List contentEvents = aguiEvents.OfType().ToList(); - - Assert.Single(startEvents); - Assert.Equal("chatcmpl-abc123", startEvents[0].MessageId); - - Assert.Equal(2, contentEvents.Count); - Assert.All(contentEvents, e => Assert.Equal("chatcmpl-abc123", e.MessageId)); - } - - /// - /// Bug #1 reproduction: parallel tool calls with empty MessageId should NOT all - /// share the same synthetic ParentMessageId. Each should pass through the raw - /// provider value (empty), allowing the FE to render them as distinct cards. - /// - [Fact] - public async Task ParallelToolCalls_EmptyMessageId_DoNotShareParentMessageIdAsync() - { - // Arrange — 3 parallel tool calls with empty MessageId (real OpenAI behavior) - List providerUpdates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "Let me run those queries.") { MessageId = "chatcmpl-real" }, - new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_A", "query") { Arguments = new Dictionary { ["q"] = "1" } }] }, - new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_B", "query") { Arguments = new Dictionary { ["q"] = "2" } }] }, - new ChatResponseUpdate { Role = ChatRole.Assistant, MessageId = "", Contents = [new FunctionCallContent("call_C", "query") { Arguments = new Dictionary { ["q"] = "3" } }] }, - ]; - - // Act - List aguiEvents = []; - await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() - .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) - { - aguiEvents.Add(evt); - } - - // Assert — all 3 tool calls should have empty ParentMessageId (raw provider value), - // NOT the text fallback GUID - List toolCallStarts = aguiEvents.OfType().ToList(); - Assert.Equal(3, toolCallStarts.Count); - Assert.All(toolCallStarts, tc => Assert.True(string.IsNullOrEmpty(tc.ParentMessageId))); - - // Text events should still have a valid fallback MessageId - TextMessageStartEvent textStart = Assert.Single(aguiEvents.OfType()); - Assert.False(string.IsNullOrEmpty(textStart.MessageId)); - } - - /// - /// Bug #2 reproduction: tool results batched into one ChatResponseUpdate with a - /// shared MEAI MessageId should each get a unique deterministic MessageId. - /// - [Fact] - public async Task ToolCallResults_SharedMeaiMessageId_HaveUniqueMessageIdsPerCallAsync() - { - // Arrange — MEAI batches all FunctionResultContent into one update with shared id - List providerUpdates = - [ - new ChatResponseUpdate - { - Role = ChatRole.Tool, - MessageId = "meai-shared-id", - Contents = - [ - new FunctionResultContent("call_A", "result1"), - new FunctionResultContent("call_B", "result2"), - new FunctionResultContent("call_C", "result3"), - ] - }, - ]; - - // Act - List aguiEvents = []; - await foreach (BaseEvent evt in providerUpdates.ToAsyncEnumerableAsync() - .AsAGUIEventStreamAsync("thread-1", "run-1", AGUIJsonSerializerContext.Default.Options)) - { - aguiEvents.Add(evt); - } - - // Assert — each result should have a unique MessageId - List toolResults = aguiEvents.OfType().ToList(); - Assert.Equal(3, toolResults.Count); - - string?[] distinctIds = toolResults.Select(r => r.MessageId).Distinct().ToArray(); - Assert.Equal(3, distinctIds.Length); - - // Verify deterministic format - Assert.Equal("result-call_A", toolResults[0].MessageId); - Assert.Equal("result-call_B", toolResults[1].MessageId); - Assert.Equal("result-call_C", toolResults[2].MessageId); - } -} - -/// -/// Mock IChatClient that simulates a provider not setting MessageId on streaming chunks -/// (e.g., Google GenAI / Vertex AI). -/// -internal sealed class NullMessageIdChatClient : IChatClient -{ - public void Dispose() - { - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "response")])); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - foreach (string chunk in (string[])["Agents", " are", " autonomous", " programs."]) - { - yield return new ChatResponseUpdate - { - Role = ChatRole.Assistant, - Contents = [new TextContent(chunk)] - }; - - await Task.Yield(); - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AIToolExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AIToolExtensionsTests.cs deleted file mode 100644 index ebedd68f334..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AIToolExtensionsTests.cs +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using Microsoft.Agents.AI.AGUI.Shared; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.AGUI.UnitTests; - -/// -/// Unit tests for the class. -/// -public sealed class AIToolExtensionsTests -{ - [Fact] - public void AsAGUITools_WithAIFunction_ConvertsToAGUIToolCorrectly() - { - // Arrange - AIFunction function = AIFunctionFactory.Create( - (string location) => $"Weather in {location}", - "GetWeather", - "Gets the current weather"); - List tools = [function]; - - // Act - List aguiTools = tools.AsAGUITools().ToList(); - - // Assert - AGUITool aguiTool = Assert.Single(aguiTools); - Assert.Equal("GetWeather", aguiTool.Name); - Assert.Equal("Gets the current weather", aguiTool.Description); - Assert.NotEqual(default, aguiTool.Parameters); - } - - [Fact] - public void AsAGUITools_WithMultipleFunctions_ConvertsAllCorrectly() - { - // Arrange - List tools = - [ - AIFunctionFactory.Create(() => "Result1", "Tool1", "First tool"), - AIFunctionFactory.Create(() => "Result2", "Tool2", "Second tool"), - AIFunctionFactory.Create(() => "Result3", "Tool3", "Third tool") - ]; - - // Act - List aguiTools = tools.AsAGUITools().ToList(); - - // Assert - Assert.Equal(3, aguiTools.Count); - Assert.Equal("Tool1", aguiTools[0].Name); - Assert.Equal("Tool2", aguiTools[1].Name); - Assert.Equal("Tool3", aguiTools[2].Name); - } - - [Fact] - public void AsAGUITools_WithNullInput_ReturnsEmptyEnumerable() - { - // Arrange - IEnumerable? tools = null; - - // Act - IEnumerable aguiTools = tools!.AsAGUITools(); - - // Assert - Assert.NotNull(aguiTools); - Assert.Empty(aguiTools); - } - - [Fact] - public void AsAGUITools_WithEmptyInput_ReturnsEmptyEnumerable() - { - // Arrange - List tools = []; - - // Act - List aguiTools = tools.AsAGUITools().ToList(); - - // Assert - Assert.Empty(aguiTools); - } - - [Fact] - public void AsAGUITools_FiltersOutNonAIFunctionTools() - { - // Arrange - mix of AIFunction and non-function tools - AIFunction function = AIFunctionFactory.Create(() => "Result", "TestTool"); - // Create a custom AITool that's not an AIFunction - var declaration = AIFunctionFactory.CreateDeclaration("DeclarationOnly", "Description", JsonElement.Parse("{}")); - - List tools = [function, declaration]; - - // Act - List aguiTools = tools.AsAGUITools().ToList(); - - // Assert - // Only the AIFunction should be converted, declarations are filtered - Assert.Equal(2, aguiTools.Count); // Actually both convert since declaration is also AIFunctionDeclaration - } - - [Fact] - public void AsAITools_WithAGUITool_ConvertsToAIFunctionDeclarationCorrectly() - { - // Arrange - AGUITool aguiTool = new() - { - Name = "TestTool", - Description = "Test description", - Parameters = JsonElement.Parse("""{"type":"object","properties":{}}""") - }; - List aguiTools = [aguiTool]; - - // Act - List tools = aguiTools.AsAITools().ToList(); - - // Assert - AITool tool = Assert.Single(tools); - Assert.IsType(tool, exactMatch: false); - var declaration = (AIFunctionDeclaration)tool; - Assert.Equal("TestTool", declaration.Name); - Assert.Equal("Test description", declaration.Description); - } - - [Fact] - public void AsAITools_WithMultipleAGUITools_ConvertsAllCorrectly() - { - // Arrange - List aguiTools = - [ - new AGUITool { Name = "Tool1", Description = "Desc1", Parameters = JsonElement.Parse("{}") }, - new AGUITool { Name = "Tool2", Description = "Desc2", Parameters = JsonElement.Parse("{}") }, - new AGUITool { Name = "Tool3", Description = "Desc3", Parameters = JsonElement.Parse("{}") } - ]; - - // Act - List tools = aguiTools.AsAITools().ToList(); - - // Assert - Assert.Equal(3, tools.Count); - Assert.All(tools, t => Assert.IsType(t, exactMatch: false)); - } - - [Fact] - public void AsAITools_WithNullInput_ReturnsEmptyEnumerable() - { - // Arrange - IEnumerable? aguiTools = null; - - // Act - IEnumerable tools = aguiTools!.AsAITools(); - - // Assert - Assert.NotNull(tools); - Assert.Empty(tools); - } - - [Fact] - public void AsAITools_WithEmptyInput_ReturnsEmptyEnumerable() - { - // Arrange - List aguiTools = []; - - // Act - List tools = aguiTools.AsAITools().ToList(); - - // Assert - Assert.Empty(tools); - } - - [Fact] - public void AsAITools_CreatesDeclarationsOnly_NotInvokableFunctions() - { - // Arrange - AGUITool aguiTool = new() - { - Name = "RemoteTool", - Description = "Tool implemented on server", - Parameters = JsonElement.Parse("""{"type":"object"}""") - }; - - // Act - List aguiToolsList = [aguiTool]; - AITool tool = aguiToolsList.AsAITools().Single(); - - // Assert - // The tool should be a declaration, not an executable function - Assert.IsType(tool, exactMatch: false); - // AIFunctionDeclaration cannot be invoked (no implementation) - // This is correct since the actual implementation exists on the client side - } - - [Fact] - public void RoundTrip_AIFunctionToAGUIToolBackToDeclaration_PreservesMetadata() - { - // Arrange - AIFunction originalFunction = AIFunctionFactory.Create( - (string name, int age) => $"{name} is {age} years old", - "FormatPerson", - "Formats person information"); - - // Act - List originalList = [originalFunction]; - AGUITool aguiTool = originalList.AsAGUITools().Single(); - List aguiToolsList = [aguiTool]; - AITool reconstructed = aguiToolsList.AsAITools().Single(); - - // Assert - Assert.IsType(reconstructed, exactMatch: false); - var declaration = (AIFunctionDeclaration)reconstructed; - Assert.Equal("FormatPerson", declaration.Name); - Assert.Equal("Formats person information", declaration.Description); - // Schema should be preserved through the round trip - Assert.NotEqual(default, declaration.JsonSchema); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs deleted file mode 100644 index 78f9023a361..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs +++ /dev/null @@ -1,1245 +0,0 @@ -// 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 -} diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj deleted file mode 100644 index 0dab0aa9e41..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/TestHelpers.cs deleted file mode 100644 index 925148b64cd..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/TestHelpers.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.AGUI.UnitTests; - -internal static class TestHelpers -{ - /// - /// Extension method to convert a synchronous enumerable to an async enumerable for testing purposes. - /// - public static async IAsyncEnumerable ToAsyncEnumerableAsync(this IEnumerable source) - { - foreach (T item in source) - { - yield return item; - await Task.CompletedTask; - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs index d94e5204207..24eacc36252 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/BasicStreamingTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -10,8 +10,9 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Client; using FluentAssertions; -using Microsoft.Agents.AI.AGUI; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.TestHost; @@ -30,7 +31,7 @@ public async Task ClientReceivesStreamedAssistantMessageAsync() { // Arrange await this.SetupTestServerAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "hello"); @@ -61,7 +62,7 @@ public async Task ClientReceivesRunLifecycleEventsAsync() { // Arrange await this.SetupTestServerAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "test"); @@ -78,7 +79,9 @@ public async Task ClientReceivesRunLifecycleEventsAsync() updates.Should().NotBeEmpty(); updates[0].ResponseId.Should().NotBeNullOrEmpty(); ChatResponseUpdate firstUpdate = updates[0].AsChatResponseUpdate(); - string? threadId = firstUpdate.ConversationId; + // The AG-UI thread id is surfaced on the RUN_STARTED event (the new AGUI.Client keeps the + // client stateless and never populates ChatResponseUpdate.ConversationId). + string? threadId = (firstUpdate.RawRepresentation as RunStartedEvent)?.ThreadId; string? runId = updates[0].ResponseId; threadId.Should().NotBeNullOrEmpty(); runId.Should().NotBeNullOrEmpty(); @@ -97,7 +100,15 @@ public async Task ClientReceivesRunLifecycleEventsAsync() AgentResponseUpdate lastUpdate = updates[^1]; lastUpdate.ResponseId.Should().Be(runId); ChatResponseUpdate lastChatUpdate = lastUpdate.AsChatResponseUpdate(); - lastChatUpdate.ConversationId.Should().Be(threadId); + // The stateless client never populates ChatResponseUpdate.ConversationId; thread identity stays + // on the AG-UI wire events instead, so verify the RUN_FINISHED event carries the same ids. + lastChatUpdate.ConversationId.Should().BeNull(); + RunFinishedEvent? runFinished = updates + .Select(u => u.AsChatResponseUpdate().RawRepresentation as RunFinishedEvent) + .FirstOrDefault(e => e is not null); + runFinished.Should().NotBeNull(); + runFinished!.ThreadId.Should().Be(threadId); + runFinished.RunId.Should().Be(runId); } [Fact] @@ -105,7 +116,7 @@ public async Task RunAsyncAggregatesStreamingUpdatesAsync() { // Arrange await this.SetupTestServerAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "hello"); @@ -124,7 +135,7 @@ public async Task MultiTurnConversationPreservesAllMessagesInSessionAsync() { // Arrange await this.SetupTestServerAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); ChatClientAgentSession chatClientSession = (ChatClientAgentSession)await agent.CreateSessionAsync(); ChatMessage firstUserMessage = new(ChatRole.User, "First question"); @@ -168,7 +179,7 @@ public async Task AgentSendsMultipleMessagesInOneTurnAsync() { // Arrange await this.SetupTestServerAsync(useMultiMessageAgent: true); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); ChatClientAgentSession chatClientSession = (ChatClientAgentSession)await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "Tell me a story"); @@ -200,7 +211,7 @@ public async Task UserSendsMultipleMessagesAtOnceAsync() { // Arrange await this.SetupTestServerAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); ChatClientAgentSession chatClientSession = (ChatClientAgentSession)await agent.CreateSessionAsync(); @@ -236,7 +247,7 @@ private async Task SetupTestServerAsync(bool useMultiMessageAgent = false) WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); - builder.Services.AddAGUI(); + builder.Services.AddAGUIServer(); if (useMultiMessageAgent) { @@ -253,7 +264,7 @@ private async Task SetupTestServerAsync(bool useMultiMessageAgent = false) ? this._app.Services.GetRequiredService() : this._app.Services.GetRequiredService(); - this._app.MapAGUI("/agent", agent); + this._app.MapAGUIServer("/agent", agent); await this._app.StartAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs index 60d430d23c6..40c736542ef 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ForwardedPropertiesTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -12,6 +12,8 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Server; using FluentAssertions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; @@ -268,12 +270,12 @@ public async Task ForwardedProps_WithMixedTypes_AreCorrectlyParsedAsync() private async Task SetupTestServerAsync(FakeForwardedPropsAgent fakeAgent) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddAGUI(); + builder.Services.AddAGUIServer(); builder.WebHost.UseTestServer(); this._app = builder.Build(); - this._app.MapAGUI("/agent", fakeAgent); + this._app.MapAGUIServer("/agent", fakeAgent); await this._app.StartAsync(); @@ -315,10 +317,10 @@ protected override async IAsyncEnumerable RunCoreStreamingA AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // Extract forwarded properties from ChatOptions.AdditionalProperties (set by AG-UI hosting layer) - if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } && - properties.TryGetValue("ag_ui_forwarded_properties", out object? propsObj) && - propsObj is JsonElement forwardedProps) + // Recover the originating AG-UI input from the request options (set by the hosting layer). + if (options is ChatClientAgentRunOptions { ChatOptions: { } chatOptions } && + chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) && + agentInput.ForwardedProperties is { ValueKind: not JsonValueKind.Undefined } forwardedProps) { this.ReceivedForwardedProperties = forwardedProps; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj index e0b072a44bd..019f0eb7a16 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj @@ -23,7 +23,9 @@ - + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs index 33b842bf34e..12259a52d79 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs @@ -1,16 +1,18 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Net.Http; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Client; using FluentAssertions; -using Microsoft.Agents.AI.AGUI; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.TestHost; @@ -31,7 +33,7 @@ public async Task MultiTurnWithSessionStore_PersistsSessionAcrossRequestsAsync() // FakeSessionAgent tracks turn count in session StateBag so we can verify // that state survives the serialization round-trip through the session store. await this.SetupTestServerWithSessionStoreAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); @@ -43,10 +45,35 @@ public async Task MultiTurnWithSessionStore_PersistsSessionAcrossRequestsAsync() firstTurnUpdates.Add(update); } - // Act - Second turn (same thread ID to test session persistence) + // Act - Second turn (continue the same AG-UI run on the same thread). + // The new AGUI.Client is stateless and never round-trips ConversationId. AG-UI continuation is + // expressed on the wire with the same threadId plus a parentRunId equal to the previous turn's + // runId. We capture both from turn 1's RUN_STARTED event and supply them to the stateless client + // through RawRepresentationFactory, sending only the new message (the continuation subset). + RunStartedEvent? firstRunStarted = firstTurnUpdates + .Select(u => u.AsChatResponseUpdate().RawRepresentation as RunStartedEvent) + .FirstOrDefault(e => e is not null); + firstRunStarted.Should().NotBeNull(); + string threadId = firstRunStarted!.ThreadId; + string previousRunId = firstRunStarted.RunId; + threadId.Should().NotBeNullOrEmpty(); + previousRunId.Should().NotBeNullOrEmpty(); + ChatMessage secondUserMessage = new(ChatRole.User, "Second message"); + var continuationOptions = new ChatClientAgentRunOptions + { + ChatOptions = new ChatOptions + { + RawRepresentationFactory = _ => new RunAgentInput + { + ThreadId = threadId, + ParentRunId = previousRunId, + Messages = new[] { secondUserMessage }.AsAGUIMessages().ToList(), + }, + }, + }; List secondTurnUpdates = []; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], session, new AgentRunOptions(), CancellationToken.None)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], session, continuationOptions, CancellationToken.None)) { secondTurnUpdates.Add(update); } @@ -66,11 +93,11 @@ public async Task MultiTurnWithSessionStore_PersistsSessionAcrossRequestsAsync() } [Fact] - public async Task MapAGUI_WithAgentName_StreamsResponseCorrectlyAsync() + public async Task MapAGUIServer_WithAgentName_StreamsResponseCorrectlyAsync() { - // Arrange - use the MapAGUI(agentName, pattern) overload via hosting DI + // Arrange - use the MapAGUIServer(agentName, pattern) overload via hosting DI await this.SetupTestServerWithSessionStoreAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "hello"); @@ -98,7 +125,7 @@ private async Task SetupTestServerWithSessionStoreAsync() WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); - builder.Services.AddAGUI(); + builder.Services.AddAGUIServer(); // Register agent using hosting DI pattern with InMemorySessionStore builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name)) @@ -106,8 +133,8 @@ private async Task SetupTestServerWithSessionStoreAsync() this._app = builder.Build(); - // Use the agentName overload of MapAGUI - this._app.MapAGUI("session-test-agent", "/agent"); + // Use the agentName overload of MapAGUIServer + this._app.MapAGUIServer("session-test-agent", "/agent"); await this._app.StartAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs index cc9c9ce8ef4..52d9ad70c5a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SharedStateTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -10,8 +10,10 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Client; +using AGUI.Server; using FluentAssertions; -using Microsoft.Agents.AI.AGUI; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.TestHost; @@ -26,124 +28,95 @@ public sealed class SharedStateTests : IAsyncDisposable private HttpClient? _client; [Fact] - public async Task StateSnapshot_IsReturnedAsDataContent_WithCorrectMediaTypeAsync() + public async Task StateSnapshot_IsSurfacedAsRawStateSnapshotEventAsync() { // Arrange - var initialState = new { counter = 42, status = "active" }; var fakeAgent = new FakeStateAgent(); - await this.SetupTestServerAsync(fakeAgent); - var chatClient = new AGUIChatClient(this._client!, "", null); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); - ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); - - string stateJson = JsonSerializer.Serialize(initialState); - byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson); - DataContent stateContent = new(stateBytes, "application/json"); - ChatMessage stateMessage = new(ChatRole.System, [stateContent]); + AIAgent agent = this.CreateAgent(); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + + // The AG-UI thread state travels on RunAgentInput.State, supplied to the stateless client via + // RawRepresentationFactory. The agent echoes it back as a STATE_SNAPSHOT event which the client + // surfaces as ChatResponseUpdate.RawRepresentation (issue #4869: no DataContent / ConversationId). + var initialState = JsonSerializer.SerializeToElement(new { counter = 42, status = "active" }); ChatMessage userMessage = new(ChatRole.User, "update state"); List updates = []; // Act - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], session, new AgentRunOptions(), CancellationToken.None)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, StateRunOptions(initialState), CancellationToken.None)) { updates.Add(update); } - // Assert + // Assert - the state snapshot is surfaced as a StateSnapshotEvent raw representation. updates.Should().NotBeEmpty(); - // Should receive state snapshot as DataContent with application/json media type - AgentResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")); - stateUpdate.Should().NotBeNull("should receive state snapshot update"); - - DataContent? dataContent = stateUpdate!.Contents.OfType().FirstOrDefault(dc => dc.MediaType == "application/json"); - dataContent.Should().NotBeNull(); - - // Verify the state content - string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray()); - JsonElement receivedState = JsonElement.Parse(receivedJson); - receivedState.GetProperty("counter").GetInt32().Should().Be(43, "state should be incremented"); - receivedState.GetProperty("status").GetString().Should().Be("active"); + StateSnapshotEvent? snapshot = FindStateSnapshot(updates); + snapshot.Should().NotBeNull("should receive a STATE_SNAPSHOT event"); + snapshot!.Snapshot.GetProperty("counter").GetInt32().Should().Be(43, "state should be incremented"); + snapshot.Snapshot.GetProperty("status").GetString().Should().Be("active"); } [Fact] - public async Task StateSnapshot_HasCorrectAdditionalPropertiesAsync() + public async Task StateSnapshot_UpdateHasAssistantRoleAndNoConversationIdAsync() { // Arrange - var initialState = new { step = 1 }; var fakeAgent = new FakeStateAgent(); - await this.SetupTestServerAsync(fakeAgent); - var chatClient = new AGUIChatClient(this._client!, "", null); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); - ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); - - string stateJson = JsonSerializer.Serialize(initialState); - byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson); - DataContent stateContent = new(stateBytes, "application/json"); - ChatMessage stateMessage = new(ChatRole.System, [stateContent]); + AIAgent agent = this.CreateAgent(); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + + var initialState = JsonSerializer.SerializeToElement(new { step = 1 }); ChatMessage userMessage = new(ChatRole.User, "process"); List updates = []; // Act - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], session, new AgentRunOptions(), CancellationToken.None)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, StateRunOptions(initialState), CancellationToken.None)) { updates.Add(update); } - // Assert - AgentResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")); + // Assert - the state update carries the StateSnapshotEvent and the stateless client leaves + // ConversationId unset (state identity stays on the AG-UI wire events). + AgentResponseUpdate? stateUpdate = updates + .FirstOrDefault(u => u.AsChatResponseUpdate().RawRepresentation is StateSnapshotEvent); stateUpdate.Should().NotBeNull(); ChatResponseUpdate chatUpdate = stateUpdate!.AsChatResponseUpdate(); - chatUpdate.AdditionalProperties.Should().NotBeNull(); - chatUpdate.AdditionalProperties.Should().ContainKey("is_state_snapshot"); - ((bool)chatUpdate.AdditionalProperties!["is_state_snapshot"]!).Should().BeTrue(); + chatUpdate.RawRepresentation.Should().BeOfType(); + chatUpdate.ConversationId.Should().BeNull(); + chatUpdate.Role.Should().Be(ChatRole.Assistant); } [Fact] public async Task ComplexState_WithNestedObjectsAndArrays_RoundTripsCorrectlyAsync() { // Arrange - var complexState = new - { - sessionId = "test-123", - nested = new { value = "test", count = 10 }, - array = new[] { 1, 2, 3 }, - tags = new[] { "tag1", "tag2" } - }; var fakeAgent = new FakeStateAgent(); - await this.SetupTestServerAsync(fakeAgent); - var chatClient = new AGUIChatClient(this._client!, "", null); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); - ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); - - string stateJson = JsonSerializer.Serialize(complexState); - byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson); - DataContent stateContent = new(stateBytes, "application/json"); - ChatMessage stateMessage = new(ChatRole.System, [stateContent]); + AIAgent agent = this.CreateAgent(); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + + JsonElement complexState = JsonDocument.Parse( + """{"sessionId":"test-123","nested":{"value":"test","count":10},"array":[1,2,3],"tags":["tag1","tag2"]}""").RootElement.Clone(); ChatMessage userMessage = new(ChatRole.User, "process complex state"); List updates = []; // Act - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], session, new AgentRunOptions(), CancellationToken.None)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, StateRunOptions(complexState), CancellationToken.None)) { updates.Add(update); } // Assert - AgentResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")); - stateUpdate.Should().NotBeNull(); - - DataContent? dataContent = stateUpdate!.Contents.OfType().FirstOrDefault(dc => dc.MediaType == "application/json"); - string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray()); - JsonElement receivedState = JsonElement.Parse(receivedJson); + StateSnapshotEvent? snapshot = FindStateSnapshot(updates); + snapshot.Should().NotBeNull(); + JsonElement receivedState = snapshot!.Snapshot; receivedState.GetProperty("sessionId").GetString().Should().Be("test-123"); receivedState.GetProperty("nested").GetProperty("count").GetInt32().Should().Be(10); receivedState.GetProperty("array").GetArrayLength().Should().Be(3); @@ -154,52 +127,39 @@ public async Task ComplexState_WithNestedObjectsAndArrays_RoundTripsCorrectlyAsy public async Task StateSnapshot_CanBeUsedInSubsequentRequest_ForStateRoundTripAsync() { // Arrange - var initialState = new { counter = 1, sessionId = "round-trip-test" }; var fakeAgent = new FakeStateAgent(); - await this.SetupTestServerAsync(fakeAgent); - var chatClient = new AGUIChatClient(this._client!, "", null); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); - ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); - - string stateJson = JsonSerializer.Serialize(initialState); - byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson); - DataContent stateContent = new(stateBytes, "application/json"); - ChatMessage stateMessage = new(ChatRole.System, [stateContent]); + AIAgent agent = this.CreateAgent(); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + + var initialState = JsonSerializer.SerializeToElement(new { counter = 1, sessionId = "round-trip-test" }); ChatMessage userMessage = new(ChatRole.User, "increment"); List firstRoundUpdates = []; // Act - First round - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], session, new AgentRunOptions(), CancellationToken.None)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, StateRunOptions(initialState), CancellationToken.None)) { firstRoundUpdates.Add(update); } - // Extract state snapshot from first round - AgentResponseUpdate? firstStateUpdate = firstRoundUpdates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")); - firstStateUpdate.Should().NotBeNull(); - DataContent? firstStateContent = firstStateUpdate!.Contents.OfType().FirstOrDefault(dc => dc.MediaType == "application/json"); + // Feed the returned state snapshot back into the second round. + StateSnapshotEvent? firstSnapshot = FindStateSnapshot(firstRoundUpdates); + firstSnapshot.Should().NotBeNull(); + firstSnapshot!.Snapshot.GetProperty("counter").GetInt32().Should().Be(2); - // Second round - use returned state - ChatMessage secondStateMessage = new(ChatRole.System, [firstStateContent!]); ChatMessage secondUserMessage = new(ChatRole.User, "increment again"); List secondRoundUpdates = []; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage, secondStateMessage], session, new AgentRunOptions(), CancellationToken.None)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], session, StateRunOptions(firstSnapshot.Snapshot), CancellationToken.None)) { secondRoundUpdates.Add(update); } - // Assert - Second round should have incremented counter again - AgentResponseUpdate? secondStateUpdate = secondRoundUpdates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")); - secondStateUpdate.Should().NotBeNull(); - - DataContent? secondStateContent = secondStateUpdate!.Contents.OfType().FirstOrDefault(dc => dc.MediaType == "application/json"); - string secondStateJson = System.Text.Encoding.UTF8.GetString(secondStateContent!.Data.ToArray()); - JsonElement secondState = JsonElement.Parse(secondStateJson); - - secondState.GetProperty("counter").GetInt32().Should().Be(3, "counter should be incremented twice: 1 -> 2 -> 3"); + // Assert - Second round should have incremented counter again. + StateSnapshotEvent? secondSnapshot = FindStateSnapshot(secondRoundUpdates); + secondSnapshot.Should().NotBeNull(); + secondSnapshot!.Snapshot.GetProperty("counter").GetInt32().Should().Be(3, "counter should be incremented twice: 1 -> 2 -> 3"); } [Fact] @@ -207,17 +167,15 @@ public async Task WithoutState_AgentBehavesNormally_NoStateSnapshotReturnedAsync { // Arrange var fakeAgent = new FakeStateAgent(); - await this.SetupTestServerAsync(fakeAgent); - var chatClient = new AGUIChatClient(this._client!, "", null); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); - ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + AIAgent agent = this.CreateAgent(); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "hello"); List updates = []; - // Act + // Act - no RunAgentInput.State provided. await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, new AgentRunOptions(), CancellationToken.None)) { updates.Add(update); @@ -225,12 +183,7 @@ public async Task WithoutState_AgentBehavesNormally_NoStateSnapshotReturnedAsync // Assert updates.Should().NotBeEmpty(); - - // Should NOT have state snapshot when no state is sent - bool hasStateSnapshot = updates.Any(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")); - hasStateSnapshot.Should().BeFalse("should not return state snapshot when no state is provided"); - - // Should have normal text response + FindStateSnapshot(updates).Should().BeNull("should not return state snapshot when no state is provided"); updates.Should().Contain(u => u.Contents.Any(c => c is TextContent)); } @@ -238,86 +191,81 @@ public async Task WithoutState_AgentBehavesNormally_NoStateSnapshotReturnedAsync public async Task EmptyState_DoesNotTriggerStateHandlingAsync() { // Arrange - var emptyState = new { }; var fakeAgent = new FakeStateAgent(); - await this.SetupTestServerAsync(fakeAgent); - var chatClient = new AGUIChatClient(this._client!, "", null); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); - ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); - - string stateJson = JsonSerializer.Serialize(emptyState); - byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson); - DataContent stateContent = new(stateBytes, "application/json"); - ChatMessage stateMessage = new(ChatRole.System, [stateContent]); + AIAgent agent = this.CreateAgent(); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + + var emptyState = JsonSerializer.SerializeToElement(new { }); ChatMessage userMessage = new(ChatRole.User, "hello"); List updates = []; // Act - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], session, new AgentRunOptions(), CancellationToken.None)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, StateRunOptions(emptyState), CancellationToken.None)) { updates.Add(update); } - // Assert + // Assert - empty state {} should be treated as no state. updates.Should().NotBeEmpty(); - - // Empty state {} should not trigger state snapshot mechanism - bool hasEmptyStateSnapshot = updates.Any(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")); - hasEmptyStateSnapshot.Should().BeFalse("empty state should be treated as no state"); - - // Should have normal response + FindStateSnapshot(updates).Should().BeNull("empty state should be treated as no state"); updates.Should().Contain(u => u.Contents.Any(c => c is TextContent)); } [Fact] - public async Task NonStreamingRunAsync_WithState_ReturnsStateInResponseAsync() + public async Task NonStreamingRunAsync_WithState_ReturnsTextResponseAsync() { // Arrange - var initialState = new { counter = 5 }; var fakeAgent = new FakeStateAgent(); - await this.SetupTestServerAsync(fakeAgent); - var chatClient = new AGUIChatClient(this._client!, "", null); - AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); - ChatClientAgentSession? session = (ChatClientAgentSession)await agent.CreateSessionAsync(); - - string stateJson = JsonSerializer.Serialize(initialState); - byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson); - DataContent stateContent = new(stateBytes, "application/json"); - ChatMessage stateMessage = new(ChatRole.System, [stateContent]); + AIAgent agent = this.CreateAgent(); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + + var initialState = JsonSerializer.SerializeToElement(new { counter = 5 }); ChatMessage userMessage = new(ChatRole.User, "process"); - // Act - AgentResponse response = await agent.RunAsync([userMessage, stateMessage], session, new AgentRunOptions(), CancellationToken.None); + // Act - non-streaming run. + AgentResponse response = await agent.RunAsync([userMessage], session, StateRunOptions(initialState), CancellationToken.None); - // Assert + // Assert - AG-UI state events are a streaming concern: the non-streaming aggregation drops the + // content-less STATE_SNAPSHOT update (Microsoft.Extensions.AI only materializes updates that carry + // content), so the non-streaming path surfaces the aggregated text response. The state round-trip + // itself is verified by the streaming tests above. response.Should().NotBeNull(); response.Messages.Should().NotBeEmpty(); + response.Text.Should().Contain("State processed"); + } - // Should have message with DataContent containing state - bool hasStateMessage = response.Messages.Any(m => m.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")); - hasStateMessage.Should().BeTrue("response should contain state message"); + private ChatClientAgent CreateAgent() + { + var chatClient = new AGUIChatClient(new(this._client!, "")); + return chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); + } - ChatMessage? stateResponseMessage = response.Messages.FirstOrDefault(m => m.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json")); - stateResponseMessage.Should().NotBeNull(); + private static ChatClientAgentRunOptions StateRunOptions(JsonElement state) => + new() + { + ChatOptions = new ChatOptions + { + RawRepresentationFactory = _ => new RunAgentInput { State = state }, + }, + }; - DataContent? dataContent = stateResponseMessage!.Contents.OfType().FirstOrDefault(dc => dc.MediaType == "application/json"); - string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray()); - JsonElement receivedState = JsonElement.Parse(receivedJson); - receivedState.GetProperty("counter").GetInt32().Should().Be(6); - } + private static StateSnapshotEvent? FindStateSnapshot(IEnumerable updates) => + updates + .Select(u => u.AsChatResponseUpdate().RawRepresentation as StateSnapshotEvent) + .FirstOrDefault(e => e is not null); private async Task SetupTestServerAsync(FakeStateAgent fakeAgent) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddAGUI(); + builder.Services.AddAGUIServer(); builder.WebHost.UseTestServer(); this._app = builder.Build(); - this._app.MapAGUI("/agent", fakeAgent); + this._app.MapAGUIServer("/agent", fakeAgent); await this._app.StartAsync(); @@ -354,59 +302,49 @@ protected override async IAsyncEnumerable RunCoreStreamingA AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // Check for state in ChatOptions.AdditionalProperties (set by AG-UI hosting layer) - if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } && - properties.TryGetValue("ag_ui_state", out object? stateObj) && - stateObj is JsonElement state && - state.ValueKind == JsonValueKind.Object) + // Recover the originating AG-UI input from the request options (set by the hosting layer). + if (options is ChatClientAgentRunOptions { ChatOptions: { } chatOptions } && + chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) && + agentInput.State is { ValueKind: JsonValueKind.Object } state && + HasProperties(state)) { - // Check if state object has properties (not empty {}) - bool hasProperties = false; - foreach (JsonProperty _ in state.EnumerateObject()) - { - hasProperties = true; - break; - } - - if (hasProperties) + Dictionary modifiedState = []; + foreach (JsonProperty prop in state.EnumerateObject()) { - // State is present and non-empty - modify it and return as DataContent - Dictionary modifiedState = []; - foreach (JsonProperty prop in state.EnumerateObject()) + if (prop.Name == "counter" && prop.Value.ValueKind == JsonValueKind.Number) { - if (prop.Name == "counter" && prop.Value.ValueKind == JsonValueKind.Number) - { - modifiedState[prop.Name] = prop.Value.GetInt32() + 1; - } - else if (prop.Value.ValueKind == JsonValueKind.Number) - { - modifiedState[prop.Name] = prop.Value.GetInt32(); - } - else if (prop.Value.ValueKind == JsonValueKind.String) - { - modifiedState[prop.Name] = prop.Value.GetString(); - } - else if (prop.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) - { - modifiedState[prop.Name] = prop.Value; - } + modifiedState[prop.Name] = prop.Value.GetInt32() + 1; } + else if (prop.Value.ValueKind == JsonValueKind.Number) + { + modifiedState[prop.Name] = prop.Value.GetInt32(); + } + else if (prop.Value.ValueKind == JsonValueKind.String) + { + modifiedState[prop.Name] = prop.Value.GetString(); + } + else if (prop.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + { + modifiedState[prop.Name] = prop.Value; + } + } - // Return modified state as DataContent - string modifiedStateJson = JsonSerializer.Serialize(modifiedState); - byte[] modifiedStateBytes = System.Text.Encoding.UTF8.GetBytes(modifiedStateJson); - DataContent modifiedStateContent = new(modifiedStateBytes, "application/json"); - - yield return new AgentResponseUpdate + // Emit the modified state as an AG-UI STATE_SNAPSHOT event. An AIAgent surfaces AG-UI raw + // events by wrapping a ChatResponseUpdate (whose RawRepresentation is the event) so the + // AgentResponseUpdate -> ChatResponseUpdate bridge forwards it to the server's event stream. + JsonElement snapshot = JsonSerializer.SerializeToElement(modifiedState); + yield return new AgentResponseUpdate + { + Role = ChatRole.Assistant, + RawRepresentation = new ChatResponseUpdate { - MessageId = Guid.NewGuid().ToString("N"), Role = ChatRole.Assistant, - Contents = [modifiedStateContent] - }; - } + RawRepresentation = new StateSnapshotEvent { Snapshot = snapshot } + } + }; } - // Always return a text response + // Always return a text response. string messageId = Guid.NewGuid().ToString("N"); yield return new AgentResponseUpdate { @@ -418,6 +356,16 @@ stateObj is JsonElement state && await Task.CompletedTask; } + private static bool HasProperties(JsonElement element) + { + foreach (JsonProperty _ in element.EnumerateObject()) + { + return true; + } + + return false; + } + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => new(new FakeAgentSession()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs index 3da741851d2..2f345c9489f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/ToolCallingTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -9,8 +9,8 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using AGUI.Client; using FluentAssertions; -using Microsoft.Agents.AI.AGUI; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.TestHost; @@ -42,7 +42,7 @@ public async Task ServerTriggersSingleFunctionCallAsync() }, "ServerFunction", "A function on the server"); await this.SetupTestServerAsync(serverTools: [serverTool]); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: []); AgentSession session = await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "Call the server function"); @@ -90,7 +90,7 @@ public async Task ServerTriggersMultipleFunctionCallsAsync() }, "GetTime", "Gets the current time"); await this.SetupTestServerAsync(serverTools: [getWeatherTool, getTimeTool]); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: []); AgentSession session = await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "What's the weather and time?"); @@ -131,7 +131,7 @@ public async Task ClientTriggersSingleFunctionCallAsync() }, "ClientFunction", "A function on the client"); await this.SetupTestServerAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: [clientTool]); AgentSession session = await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "Call the client function"); @@ -179,7 +179,7 @@ public async Task ClientTriggersMultipleFunctionCallsAsync() }, "FormatText", "Formats text to uppercase"); await this.SetupTestServerAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: [calculateTool, formatTool]); AgentSession session = await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "Calculate 5 + 3 and format 'hello'"); @@ -230,7 +230,7 @@ public async Task ServerAndClientTriggerFunctionCallsSimultaneouslyAsync() }, "GetClientData", "Gets data from the client"); await this.SetupTestServerAsync(serverTools: [serverTool]); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: [clientTool]); AgentSession session = await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "Get both server and client data"); @@ -259,16 +259,13 @@ public async Task ServerAndClientTriggerFunctionCallsSimultaneouslyAsync() // Assert this._output.WriteLine($"serverCallCount={serverCallCount}, clientCallCount={clientCallCount}"); - // NOTE: Current limitation - server tool execution doesn't work properly in this scenario - // The FakeChatClient generates calls for both tools, but the server's FunctionInvokingChatClient - // doesn't execute the server tool. Only the client tool gets executed by the client-side - // FunctionInvokingChatClient. This appears to be a product code issue that needs investigation. + // Verify both the server and client tools executed and both results round-tripped through + // the streaming pipeline. This is now correct behavior thanks to + // ConfigureForMixedInvocation in the AGUI.Hosting.AspNetCore package. - // For now, we verify that: - // 1. Client tool executes successfully on the client + serverCallCount.Should().Be(1, "server function should execute on server"); clientCallCount.Should().Be(1, "client function should execute on client"); - // 2. Both function calls are generated and sent var functionCallUpdates = updates.Where(u => u.Contents.Any(c => c is FunctionCallContent)).ToList(); functionCallUpdates.Should().NotBeEmpty("should contain function calls"); @@ -277,15 +274,18 @@ public async Task ServerAndClientTriggerFunctionCallsSimultaneouslyAsync() functionCalls.Should().Contain(fc => fc.Name == "GetServerData"); functionCalls.Should().Contain(fc => fc.Name == "GetClientData"); - // 3. Only client function result is present (server execution not working) var functionResults = updates.SelectMany(u => u.Contents.OfType()).ToList(); - functionResults.Should().HaveCount(1, "only client function result is present due to current limitation"); + functionResults.Should().HaveCount(2, "both server and client function results should be present"); + + var serverResult = functionResults.FirstOrDefault(fr => + functionCalls.Any(fc => fc.Name == "GetServerData" && fc.CallId == fr.CallId)); + serverResult.Should().NotBeNull("server function call should have a result"); + serverResult!.Result?.ToString().Should().Contain("Server data"); - // Client function should succeed var clientResult = functionResults.FirstOrDefault(fr => functionCalls.Any(fc => fc.Name == "GetClientData" && fc.CallId == fr.CallId)); clientResult.Should().NotBeNull("client function call should have a result"); - clientResult!.Result?.ToString().Should().Be("Client data", "client function should execute successfully"); + clientResult!.Result?.ToString().Should().Contain("Client data"); } [Fact] @@ -295,7 +295,7 @@ public async Task FunctionCallsPreserveCallIdAndNameAsync() AIFunction testTool = AIFunctionFactory.Create(() => "Test result", "TestFunction", "A test function"); await this.SetupTestServerAsync(serverTools: [testTool]); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: []); AgentSession session = await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "Call the test function"); @@ -339,7 +339,7 @@ public async Task ParallelFunctionCallsFromServerAreHandledCorrectlyAsync() }, "Function2", "Second function"); await this.SetupTestServerAsync(serverTools: [func1, func2], triggerParallelCalls: true); - var chatClient = new AGUIChatClient(this._client!, "", null); + var chatClient = new AGUIChatClient(new(this._client!, "")); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: []); AgentSession session = await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "Call both functions in parallel"); @@ -385,7 +385,7 @@ public async Task AGUIChatClientCombinesCustomJsonSerializerOptionsAsync() var clientJsonOptions = new JsonSerializerOptions(); clientJsonOptions.TypeInfoResolverChain.Add(ClientJsonContext.Default); - _ = new AGUIChatClient(this._client!, "", null, clientJsonOptions); + _ = new AGUIChatClient(new(this._client!, "") { JsonSerializerOptions = clientJsonOptions }); // Act - Verify that both AG-UI types and custom types can be serialized // The AGUIChatClient should have combined AGUIJsonSerializerContext with ClientJsonContext @@ -425,7 +425,7 @@ public async Task ServerToolCallWithCustomArgumentsAsync() ServerJsonContext.Default.Options); await this.SetupTestServerAsync(serverTools: [serverTool], jsonSerializerOptions: ServerJsonContext.Default.Options); - var chatClient = new AGUIChatClient(this._client!, "", null, ServerJsonContext.Default.Options); + var chatClient = new AGUIChatClient(new(this._client!, "") { JsonSerializerOptions = ServerJsonContext.Default.Options }); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: []); AgentSession session = await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "Get server forecast for Seattle for 5 days"); @@ -471,7 +471,7 @@ public async Task ClientToolCallWithCustomArgumentsAsync() ClientJsonContext.Default.Options); await this.SetupTestServerAsync(); - var chatClient = new AGUIChatClient(this._client!, "", null, ClientJsonContext.Default.Options); + var chatClient = new AGUIChatClient(new(this._client!, "") { JsonSerializerOptions = ClientJsonContext.Default.Options }); AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Test assistant", tools: [clientTool]); AgentSession session = await agent.CreateSessionAsync(); ChatMessage userMessage = new(ChatRole.User, "Get client forecast for Portland with hourly data"); @@ -504,7 +504,7 @@ private async Task SetupTestServerAsync( JsonSerializerOptions? jsonSerializerOptions = null) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddAGUI(); + builder.Services.AddAGUIServer(); builder.WebHost.UseTestServer(); // Configure HTTP JSON options if custom serializer options provided @@ -518,7 +518,7 @@ private async Task SetupTestServerAsync( // FakeChatClient will receive options.Tools containing both server and client tools (merged by framework) var fakeChatClient = new FakeToolCallingChatClient(triggerParallelCalls, this._output, jsonSerializerOptions: jsonSerializerOptions); AIAgent baseAgent = fakeChatClient.AsAIAgent(instructions: null, name: "base-agent", description: "A base agent for tool testing", tools: serverTools ?? []); - this._app.MapAGUI("/agent", baseAgent); + this._app.MapAGUIServer("/agent", baseAgent); await this._app.StartAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index 248629b3924..644adccb0ea 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -1,22 +1,14 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Moq; namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; @@ -27,7 +19,7 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; public sealed class AGUIEndpointRouteBuilderExtensionsTests { [Fact] - public void MapAGUIAgent_MapsEndpoint_AtSpecifiedPattern() + public void MapAGUIServer_MapsEndpoint_AtSpecifiedPattern() { // Arrange Mock endpointsMock = new(); @@ -41,14 +33,14 @@ public void MapAGUIAgent_MapsEndpoint_AtSpecifiedPattern() AIAgent agent = new TestAgent(); // Act - IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(Pattern, agent); + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer(Pattern, agent); // Assert Assert.NotNull(result); } [Fact] - public void MapAGUI_WithAgentName_ResolvesKeyedAgentFromDI() + public void MapAGUIServer_WithAgentName_ResolvesKeyedAgentFromDI() { // Arrange Mock endpointsMock = new(); @@ -63,7 +55,7 @@ public void MapAGUI_WithAgentName_ResolvesKeyedAgentFromDI() endpointsMock.Setup(e => e.DataSources).Returns([]); // Act - IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("test-agent", "/api/agent"); + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer("test-agent", "/api/agent"); // Assert Assert.NotNull(result); @@ -72,7 +64,7 @@ public void MapAGUI_WithAgentName_ResolvesKeyedAgentFromDI() } [Fact] - public void MapAGUI_WithHostedAgentBuilder_ResolvesAgentByBuilderName() + public void MapAGUIServer_WithHostedAgentBuilder_ResolvesAgentByBuilderName() { // Arrange Mock endpointsMock = new(); @@ -90,7 +82,7 @@ public void MapAGUI_WithHostedAgentBuilder_ResolvesAgentByBuilderName() endpointsMock.Setup(e => e.DataSources).Returns([]); // Act - IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(agentBuilderMock.Object, "/api/agent"); + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer(agentBuilderMock.Object, "/api/agent"); // Assert Assert.NotNull(result); @@ -99,7 +91,7 @@ public void MapAGUI_WithHostedAgentBuilder_ResolvesAgentByBuilderName() } [Fact] - public void MapAGUI_WithAgent_ResolvesSessionStoreFromDI() + public void MapAGUIServer_WithAgent_ResolvesSessionStoreFromDI() { // Arrange Mock endpointsMock = new(); @@ -115,7 +107,7 @@ public void MapAGUI_WithAgent_ResolvesSessionStoreFromDI() endpointsMock.Setup(e => e.DataSources).Returns([]); // Act - IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent); + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer("/api/agent", agent); // Assert Assert.NotNull(result); @@ -124,7 +116,7 @@ public void MapAGUI_WithAgent_ResolvesSessionStoreFromDI() } [Fact] - public void MapAGUI_WithoutSessionStore_FallsBackToNoopStore() + public void MapAGUIServer_WithoutSessionStore_FallsBackToNoopStore() { // Arrange Mock endpointsMock = new(); @@ -138,25 +130,25 @@ public void MapAGUI_WithoutSessionStore_FallsBackToNoopStore() endpointsMock.Setup(e => e.DataSources).Returns([]); // Act - should not throw (falls back to NoopAgentSessionStore) - IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent); + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer("/api/agent", agent); // Assert Assert.NotNull(result); } [Fact] - public void MapAGUI_WithNullEndpoints_ThrowsArgumentNullException() + public void MapAGUIServer_WithNullEndpoints_ThrowsArgumentNullException() { // Arrange AIAgent agent = new TestAgent(); // Act & Assert Assert.Throws(() => - AGUIEndpointRouteBuilderExtensions.MapAGUI(null!, "/api/agent", agent)); + AGUIEndpointRouteBuilderExtensions.MapAGUIServer(null!, "/api/agent", agent)); } [Fact] - public void MapAGUI_WithNullAgent_ThrowsArgumentNullException() + public void MapAGUIServer_WithNullAgent_ThrowsArgumentNullException() { // Arrange Mock endpointsMock = new(); @@ -166,11 +158,11 @@ public void MapAGUI_WithNullAgent_ThrowsArgumentNullException() // Act & Assert Assert.Throws(() => - endpointsMock.Object.MapAGUI("/api/agent", (AIAgent)null!)); + endpointsMock.Object.MapAGUIServer("/api/agent", (AIAgent)null!)); } [Fact] - public void MapAGUI_WithNullAgentName_ThrowsArgumentNullException() + public void MapAGUIServer_WithNullAgentName_ThrowsArgumentNullException() { // Arrange Mock endpointsMock = new(); @@ -180,11 +172,11 @@ public void MapAGUI_WithNullAgentName_ThrowsArgumentNullException() // Act & Assert Assert.Throws(() => - endpointsMock.Object.MapAGUI((string)null!, "/api/agent")); + endpointsMock.Object.MapAGUIServer((string)null!, "/api/agent")); } [Fact] - public void MapAGUI_WithNullAgentBuilder_ThrowsArgumentNullException() + public void MapAGUIServer_WithNullAgentBuilder_ThrowsArgumentNullException() { // Arrange Mock endpointsMock = new(); @@ -193,558 +185,36 @@ public void MapAGUI_WithNullAgentBuilder_ThrowsArgumentNullException() // Act & Assert Assert.Throws(() => - endpointsMock.Object.MapAGUI((IHostedAgentBuilder)null!, "/api/agent")); + endpointsMock.Object.MapAGUIServer((IHostedAgentBuilder)null!, "/api/agent")); } - [Fact] - public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync() - { - // Arrange - DefaultHttpContext context = new(); - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("invalid json")); - context.RequestAborted = CancellationToken.None; - - RequestDelegate handler = this.CreateRequestDelegate((messages, tools, ctx, props) => new TestAgent()); - - // Act - await handler(context); - - // Assert - Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); - } - - [Fact] - public async Task MapAGUIAgent_InvokesAgentFactory_WithCorrectMessagesAndContextAsync() - { - // Arrange - List? capturedMessages = null; - IEnumerable>? capturedContext = null; - - AIAgent factory(IEnumerable messages, IEnumerable tools, IEnumerable> context, JsonElement props) - { - capturedMessages = messages.ToList(); - capturedContext = context; - return new TestAgent(); - } - - DefaultHttpContext httpContext = new(); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }], - Context = [new AGUIContextItem { Description = "key1", Value = "value1" }] - }; - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); - httpContext.Response.Body = new MemoryStream(); - - RequestDelegate handler = this.CreateRequestDelegate(factory); - - // Act - await handler(httpContext); - - // Assert - Assert.NotNull(capturedMessages); - Assert.Single(capturedMessages); - Assert.Equal("Test", capturedMessages[0].Text); - Assert.NotNull(capturedContext); - Assert.Contains(capturedContext, kvp => kvp.Key == "key1" && kvp.Value == "value1"); - } - - [Fact] - public async Task MapAGUIAgent_ReturnsSSEResponseStream_WithCorrectContentTypeAsync() - { - // Arrange - DefaultHttpContext httpContext = new(); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); - httpContext.Response.Body = new MemoryStream(); - - RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent()); - - // Act - await handler(httpContext); - - // Assert - Assert.Equal("text/event-stream", httpContext.Response.ContentType); - } - - [Fact] - public async Task MapAGUIAgent_PassesCancellationToken_ToAgentExecutionAsync() - { - // Arrange - using CancellationTokenSource cts = new(); - cts.Cancel(); - - DefaultHttpContext httpContext = new(); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); - httpContext.Response.Body = new MemoryStream(); - httpContext.RequestAborted = cts.Token; - - RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent()); - - // Act & Assert - await Assert.ThrowsAnyAsync(() => handler(httpContext)); - } - - [Fact] - public async Task MapAGUIAgent_ConvertsInputMessages_ToChatMessagesBeforeFactoryAsync() - { - // Arrange - List? capturedMessages = null; - - AIAgent factory(IEnumerable messages, IEnumerable tools, IEnumerable> context, JsonElement props) - { - capturedMessages = messages.ToList(); - return new TestAgent(); - } - - DefaultHttpContext httpContext = new(); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = - [ - new AGUIUserMessage { Id = "m1", Content = "First" }, - new AGUIAssistantMessage { Id = "m2", Content = "Second" } - ] - }; - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); - httpContext.Response.Body = new MemoryStream(); - - RequestDelegate handler = this.CreateRequestDelegate(factory); - - // Act - await handler(httpContext); - - // Assert - Assert.NotNull(capturedMessages); - Assert.Equal(2, capturedMessages.Count); - Assert.Equal(ChatRole.User, capturedMessages[0].Role); - Assert.Equal("First", capturedMessages[0].Text); - Assert.Equal(ChatRole.Assistant, capturedMessages[1].Role); - Assert.Equal("Second", capturedMessages[1].Text); - } - - [Fact] - public async Task MapAGUIAgent_ProducesValidAGUIEventStream_WithRunStartAndFinishAsync() - { - // Arrange - DefaultHttpContext httpContext = new(); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); - MemoryStream responseStream = new(); - httpContext.Response.Body = responseStream; - - RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent()); - - // Act - await handler(httpContext); - - // Assert - responseStream.Position = 0; - string responseContent = Encoding.UTF8.GetString(responseStream.ToArray()); - - List events = ParseSseEvents(responseContent); - - JsonElement runStarted = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunStarted); - JsonElement runFinished = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunFinished); - - Assert.Equal("thread1", runStarted.GetProperty("threadId").GetString()); - Assert.Equal("run1", runStarted.GetProperty("runId").GetString()); - Assert.Equal("thread1", runFinished.GetProperty("threadId").GetString()); - Assert.Equal("run1", runFinished.GetProperty("runId").GetString()); - } - - [Fact] - public async Task MapAGUIAgent_ProducesTextMessageEvents_InCorrectOrderAsync() - { - // Arrange - DefaultHttpContext httpContext = new(); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Hello" }] - }; - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); - MemoryStream responseStream = new(); - httpContext.Response.Body = responseStream; - - RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent()); - - // Act - await handler(httpContext); - - // Assert - responseStream.Position = 0; - string responseContent = Encoding.UTF8.GetString(responseStream.ToArray()); - - List events = ParseSseEvents(responseContent); - List eventTypes = new(events.Count); - foreach (JsonElement evt in events) - { - eventTypes.Add(evt.GetProperty("type").GetString()); - } - - Assert.Contains(AGUIEventTypes.RunStarted, eventTypes); - Assert.Contains(AGUIEventTypes.TextMessageContent, eventTypes); - Assert.Contains(AGUIEventTypes.RunFinished, eventTypes); - - int runStartIndex = eventTypes.IndexOf(AGUIEventTypes.RunStarted); - int firstContentIndex = eventTypes.IndexOf(AGUIEventTypes.TextMessageContent); - int runFinishIndex = eventTypes.LastIndexOf(AGUIEventTypes.RunFinished); - - Assert.True(runStartIndex < firstContentIndex, "Run start should precede text content."); - Assert.True(firstContentIndex < runFinishIndex, "Text content should precede run finish."); - } - - [Fact] - public async Task MapAGUIAgent_EmitsTextMessageContent_WithCorrectDeltaAsync() - { - // Arrange - DefaultHttpContext httpContext = new(); - RunAgentInput input = new() - { - ThreadId = "thread1", - RunId = "run1", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); - MemoryStream responseStream = new(); - httpContext.Response.Body = responseStream; - - RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent()); - - // Act - await handler(httpContext); - - // Assert - responseStream.Position = 0; - string responseContent = Encoding.UTF8.GetString(responseStream.ToArray()); - - List events = ParseSseEvents(responseContent); - JsonElement textContentEvent = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.TextMessageContent); - - Assert.Equal("Test response", textContentEvent.GetProperty("delta").GetString()); - } - - [Fact] - public async Task MapAGUIAgent_WithCustomAgent_ProducesExpectedStreamStructureAsync() - { - // Arrange - static AIAgent CustomAgentFactory(IEnumerable messages, IEnumerable tools, IEnumerable> context, JsonElement props) - { - return new MultiResponseAgent(); - } - - DefaultHttpContext httpContext = new(); - RunAgentInput input = new() - { - ThreadId = "custom_thread", - RunId = "custom_run", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Multi" }] - }; - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); - MemoryStream responseStream = new(); - httpContext.Response.Body = responseStream; - - RequestDelegate handler = this.CreateRequestDelegate(CustomAgentFactory); - - // Act - await handler(httpContext); - - // Assert - responseStream.Position = 0; - string responseContent = Encoding.UTF8.GetString(responseStream.ToArray()); - - List events = ParseSseEvents(responseContent); - List contentEvents = []; - foreach (JsonElement evt in events) - { - if (evt.GetProperty("type").GetString() == AGUIEventTypes.TextMessageContent) - { - contentEvents.Add(evt); - } - } - - Assert.True(contentEvents.Count >= 3, $"Expected at least 3 text_message.content events, got {contentEvents.Count}"); - - List deltas = new(contentEvents.Count); - foreach (JsonElement contentEvent in contentEvents) - { - deltas.Add(contentEvent.GetProperty("delta").GetString()); - } - - Assert.Contains("First", deltas); - Assert.Contains(" part", deltas); - Assert.Contains(" of response", deltas); - } - - [Fact] - public async Task MapAGUIAgent_ProducesCorrectSessionAndRunIds_InAllEventsAsync() + private sealed class TestAgent : AIAgent { - // Arrange - DefaultHttpContext httpContext = new(); - RunAgentInput input = new() - { - ThreadId = "test_thread_123", - RunId = "test_run_456", - Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }] - }; - string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput); - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); - MemoryStream responseStream = new(); - httpContext.Response.Body = responseStream; - - RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent()); - - // Act - await handler(httpContext); + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - // Assert - responseStream.Position = 0; - string responseContent = Encoding.UTF8.GetString(responseStream.ToArray()); + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - List events = ParseSseEvents(responseContent); - JsonElement runStarted = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunStarted); + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); - Assert.Equal("test_thread_123", runStarted.GetProperty("threadId").GetString()); - Assert.Equal("test_run_456", runStarted.GetProperty("runId").GetString()); - } + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - private static List ParseSseEvents(string responseContent) - { - List events = []; - using StringReader reader = new(responseContent); - StringBuilder dataBuilder = new(); - string? line; - - while ((line = reader.ReadLine()) != null) - { - if (line.StartsWith("data:", StringComparison.Ordinal)) - { - string payload = line.Length > 5 && line[5] == ' ' - ? line.Substring(6) - : line.Substring(5); - dataBuilder.Append(payload); - } - else if (line.Length == 0 && dataBuilder.Length > 0) - { - using JsonDocument document = JsonDocument.Parse(dataBuilder.ToString()); - events.Add(document.RootElement.Clone()); - dataBuilder.Clear(); - } - } - - if (dataBuilder.Length > 0) - { - using JsonDocument document = JsonDocument.Parse(dataBuilder.ToString()); - events.Add(document.RootElement.Clone()); - } - - return events; + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } - private sealed class MultiResponseAgent : AIAgent + private sealed class NamedTestAgent : AIAgent { - protected override string? IdCore => "multi-response-agent"; - - public override string? Description => "Agent that produces multiple text chunks"; - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => - new(new TestAgentSession()); - - protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => - new(serializedState.Deserialize(jsonSerializerOptions)!); - - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - if (session is not TestAgentSession testSession) - { - throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(TestAgentSession)}' can be serialized by this agent."); - } - - return new(JsonSerializer.SerializeToElement(testSession, jsonSerializerOptions)); - } - - protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "First")); - yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " part")); - yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " of response")); - } - } + protected override string? IdCore => "named-test-agent"; - private RequestDelegate CreateRequestDelegate( - Func, IEnumerable, IEnumerable>, JsonElement, AIAgent> factory) - { - return async context => - { - CancellationToken cancellationToken = context.RequestAborted; - - RunAgentInput? input; - try - { - input = await JsonSerializer.DeserializeAsync( - context.Request.Body, - AGUIJsonSerializerContext.Default.RunAgentInput, - cancellationToken).ConfigureAwait(false); - } - catch (JsonException) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - if (input is null) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - IEnumerable messages = input.Messages.AsChatMessages(AGUIJsonSerializerContext.Default.Options); - IEnumerable> contextValues = input.Context.Select(c => new KeyValuePair(c.Description, c.Value)); - JsonElement forwardedProps = input.ForwardedProperties; - AIAgent agent = factory(messages, [], contextValues, forwardedProps); - - IAsyncEnumerable events = agent.RunStreamingAsync( - messages, - cancellationToken: cancellationToken) - .AsChatResponseUpdatesAsync() - .AsAGUIEventStreamAsync( - input.ThreadId, - input.RunId, - AGUIJsonSerializerContext.Default.Options, - cancellationToken); - - ILogger logger = NullLogger.Instance; - await new AGUIServerSentEventsResult(events, logger).ExecuteAsync(context).ConfigureAwait(false); - }; - } + public override string? Name => "test-agent"; - private sealed class TestAgentSession : AgentSession - { - public TestAgentSession() - { - } - - [JsonConstructor] - public TestAgentSession(AgentSessionStateBag stateBag) : base(stateBag) - { - } - } + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - private sealed class TestAgent : AIAgent - { - protected override string? IdCore => "test-agent"; - - public override string? Description => "Test agent"; - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => - new(new TestAgentSession()); - - protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => - new(serializedState.Deserialize(jsonSerializerOptions)!); - - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - if (session is not TestAgentSession testSession) - { - throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(TestAgentSession)}' can be serialized by this agent."); - } - - return new(JsonSerializer.SerializeToElement(testSession, jsonSerializerOptions)); - } - - protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response")); - } - } + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - private sealed class NamedTestAgent : AIAgent - { - protected override string? IdCore => "test-agent"; + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public override string? Name => "test-agent"; + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public override string? Description => "Named test agent"; - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => - new(new TestAgentSession()); - - protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => - new(serializedState.Deserialize(jsonSerializerOptions)!); - - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - if (session is not TestAgentSession testSession) - { - throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(TestAgentSession)}' can be serialized by this agent."); - } - - return new(JsonSerializer.SerializeToElement(testSession, jsonSerializerOptions)); - } - - protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask; - yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response")); - } + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIServerSentEventsResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIServerSentEventsResultTests.cs index f0492184735..2d1d840cb1d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIServerSentEventsResultTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIServerSentEventsResultTests.cs @@ -1,12 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. +#if !NET10_0_OR_GREATER + using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; +using AGUI.Abstractions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -147,3 +149,5 @@ public async Task ExecuteAsync_WithNullHttpContext_ThrowsArgumentNullExceptionAs await Assert.ThrowsAsync(() => result.ExecuteAsync(null!)); } } + +#endif diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs deleted file mode 100644 index bf2aa6fb0b8..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/ChatResponseUpdateAGUIExtensionsTests.cs +++ /dev/null @@ -1,286 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; - -public sealed class ChatResponseUpdateAGUIExtensionsTests -{ - [Fact] - public async Task AsAGUIEventStreamAsync_YieldsRunStartedEvent_AtBeginningWithCorrectIdsAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - List updates = []; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - Assert.NotEmpty(events); - RunStartedEvent startEvent = Assert.IsType(events.First()); - Assert.Equal(ThreadId, startEvent.ThreadId); - Assert.Equal(RunId, startEvent.RunId); - Assert.Equal(AGUIEventTypes.RunStarted, startEvent.Type); - } - - [Fact] - public async Task AsAGUIEventStreamAsync_YieldsRunFinishedEvent_AtEndWithCorrectIdsAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - List updates = []; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - Assert.NotEmpty(events); - RunFinishedEvent finishEvent = Assert.IsType(events.Last()); - Assert.Equal(ThreadId, finishEvent.ThreadId); - Assert.Equal(RunId, finishEvent.RunId); - Assert.Equal(AGUIEventTypes.RunFinished, finishEvent.Type); - } - - [Fact] - public async Task AsAGUIEventStreamAsync_ConvertsTextContentUpdates_ToTextMessageEventsAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - List updates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg1" }, - new ChatResponseUpdate(ChatRole.Assistant, " World") { MessageId = "msg1" } - ]; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - Assert.Contains(events, e => e is TextMessageStartEvent); - Assert.Contains(events, e => e is TextMessageContentEvent); - Assert.Contains(events, e => e is TextMessageEndEvent); - } - - [Fact] - public async Task AsAGUIEventStreamAsync_GroupsConsecutiveUpdates_WithSameMessageIdAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - const string MessageId = "msg1"; - List updates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = MessageId }, - new ChatResponseUpdate(ChatRole.Assistant, " ") { MessageId = MessageId }, - new ChatResponseUpdate(ChatRole.Assistant, "World") { MessageId = MessageId } - ]; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - List startEvents = events.OfType().ToList(); - List endEvents = events.OfType().ToList(); - Assert.Single(startEvents); - Assert.Single(endEvents); - Assert.Equal(MessageId, startEvents[0].MessageId); - Assert.Equal(MessageId, endEvents[0].MessageId); - } - - [Fact] - public async Task AsAGUIEventStreamAsync_WithRoleChanges_EmitsProperTextMessageStartEventsAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - List updates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg1" }, - new ChatResponseUpdate(ChatRole.User, "Hi") { MessageId = "msg2" } - ]; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - List startEvents = events.OfType().ToList(); - Assert.Equal(2, startEvents.Count); - Assert.Equal("msg1", startEvents[0].MessageId); - Assert.Equal("msg2", startEvents[1].MessageId); - } - - [Fact] - public async Task AsAGUIEventStreamAsync_EmitsTextMessageEndEvent_WhenMessageIdChangesAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - List updates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "First") { MessageId = "msg1" }, - new ChatResponseUpdate(ChatRole.Assistant, "Second") { MessageId = "msg2" } - ]; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - List endEvents = events.OfType().ToList(); - Assert.NotEmpty(endEvents); - Assert.Contains(endEvents, e => e.MessageId == "msg1"); - } - - [Fact] - public async Task AsAGUIEventStreamAsync_WithFunctionCallContent_EmitsToolCallEventsAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - Dictionary arguments = new() { ["location"] = "Seattle", ["units"] = "fahrenheit" }; - FunctionCallContent functionCall = new("call_123", "GetWeather", arguments); - List updates = - [ - new ChatResponseUpdate(ChatRole.Assistant, [functionCall]) { MessageId = "msg1" } - ]; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - ToolCallStartEvent? startEvent = events.OfType().FirstOrDefault(); - Assert.NotNull(startEvent); - Assert.Equal("call_123", startEvent.ToolCallId); - Assert.Equal("GetWeather", startEvent.ToolCallName); - Assert.Equal("msg1", startEvent.ParentMessageId); - - ToolCallArgsEvent? argsEvent = events.OfType().FirstOrDefault(); - Assert.NotNull(argsEvent); - Assert.Equal("call_123", argsEvent.ToolCallId); - Assert.Contains("location", argsEvent.Delta); - Assert.Contains("Seattle", argsEvent.Delta); - - ToolCallEndEvent? endEvent = events.OfType().FirstOrDefault(); - Assert.NotNull(endEvent); - Assert.Equal("call_123", endEvent.ToolCallId); - } - - [Fact] - public async Task AsAGUIEventStreamAsync_WithMultipleFunctionCalls_EmitsAllToolCallEventsAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - FunctionCallContent call1 = new("call_1", "Tool1", new Dictionary()); - FunctionCallContent call2 = new("call_2", "Tool2", new Dictionary()); - ChatResponseUpdate response = new(ChatRole.Assistant, [call1, call2]) { MessageId = "msg1" }; - List updates = [response]; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - List startEvents = events.OfType().ToList(); - Assert.Equal(2, startEvents.Count); - Assert.Contains(startEvents, e => e.ToolCallId == "call_1" && e.ToolCallName == "Tool1"); - Assert.Contains(startEvents, e => e.ToolCallId == "call_2" && e.ToolCallName == "Tool2"); - - List endEvents = events.OfType().ToList(); - Assert.Equal(2, endEvents.Count); - } - - [Fact] - public async Task AsAGUIEventStreamAsync_WithFunctionCallWithNullArguments_EmitsEventsCorrectlyAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - FunctionCallContent functionCall = new("call_456", "NoArgsTool", null); - List updates = - [ - new ChatResponseUpdate(ChatRole.Assistant, [functionCall]) { MessageId = "msg1" } - ]; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - Assert.Contains(events, e => e is ToolCallStartEvent); - Assert.Contains(events, e => e is ToolCallArgsEvent); - Assert.Contains(events, e => e is ToolCallEndEvent); - } - - [Fact] - public async Task AsAGUIEventStreamAsync_WithMixedContentTypes_EmitsAllEventTypesAsync() - { - // Arrange - const string ThreadId = "thread1"; - const string RunId = "run1"; - List updates = - [ - new ChatResponseUpdate(ChatRole.Assistant, "Text message") { MessageId = "msg1" }, - new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call_1", "Tool1", null)]) { MessageId = "msg2" } - ]; - - // Act - List events = []; - await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None)) - { - events.Add(evt); - } - - // Assert - Assert.Contains(events, e => e is RunStartedEvent); - Assert.Contains(events, e => e is TextMessageStartEvent); - Assert.Contains(events, e => e is TextMessageContentEvent); - Assert.Contains(events, e => e is TextMessageEndEvent); - Assert.Contains(events, e => e is ToolCallStartEvent); - Assert.Contains(events, e => e is ToolCallArgsEvent); - Assert.Contains(events, e => e is ToolCallEndEvent); - Assert.Contains(events, e => e is RunFinishedEvent); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj index 57a653d9f06..ed65db63289 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj @@ -14,6 +14,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/TestHelpers.cs index 9f023644da5..9eab81a0ce0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/TestHelpers.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/TestHelpers.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#if !NET10_0_OR_GREATER + using System.Collections.Generic; using System.Threading.Tasks; @@ -19,3 +21,5 @@ public static async IAsyncEnumerable ToAsyncEnumerableAsync(this IEnumerab } } } + +#endif