Merge branch 'main' into feature-python-foundry-agents

This commit is contained in:
Dmytro Struk
2025-11-05 08:09:07 -08:00
committed by GitHub
Unverified
48 changed files with 4707 additions and 3 deletions
+95
View File
@@ -0,0 +1,95 @@
---
status: accepted
contact: javiercn
date: 2025-10-29
deciders: javiercn, DeagleGross, moonbox3, markwallace-microsoft
consulted: Agent Framework team
informed: .NET community
---
# AG-UI Protocol Support for .NET Agent Framework
## Context and Problem Statement
The .NET Agent Framework needed a standardized way to enable communication between AI agents and user-facing applications with support for streaming, real-time updates, and bidirectional communication. Without AG-UI protocol support, .NET agents could not interoperate with the growing ecosystem of AG-UI-compatible frontends and agent frameworks (LangGraph, CrewAI, Pydantic AI, etc.), limiting the framework's adoption and utility.
The AG-UI (Agent-User Interaction) protocol is an open, lightweight, event-based protocol that addresses key challenges in agentic applications including streaming support for long-running agents, event-driven architecture for nondeterministic behavior, and protocol interoperability that complements MCP (tool/context) and A2A (agent-to-agent) protocols.
## Decision Drivers
- Need for streaming communication between agents and client applications
- Requirement for protocol interoperability with other AI frameworks
- Support for long-running, multi-turn conversation sessions
- Real-time UI updates for nondeterministic agent behavior
- Standardized approach to agent-to-UI communication
- Framework abstraction to protect consumers from protocol changes
## Considered Options
1. **Implement AG-UI event types as public API surface** - Expose AG-UI event models directly to consumers
2. **Use custom AIContent types for lifecycle events** - Create new content types (RunStartedContent, RunFinishedContent, RunErrorContent)
3. **Current approach** - Internal event types with framework-native abstractions
## Decision Outcome
Chosen option: "Current approach with internal event types and framework-native abstractions", because it:
- Protects consumers from protocol changes by keeping AG-UI events internal
- Maintains framework abstractions through conversion at boundaries
- Uses existing framework types (AgentRunResponseUpdate, ChatMessage) for public API
- Focuses on core text streaming functionality
- Leverages existing properties (ConversationId, ResponseId, ErrorContent) instead of custom types
- Provides bidirectional client and server support
### Implementation Details
**In Scope:**
1. **Client-side AG-UI consumption** (`Microsoft.Agents.AI.AGUI` package)
- `AGUIAgent` class for connecting to remote AG-UI servers
- `AGUIAgentThread` for managing conversation threads
- HTTP/SSE streaming support
- Event-to-framework type conversion
2. **Server-side AG-UI hosting** (`Microsoft.Agents.AI.Hosting.AGUI.AspNetCore` package)
- `MapAGUIAgent` extension method for ASP.NET Core
- Server-Sent Events (SSE) response formatting
- Framework-to-event type conversion
- Agent factory pattern for per-request instantiation
3. **Text streaming events**
- Lifecycle events: `RunStarted`, `RunFinished`, `RunError`
- Text message events: `TextMessageStart`, `TextMessageContent`, `TextMessageEnd`
- Thread and run ID management via `ConversationId` and `ResponseId`
### Key Design Decisions
1. **Event Models as Internal Types** - AG-UI event types are internal with conversion via extension methods; public API uses the existing types in Microsoft.Extensions.AI as those are the abstractions people are familiar with
2. **No Custom Content Types** - Run lifecycle communicated through existing `ChatResponseUpdate` properties (`ConversationId`, `ResponseId`) and standard `ErrorContent` type
3. **Agent Factory Pattern** - `MapAGUIAgent` uses factory function `(messages) => AIAgent` to allow request-specific agent configuration supporting multi-tenancy
4. **Bidirectional Conversion Architecture** - Symmetric conversion logic in shared namespace compiled into both packages for server (`AgentRunResponseUpdate` → AG-UI events) and client (AG-UI events → `AgentRunResponseUpdate`)
5. **Thread Management** - `AGUIAgentThread` stores only `ThreadId` with thread ID communicated via `ConversationId`; applications manage persistence for parity with other implementations and to be compliant with the protocol. Future extensions will support having the server manage the conversation.
6. **Custom JSON Converter** - Uses custom polymorphic deserialization via `BaseEventJsonConverter` instead of built-in System.Text.Json support to handle AG-UI protocol's flexible discriminator positioning
### Consequences
**Positive:**
- .NET developers can consume AG-UI servers from any framework
- .NET agents accessible from any AG-UI-compatible client
- Standardized streaming communication patterns
- Protected from protocol changes through internal implementation
- Symmetric conversion logic between client and server
- Framework-native public API surface
**Negative:**
- Custom JSON converter required (internal implementation detail)
- Shared code uses preprocessor directives (`#if ASPNETCORE`)
- Additional abstraction layer between protocol and public API
**Neutral:**
- Initial implementation focused on text streaming
- Applications responsible for thread persistence
+2 -1
View File
@@ -30,6 +30,7 @@
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="9.0.10" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Net.Http.Json" Version="9.0.10" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
<PackageVersion Include="System.Threading.Channels" Version="9.0.10" />
@@ -97,7 +98,7 @@
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<!-- Test -->
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="9.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageVersion Include="Moq" Version="[4.18.4]" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Abstractions" Version="1.66.0" />
+9
View File
@@ -18,6 +18,10 @@
<Project Path="samples/AgentWebChat/AgentWebChat.ServiceDefaults/AgentWebChat.ServiceDefaults.csproj" />
<Project Path="samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj" />
</Folder>
<Folder Name="/Samples/AGUIClientServer/">
<Project Path="samples/AGUIClientServer/AGUIClient/AGUIClient.csproj" />
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
</Folder>
@@ -272,10 +276,12 @@
<Folder Name="/src/">
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
<Project Path="src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
@@ -289,6 +295,7 @@
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
@@ -297,9 +304,11 @@
</Folder>
<Folder Name="/Tests/UnitTests/">
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>a8b2e9f0-1ea3-4f18-9d41-42d1a6f8fe10</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,137 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use the AG-UI client to connect to a remote AG-UI server
// and display streaming updates including conversation/response metadata, text content, and errors.
using System.CommandLine;
using System.Reflection;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AGUI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AGUIClient;
public static class Program
{
public static async Task<int> Main(string[] args)
{
// Create root command with options
RootCommand rootCommand = new("AGUIClient");
rootCommand.SetAction((_, ct) => HandleCommandsAsync(ct));
// Run the command
return await rootCommand.Parse(args).InvokeAsync();
}
private static async Task HandleCommandsAsync(CancellationToken cancellationToken)
{
// Set up the logging
using ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Information);
});
ILogger logger = loggerFactory.CreateLogger("AGUIClient");
// Retrieve configuration settings
IConfigurationRoot configRoot = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.Build();
string serverUrl = configRoot["AGUI_SERVER_URL"] ?? "http://localhost:5100";
logger.LogInformation("Connecting to AG-UI server at: {ServerUrl}", serverUrl);
// Create the AG-UI client agent
using HttpClient httpClient = new()
{
Timeout = TimeSpan.FromSeconds(60)
};
AGUIAgent agent = new(
id: "agui-client",
description: "AG-UI Client Agent",
httpClient: httpClient,
endpoint: serverUrl);
AgentThread thread = agent.GetNewThread();
List<ChatMessage> messages = [new(ChatRole.System, "You are a helpful assistant.")];
try
{
while (true)
{
// Get user message
Console.Write("\nUser (:q or quit to exit): ");
string? message = Console.ReadLine();
if (string.IsNullOrWhiteSpace(message))
{
Console.WriteLine("Request cannot be empty.");
continue;
}
if (message is ":q" or "quit")
{
break;
}
messages.Add(new(ChatRole.User, message));
// Call RunStreamingAsync to get streaming updates
bool isFirstUpdate = true;
string? threadId = null;
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread, cancellationToken: cancellationToken))
{
// Use AsChatResponseUpdate to access ChatResponseUpdate properties
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
if (chatUpdate.ConversationId != null)
{
threadId = chatUpdate.ConversationId;
}
// Display run started information from the first update
if (isFirstUpdate && threadId != null && update.ResponseId != null)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {update.ResponseId}]");
Console.ResetColor();
isFirstUpdate = false;
}
// Display different content types with appropriate formatting
foreach (AIContent content in update.Contents)
{
switch (content)
{
case TextContent textContent:
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write(textContent.Text);
Console.ResetColor();
break;
case ErrorContent errorContent:
Console.ForegroundColor = ConsoleColor.Red;
string code = errorContent.AdditionalProperties?["Code"] as string ?? "Unknown";
Console.WriteLine($"\n[Error - Code: {code}, Message: {errorContent.Message}]");
Console.ResetColor();
break;
}
}
}
messages.Clear();
Console.WriteLine();
}
}
catch (OperationCanceledException)
{
logger.LogInformation("AGUIClient operation was canceled.");
}
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException and not ThreadAbortException and not AccessViolationException)
{
logger.LogError(ex, "An error occurred while running the AGUIClient");
return;
}
}
}
@@ -0,0 +1,34 @@
# AG-UI Client
This is a console application that demonstrates how to connect to an AG-UI server and interact with remote agents using the AG-UI protocol.
## Features
- Connects to an AG-UI server endpoint
- Displays streaming updates with color-coded output:
- **Yellow**: Run started notifications
- **Cyan**: Agent text responses (streamed)
- **Green**: Run finished notifications
- **Red**: Error messages (if any)
- Interactive prompt loop for sending messages
## Configuration
Set the following environment variable to specify the AG-UI server URL:
```powershell
$env:AGUI_SERVER_URL="http://localhost:5100"
```
If not set, the default is `http://localhost:5100`.
## Running the Client
1. Make sure the AG-UI server is running
2. Run the client:
```bash
cd AGUIClient
dotnet run
```
3. Enter your messages and observe the streaming updates
4. Type `:q` or `quit` to exit
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>a8b2e9f0-1ea3-4f18-9d41-42d1a6f8fe10</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,17 @@
@host = http://localhost:5100
### Send a message to the AG-UI agent
POST {{host}}/
Content-Type: application/json
{
"threadId": "thread_123",
"runId": "run_456",
"messages": [
{
"role": "user",
"content": "What is the capital of France?"
}
],
"context": {}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
WebApplication app = builder.Build();
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.");
// Create the AI agent
var agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.CreateAIAgent(name: "AGUIAssistant");
// Map the AG-UI agent endpoint
app.MapAGUI("/", agent);
await app.RunAsync();
+202
View File
@@ -0,0 +1,202 @@
# AG-UI Client and Server Sample
This sample demonstrates how to use the AG-UI (Agent UI) protocol to enable communication between a client application and a remote agent server. The AG-UI protocol provides a standardized way for clients to interact with AI agents.
## Overview
The demonstration has two components:
1. **AGUIServer** - An ASP.NET Core web server that hosts an AI agent and exposes it via the AG-UI protocol
2. **AGUIClient** - A console application that connects to the AG-UI server and displays streaming updates
> **Warning**
> The AG-UI protocol is still under development and changing.
> We will try to keep these samples updated as the protocol evolves.
## Configuring Environment Variables
Configure the required Azure OpenAI environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="<<your-model-endpoint>>"
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4.1-mini"
```
> **Note:** This sample uses `DefaultAzureCredential` for authentication. Make sure you're authenticated with Azure (e.g., via `az login`, Visual Studio, or environment variables).
## Running the Sample
### Step 1: Start the AG-UI Server
```bash
cd AGUIServer
dotnet build
dotnet run --urls "http://localhost:5100"
```
The server will start and listen on `http://localhost:5100`.
### Step 2: Testing with the REST Client (Optional)
Before running the client, you can test the server using the included `.http` file:
1. Open [./AGUIServer/AGUIServer.http](./AGUIServer/AGUIServer.http) in Visual Studio or VS Code with the REST Client extension
2. Send a test request to verify the server is working
3. Observe the server-sent events stream in the response
Sample request:
```http
POST http://localhost:5100/
Content-Type: application/json
{
"threadId": "thread_123",
"runId": "run_456",
"messages": [
{
"role": "user",
"content": "What is the capital of France?"
}
],
"context": {}
}
```
### Step 3: Run the AG-UI Client
In a new terminal window:
```bash
cd AGUIClient
dotnet run
```
Optionally, configure a different server URL:
```powershell
$env:AGUI_SERVER_URL="http://localhost:5100"
```
### Step 4: Interact with the Agent
1. The client will connect to the AG-UI server
2. Enter your message at the prompt
3. Observe the streaming updates with color-coded output:
- **Yellow**: Run started notification showing thread and run IDs
- **Cyan**: Agent's text response (streamed character by character)
- **Green**: Run finished notification
- **Red**: Error messages (if any occur)
4. Type `:q` or `quit` to exit
## Sample Output
```
AGUIClient> dotnet run
info: AGUIClient[0]
Connecting to AG-UI server at: http://localhost:5100
User (:q or quit to exit): What is the capital of France?
[Run Started - Thread: thread_abc123, Run: run_xyz789]
The capital of France is Paris. It is known for its rich history, culture, and iconic landmarks such as the Eiffel Tower and the Louvre Museum.
[Run Finished - Thread: thread_abc123, Run: run_xyz789]
User (:q or quit to exit): Tell me a fun fact about space
[Run Started - Thread: thread_abc123, Run: run_def456]
Here's a fun fact: A day on Venus is longer than its year! Venus takes about 243 Earth days to rotate once on its axis, but only about 225 Earth days to orbit the Sun.
[Run Finished - Thread: thread_abc123, Run: run_def456]
User (:q or quit to exit): :q
```
## How It Works
### Server Side
The `AGUIServer` uses the `MapAGUI` extension method to expose an agent through the AG-UI protocol:
```csharp
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient(model)
.CreateAIAgent(
instructions: "You are a helpful assistant.",
name: "AGUIAssistant");
app.MapAGUI("/", agent);
```
This automatically handles:
- HTTP POST requests with message payloads
- Converting agent responses to AG-UI event streams
- Server-sent events (SSE) formatting
- Thread and run management
### Client Side
The `AGUIClient` uses the `AGUIAgent` class to connect to the remote server:
```csharp
AGUIAgent agent = new(
id: "agui-client",
description: "AG-UI Client Agent",
messages: [],
httpClient: httpClient,
endpoint: serverUrl);
bool isFirstUpdate = true;
AgentRunResponseUpdate? currentUpdate = null;
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread))
{
// First update indicates run started
if (isFirstUpdate)
{
Console.WriteLine($"[Run Started - Thread: {update.ConversationId}, Run: {update.ResponseId}]");
isFirstUpdate = false;
}
currentUpdate = update;
foreach (AIContent content in update.Contents)
{
switch (content)
{
case TextContent textContent:
// Display streaming text
Console.Write(textContent.Text);
break;
case ErrorContent errorContent:
// Display error notification
Console.WriteLine($"[Error: {errorContent.Message}]");
break;
}
}
}
// Last update indicates run finished
if (currentUpdate != null)
{
Console.WriteLine($"\n[Run Finished - Thread: {currentUpdate.ConversationId}, Run: {currentUpdate.ResponseId}]");
}
```
The `RunStreamingAsync` method:
1. Sends messages to the server via HTTP POST
2. Receives server-sent events (SSE) stream
3. Parses events into `AgentRunResponseUpdate` objects
4. Yields updates as they arrive for real-time display
## Key Concepts
- **Thread**: Represents a conversation context that persists across multiple runs (accessed via `ConversationId` property)
- **Run**: A single execution of the agent for a given set of messages (identified by `ResponseId` property)
- **AgentRunResponseUpdate**: Contains the response data with:
- `ResponseId`: The unique run identifier
- `ConversationId`: The thread/conversation identifier
- `Contents`: Collection of content items (TextContent, ErrorContent, etc.)
- **Run Lifecycle**:
- The **first** `AgentRunResponseUpdate` in a run indicates the run has started
- Subsequent updates contain streaming content as the agent processes
- The **last** `AgentRunResponseUpdate` in a run indicates the run has finished
- If an error occurs, the update will contain `ErrorContent`
@@ -0,0 +1,102 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
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.Shared.Diagnostics;
namespace Microsoft.Agents.AI.AGUI;
/// <summary>
/// Provides an <see cref="AIAgent"/> implementation that communicates with an AG-UI compliant server.
/// </summary>
public sealed class AGUIAgent : AIAgent
{
private readonly AGUIHttpService _client;
/// <summary>
/// Initializes a new instance of the <see cref="AGUIAgent"/> class.
/// </summary>
/// <param name="id">The agent ID.</param>
/// <param name="description">Optional description of the agent.</param>
/// <param name="httpClient">The HTTP client to use for communication with the AG-UI server.</param>
/// <param name="endpoint">The URL for the AG-UI server.</param>
public AGUIAgent(string id, string description, HttpClient httpClient, string endpoint)
{
this.Id = Throw.IfNullOrWhitespace(id);
this.Description = description;
this._client = new AGUIHttpService(
httpClient ?? Throw.IfNull(httpClient),
endpoint ?? Throw.IfNullOrEmpty(endpoint));
}
/// <inheritdoc/>
public override string Id { get; }
/// <inheritdoc/>
public override string? Description { get; }
/// <inheritdoc/>
public override AgentThread GetNewThread() => new AGUIAgentThread();
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
new AGUIAgentThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return await this.RunStreamingAsync(messages, thread, null, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
List<ChatResponseUpdate> updates = [];
_ = Throw.IfNull(messages);
if ((thread ?? this.GetNewThread()) is not AGUIAgentThread typedThread)
{
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
}
string runId = $"run_{Guid.NewGuid()}";
var llmMessages = typedThread.MessageStore.Concat(messages);
RunAgentInput input = new()
{
ThreadId = typedThread.ThreadId,
RunId = runId,
Messages = llmMessages.AsAGUIMessages(),
};
await foreach (var update in this._client.PostRunAsync(input, cancellationToken).AsAgentRunResponseUpdatesAsync(cancellationToken).ConfigureAwait(false))
{
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
updates.Add(chatUpdate);
yield return update;
}
ChatResponse response = updates.ToChatResponse();
await NotifyThreadOfNewMessagesAsync(typedThread, messages.Concat(response.Messages), cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.AGUI;
internal sealed class AGUIAgentThread : InMemoryAgentThread
{
public AGUIAgentThread()
: base()
{
this.ThreadId = Guid.NewGuid().ToString();
}
public AGUIAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
: base(UnwrapState(serializedThreadState), jsonSerializerOptions)
{
var threadId = serializedThreadState.TryGetProperty(nameof(AGUIAgentThreadState.ThreadId), out var stateElement)
? stateElement.GetString()
: null;
if (string.IsNullOrEmpty(threadId))
{
Throw.InvalidOperationException("Serialized thread is missing required ThreadId.");
}
this.ThreadId = threadId;
}
private static JsonElement UnwrapState(JsonElement serializedThreadState)
{
var state = serializedThreadState.Deserialize(AGUIJsonSerializerContext.Default.AGUIAgentThreadState);
if (state == null)
{
Throw.InvalidOperationException("Serialized thread is missing required WrappedState.");
}
return state.WrappedState;
}
public string ThreadId { get; set; }
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
var wrappedState = base.Serialize(jsonSerializerOptions);
var state = new AGUIAgentThreadState
{
ThreadId = this.ThreadId,
WrappedState = wrappedState,
};
return JsonSerializer.SerializeToElement(state, AGUIJsonSerializerContext.Default.AGUIAgentThreadState);
}
internal sealed class AGUIAgentThreadState
{
public string ThreadId { get; set; } = string.Empty;
public JsonElement WrappedState { get; set; }
}
}
@@ -0,0 +1,52 @@
// 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<BaseEvent> 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<byte> data)
{
return JsonSerializer.Deserialize(data, AGUIJsonSerializerContext.Default.BaseEvent) ??
throw new InvalidOperationException("Failed to deserialize SSE item.");
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<IsPackable>false</IsPackable>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework AG-UI</Title>
<Description>Provides Microsoft Agent Framework support for Agent-User Interaction (AG-UI) protocol client functionality.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Net.Http.Json" />
<PackageReference Include="System.Threading.Channels" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.AGUI.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.AGUI.IntegrationTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
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 AGUIChatMessageExtensions
{
private static readonly ChatRole s_developerChatRole = new("developer");
public static IEnumerable<ChatMessage> AsChatMessages(
this IEnumerable<AGUIMessage> aguiMessages)
{
foreach (var message in aguiMessages)
{
yield return new ChatMessage(
MapChatRole(message.Role),
message.Content);
}
}
public static IEnumerable<AGUIMessage> AsAGUIMessages(
this IEnumerable<ChatMessage> chatMessages)
{
foreach (var message in chatMessages)
{
yield return new AGUIMessage
{
Id = message.MessageId,
Role = message.Role.Value,
Content = message.Text,
};
}
}
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 :
throw new InvalidOperationException($"Unknown chat role: {role}");
}
@@ -0,0 +1,22 @@
// 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";
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
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
[JsonSourceGenerationOptions(WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
[JsonSerializable(typeof(RunAgentInput))]
[JsonSerializable(typeof(BaseEvent))]
[JsonSerializable(typeof(RunStartedEvent))]
[JsonSerializable(typeof(RunFinishedEvent))]
[JsonSerializable(typeof(RunErrorEvent))]
[JsonSerializable(typeof(TextMessageStartEvent))]
[JsonSerializable(typeof(TextMessageContentEvent))]
[JsonSerializable(typeof(TextMessageEndEvent))]
#if !ASPNETCORE
[JsonSerializable(typeof(AGUIAgentThread.AGUIAgentThreadState))]
#endif
internal partial class AGUIJsonSerializerContext : JsonSerializerContext
{
}
@@ -0,0 +1,21 @@
// 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 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;
}
@@ -0,0 +1,18 @@
// 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";
}
@@ -0,0 +1,161 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
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 AgentRunResponseUpdateAGUIExtensions
{
#if !ASPNETCORE
public static async IAsyncEnumerable<AgentRunResponseUpdate> AsAgentRunResponseUpdatesAsync(
this IAsyncEnumerable<BaseEvent> events,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string? currentMessageId = null;
ChatRole currentRole = default!;
string? conversationId = null;
string? responseId = null;
await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
{
switch (evt)
{
case RunStartedEvent runStarted:
conversationId = runStarted.ThreadId;
responseId = runStarted.RunId;
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(
ChatRole.Assistant,
[])
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow
});
break;
case 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}");
}
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(
ChatRole.Assistant, runFinished.Result?.GetRawText())
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow
});
break;
case RunErrorEvent runError:
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(
ChatRole.Assistant,
[(new ErrorContent(runError.Message) { ErrorCode = runError.Code })]));
break;
case TextMessageStartEvent textStart:
if (currentRole != default || currentMessageId != null)
{
throw new InvalidOperationException("Received TextMessageStartEvent while another message is being processed.");
}
currentRole = AGUIChatMessageExtensions.MapChatRole(textStart.Role);
currentMessageId = textStart.MessageId;
break;
case TextMessageContentEvent textContent:
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(
currentRole,
textContent.Delta)
{
ConversationId = conversationId,
ResponseId = responseId,
MessageId = textContent.MessageId,
CreatedAt = DateTimeOffset.UtcNow
});
break;
case TextMessageEndEvent textEnd:
if (currentMessageId != textEnd.MessageId)
{
throw new InvalidOperationException("Received TextMessageEndEvent for a different message than the current one.");
}
currentRole = default!;
currentMessageId = null;
break;
}
}
}
#endif
public static async IAsyncEnumerable<BaseEvent> AsAGUIEventStreamAsync(
this IAsyncEnumerable<AgentRunResponseUpdate> updates,
string threadId,
string runId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return new RunStartedEvent
{
ThreadId = threadId,
RunId = runId
};
string? currentMessageId = null;
await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
var chatResponse = update.AsChatResponseUpdate();
if (chatResponse is { Contents.Count: > 0 } && chatResponse.Contents[0] is TextContent && !string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
{
// 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 = chatResponse.MessageId!,
Role = chatResponse.Role!.Value.Value
};
currentMessageId = chatResponse.MessageId;
}
// Emit text content if present
if (chatResponse is { Contents.Count: > 0 } && chatResponse.Contents[0] is TextContent textContent)
{
yield return new TextMessageContentEvent
{
MessageId = chatResponse.MessageId!,
Delta = textContent.Text ?? string.Empty
};
}
}
// 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,
};
}
}
@@ -0,0 +1,16 @@
// 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;
}
@@ -0,0 +1,103 @@
// 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
/// <summary>
/// Custom JSON converter for polymorphic deserialization of BaseEvent and its derived types.
/// Uses the "type" property as a discriminator to determine the concrete type to deserialize.
/// </summary>
internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
{
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)
{
// Parse the JSON into a JsonDocument to inspect properties
using JsonDocument document = JsonDocument.ParseValue(ref reader);
JsonElement jsonElement = document.RootElement.Clone();
// 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();
#if ASPNETCORE
AGUIJsonSerializerContext context = (AGUIJsonSerializerContext)options.TypeInfoResolver!;
#else
AGUIJsonSerializerContext context = AGUIJsonSerializerContext.Default;
#endif
// Map discriminator to concrete type and deserialize using the serializer context
BaseEvent? result = discriminator switch
{
AGUIEventTypes.RunStarted => jsonElement.Deserialize(context.RunStartedEvent),
AGUIEventTypes.RunFinished => jsonElement.Deserialize(context.RunFinishedEvent),
AGUIEventTypes.RunError => jsonElement.Deserialize(context.RunErrorEvent),
AGUIEventTypes.TextMessageStart => jsonElement.Deserialize(context.TextMessageStartEvent),
AGUIEventTypes.TextMessageContent => jsonElement.Deserialize(context.TextMessageContentEvent),
AGUIEventTypes.TextMessageEnd => jsonElement.Deserialize(context.TextMessageEndEvent),
_ => 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)
{
#if ASPNETCORE
AGUIJsonSerializerContext context = (AGUIJsonSerializerContext)options.TypeInfoResolver!;
#else
AGUIJsonSerializerContext context = AGUIJsonSerializerContext.Default;
#endif
// Serialize the concrete type directly using the serializer context
switch (value)
{
case RunStartedEvent runStarted:
JsonSerializer.Serialize(writer, runStarted, context.RunStartedEvent);
break;
case RunFinishedEvent runFinished:
JsonSerializer.Serialize(writer, runFinished, context.RunFinishedEvent);
break;
case RunErrorEvent runError:
JsonSerializer.Serialize(writer, runError, context.RunErrorEvent);
break;
case TextMessageStartEvent textStart:
JsonSerializer.Serialize(writer, textStart, context.TextMessageStartEvent);
break;
case TextMessageContentEvent textContent:
JsonSerializer.Serialize(writer, textContent, context.TextMessageContentEvent);
break;
case TextMessageEndEvent textEnd:
JsonSerializer.Serialize(writer, textEnd, context.TextMessageEndEvent);
break;
default:
throw new JsonException($"Unknown BaseEvent type: {value.GetType().Name}");
}
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
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<AGUIMessage> Messages { get; set; } = [];
[JsonPropertyName("context")]
public Dictionary<string, string> Context { get; set; } = new(StringComparer.Ordinal);
[JsonPropertyName("forwardedProperties")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public JsonElement ForwardedProperties { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class RunErrorEvent : BaseEvent
{
public RunErrorEvent()
{
this.Type = AGUIEventTypes.RunError;
}
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("code")]
public string? Code { get; set; }
}
@@ -0,0 +1,27 @@
// 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; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class 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;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class 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;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class TextMessageEndEvent : BaseEvent
{
public TextMessageEndEvent()
{
this.Type = AGUIEventTypes.TextMessageEnd;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class 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;
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
/// <summary>
/// Provides extension methods for mapping AG-UI agents to ASP.NET Core endpoints.
/// </summary>
public static class AGUIEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps an AG-UI agent endpoint.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="pattern">The URL pattern for the endpoint.</param>
/// <param name="aiAgent">The agent instance.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
[StringSyntax("route")] string pattern,
AIAgent aiAgent)
{
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
{
if (input is null)
{
return Results.BadRequest();
}
var messages = input.Messages.AsChatMessages();
var agent = aiAgent;
var events = agent.RunStreamingAsync(
messages,
cancellationToken: cancellationToken)
.AsAGUIEventStreamAsync(
input.ThreadId,
input.RunId,
cancellationToken);
var logger = context.RequestServices.GetRequiredService<ILogger<AGUIServerSentEventsResult>>();
return new AGUIServerSentEventsResult(events, logger);
});
}
}
@@ -0,0 +1,128 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
internal sealed partial class AGUIServerSentEventsResult : IResult, IDisposable
{
private readonly IAsyncEnumerable<BaseEvent> _events;
private readonly ILogger<AGUIServerSentEventsResult> _logger;
private Utf8JsonWriter? _jsonWriter;
public int? StatusCode => StatusCodes.Status200OK;
internal AGUIServerSentEventsResult(IAsyncEnumerable<BaseEvent> events, ILogger<AGUIServerSentEventsResult> logger)
{
this._events = events;
this._logger = logger;
}
public async Task ExecuteAsync(HttpContext httpContext)
{
if (httpContext == null)
{
throw new ArgumentNullException(nameof(httpContext));
}
httpContext.Response.ContentType = "text/event-stream";
httpContext.Response.Headers.CacheControl = "no-cache,no-store";
httpContext.Response.Headers.Pragma = "no-cache";
var body = httpContext.Response.Body;
var cancellationToken = httpContext.RequestAborted;
try
{
await SseFormatter.WriteAsync(
WrapEventsAsSseItemsAsync(this._events, cancellationToken),
body,
this.SerializeEvent,
cancellationToken).ConfigureAwait(false);
}
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
};
await SseFormatter.WriteAsync(
WrapEventsAsSseItemsAsync([errorEvent]),
body,
this.SerializeEvent,
CancellationToken.None).ConfigureAwait(false);
}
catch (Exception sendErrorEx)
{
// If we can't send the error event, just let the connection close
LogSendErrorEventFailed(this._logger, sendErrorEx);
}
}
await body.FlushAsync(httpContext.RequestAborted).ConfigureAwait(false);
}
private static async IAsyncEnumerable<SseItem<BaseEvent>> WrapEventsAsSseItemsAsync(
IAsyncEnumerable<BaseEvent> events,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (BaseEvent evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
{
yield return new SseItem<BaseEvent>(evt);
}
}
private static async IAsyncEnumerable<SseItem<BaseEvent>> WrapEventsAsSseItemsAsync(
IEnumerable<BaseEvent> events)
{
foreach (BaseEvent evt in events)
{
yield return new SseItem<BaseEvent>(evt);
}
}
private void SerializeEvent(SseItem<BaseEvent> item, IBufferWriter<byte> writer)
{
if (this._jsonWriter == null)
{
this._jsonWriter = new Utf8JsonWriter(writer);
}
else
{
this._jsonWriter.Reset(writer);
}
JsonSerializer.Serialize(this._jsonWriter, item.Data, AGUIJsonSerializerContext.Default.BaseEvent);
}
public void Dispose()
{
this._jsonWriter?.Dispose();
}
[LoggerMessage(
Level = LogLevel.Error,
Message = "An error occurred while streaming AG-UI events",
SkipEnabledCheck = true)]
private static partial void LogStreamingError(ILogger logger, Exception exception);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Failed to send error event to client after streaming failure",
SkipEnabledCheck = true)]
private static partial void LogSendErrorEventFailed(ILogger logger, Exception exception);
}
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Hosting.AGUI.AspNetCore</RootNamespace>
<VersionSuffix>preview</VersionSuffix>
<DefineConstants>$(DefineConstants);ASPNETCORE</DefineConstants>
<IsPackable>false</IsPackable>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated</InterceptorsNamespaces>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Hosting AG-UI ASP.NET Core</Title>
<Description>Provides Microsoft Agent Framework support for hosting AG-UI agents in an ASP.NET Core context.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Text.Json" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Microsoft.Agents.AI.AGUI\Shared\**\*.cs" LinkBase="Shared" />
<Compile Remove="ServerSentEventsResult.cs" Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp' AND $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '10.0'))" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,344 @@
// 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;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.AGUI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AGUIAgent"/> class.
/// </summary>
public sealed class AGUIAgentTests
{
[Fact]
public async Task RunAsync_AggregatesStreamingUpdates_ReturnsCompleteMessagesAsync()
{
// Arrange
using HttpClient httpClient = this.CreateMockHttpClient(new BaseEvent[]
{
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" }
});
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
// Act
AgentRunResponse 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" }
]);
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
// Act
AgentRunResponse 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();
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => agent.RunAsync(messages: null!));
}
[Fact]
public async Task RunAsync_WithNullThread_CreatesNewThreadAsync()
{
// Arrange
using HttpClient httpClient = this.CreateMockHttpClient(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
// Act
AgentRunResponse response = await agent.RunAsync(messages, thread: null);
// Assert
Assert.NotNull(response);
}
[Fact]
public async Task RunAsync_WithNonAGUIAgentThread_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
using HttpClient httpClient = new();
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
AgentThread invalidThread = new TestInMemoryAgentThread();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(messages, thread: invalidThread));
}
[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" }
]);
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
// Act
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate 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();
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
{
await foreach (var _ in agent.RunStreamingAsync(messages: null!))
{
// Intentionally empty - consuming stream to trigger exception
}
});
}
[Fact]
public async Task RunStreamingAsync_WithNullThread_CreatesNewThreadAsync()
{
// Arrange
using HttpClient httpClient = this.CreateMockHttpClient(new BaseEvent[]
{
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
});
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
// Act
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread: null))
{
// Consume the stream
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
}
[Fact]
public async Task RunStreamingAsync_WithNonAGUIAgentThread_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
using HttpClient httpClient = new();
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
AgentThread invalidThread = new TestInMemoryAgentThread();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in agent.RunStreamingAsync(messages, thread: invalidThread))
{
// Consume the stream
}
});
}
[Fact]
public async Task RunStreamingAsync_GeneratesUniqueRunId_ForEachInvocationAsync()
{
// Arrange
List<string> capturedRunIds = [];
using HttpClient httpClient = this.CreateMockHttpClientWithCapture(new BaseEvent[]
{
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
}, capturedRunIds);
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
List<ChatMessage> 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, capturedRunIds.Count);
Assert.NotEqual(capturedRunIds[0], capturedRunIds[1]);
}
[Fact]
public async Task RunStreamingAsync_NotifiesThreadOfNewMessages_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" }
]);
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
AGUIAgentThread thread = new();
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
// Act
await foreach (var _ in agent.RunStreamingAsync(messages, thread))
{
// Consume the stream
}
// Assert
Assert.NotEmpty(thread.MessageStore);
}
[Fact]
public void DeserializeThread_WithValidState_ReturnsAGUIAgentThread()
{
// Arrange
using var httpClient = new HttpClient();
AGUIAgent agent = new("agent1", "Test agent", httpClient, "http://localhost/agent");
AGUIAgentThread originalThread = new() { ThreadId = "test-thread-123" };
JsonElement serialized = originalThread.Serialize();
// Act
AgentThread deserialized = agent.DeserializeThread(serialized);
// Assert
Assert.NotNull(deserialized);
Assert.IsType<AGUIAgentThread>(deserialized);
AGUIAgentThread typedThread = (AGUIAgentThread)deserialized;
Assert.Equal("test-thread-123", typedThread.ThreadId);
}
private HttpClient CreateMockHttpClient(BaseEvent[] events)
{
string sseContent = string.Join("", events.Select(e =>
$"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
Mock<HttpMessageHandler> handlerMock = new();
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(sseContent)
});
return new HttpClient(handlerMock.Object);
}
private HttpClient CreateMockHttpClientWithCapture(BaseEvent[] events, List<string> capturedRunIds)
{
string sseContent = string.Join("", events.Select(e =>
$"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
Mock<HttpMessageHandler> handlerMock = new();
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Returns(async (HttpRequestMessage request, CancellationToken ct) =>
{
#if NET
string requestBody = await request.Content!.ReadAsStringAsync(ct).ConfigureAwait(false);
#else
string requestBody = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
#endif
RunAgentInput? input = JsonSerializer.Deserialize(requestBody, AGUIJsonSerializerContext.Default.RunAgentInput);
if (input != null)
{
capturedRunIds.Add(input.RunId);
}
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(sseContent)
};
});
return new HttpClient(handlerMock.Object);
}
private sealed class TestInMemoryAgentThread : InMemoryAgentThread
{
}
}
@@ -0,0 +1,132 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.AGUI.UnitTests;
public sealed class AGUIAgentThreadTests
{
[Fact]
public void Constructor_WithValidThreadId_DeserializesSuccessfully()
{
// Arrange
const string ThreadId = "thread123";
AGUIAgentThread originalThread = new() { ThreadId = ThreadId };
JsonElement serialized = originalThread.Serialize();
// Act
AGUIAgentThread deserializedThread = new(serialized);
// Assert
Assert.Equal(ThreadId, deserializedThread.ThreadId);
}
[Fact]
public void Constructor_WithMissingThreadId_ThrowsInvalidOperationException()
{
// Arrange
const string Json = """
{"WrappedState":{}}
""";
JsonElement serialized = JsonSerializer.Deserialize<JsonElement>(Json);
// Act & Assert
Assert.Throws<InvalidOperationException>(() => new AGUIAgentThread(serialized));
}
[Fact]
public void Constructor_WithMissingWrappedState_ThrowsArgumentException()
{
// Arrange
const string Json = """
{}
""";
JsonElement serialized = JsonSerializer.Deserialize<JsonElement>(Json);
// Act & Assert
Assert.Throws<ArgumentException>(() => new AGUIAgentThread(serialized));
}
[Fact]
public async Task Constructor_UnwrapsAndRestores_BaseStateAsync()
{
// Arrange
AGUIAgentThread originalThread = new() { ThreadId = "thread1" };
ChatMessage message = new(ChatRole.User, "Test message");
await TestAgent.AddMessageToThreadAsync(originalThread, message);
JsonElement serialized = originalThread.Serialize();
// Act
AGUIAgentThread deserializedThread = new(serialized);
// Assert
Assert.Single(deserializedThread.MessageStore);
Assert.Equal("Test message", deserializedThread.MessageStore.First().Text);
}
[Fact]
public void Serialize_IncludesThreadId_InSerializedState()
{
// Arrange
const string ThreadId = "thread456";
AGUIAgentThread thread = new() { ThreadId = ThreadId };
// Act
JsonElement serialized = thread.Serialize();
// Assert
Assert.True(serialized.TryGetProperty("ThreadId", out JsonElement threadIdElement));
Assert.Equal(ThreadId, threadIdElement.GetString());
}
[Fact]
public async Task Serialize_WrapsBaseState_CorrectlyAsync()
{
// Arrange
AGUIAgentThread thread = new() { ThreadId = "thread1" };
ChatMessage message = new(ChatRole.User, "Test message");
await TestAgent.AddMessageToThreadAsync(thread, message);
// Act
JsonElement serialized = thread.Serialize();
// Assert
Assert.True(serialized.TryGetProperty("WrappedState", out JsonElement wrappedState));
Assert.NotEqual(JsonValueKind.Null, wrappedState.ValueKind);
}
[Fact]
public async Task Serialize_RoundTrip_PreservesThreadIdAndMessagesAsync()
{
// Arrange
const string ThreadId = "thread789";
AGUIAgentThread originalThread = new() { ThreadId = ThreadId };
ChatMessage message1 = new(ChatRole.User, "First message");
ChatMessage message2 = new(ChatRole.Assistant, "Second message");
await TestAgent.AddMessageToThreadAsync(originalThread, message1);
await TestAgent.AddMessageToThreadAsync(originalThread, message2);
// Act
JsonElement serialized = originalThread.Serialize();
AGUIAgentThread deserializedThread = new(serialized);
// Assert
Assert.Equal(ThreadId, deserializedThread.ThreadId);
Assert.Equal(2, deserializedThread.MessageStore.Count);
Assert.Equal("First message", deserializedThread.MessageStore.ElementAt(0).Text);
Assert.Equal("Second message", deserializedThread.MessageStore.ElementAt(1).Text);
}
private abstract class TestAgent : AIAgent
{
public static async Task AddMessageToThreadAsync(AgentThread thread, ChatMessage message)
{
await NotifyThreadOfNewMessagesAsync(thread, [message], CancellationToken.None);
}
}
}
@@ -0,0 +1,188 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.AGUI.Shared;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.AGUI.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AGUIChatMessageExtensions"/> class.
/// </summary>
public sealed class AGUIChatMessageExtensionsTests
{
[Fact]
public void AsChatMessages_WithEmptyCollection_ReturnsEmptyList()
{
// Arrange
List<AGUIMessage> aguiMessages = [];
// Act
IEnumerable<ChatMessage> chatMessages = aguiMessages.AsChatMessages();
// Assert
Assert.NotNull(chatMessages);
Assert.Empty(chatMessages);
}
[Fact]
public void AsChatMessages_WithSingleMessage_ConvertsToChatMessageCorrectly()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIMessage
{
Id = "msg1",
Role = AGUIRoles.User,
Content = "Hello"
}
];
// Act
IEnumerable<ChatMessage> chatMessages = aguiMessages.AsChatMessages();
// 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<AGUIMessage> aguiMessages =
[
new AGUIMessage { Id = "msg1", Role = AGUIRoles.User, Content = "First" },
new AGUIMessage { Id = "msg2", Role = AGUIRoles.Assistant, Content = "Second" },
new AGUIMessage { Id = "msg3", Role = AGUIRoles.User, Content = "Third" }
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages().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<AGUIMessage> aguiMessages =
[
new AGUIMessage { Id = "msg1", Role = AGUIRoles.System, Content = "System message" },
new AGUIMessage { Id = "msg2", Role = AGUIRoles.User, Content = "User message" },
new AGUIMessage { Id = "msg3", Role = AGUIRoles.Assistant, Content = "Assistant message" },
new AGUIMessage { Id = "msg4", Role = AGUIRoles.Developer, Content = "Developer message" }
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages().ToList();
// Assert
Assert.Equal(4, 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);
}
[Fact]
public void AsAGUIMessages_WithEmptyCollection_ReturnsEmptyList()
{
// Arrange
List<ChatMessage> chatMessages = [];
// Act
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages();
// Assert
Assert.NotNull(aguiMessages);
Assert.Empty(aguiMessages);
}
[Fact]
public void AsAGUIMessages_WithSingleMessage_ConvertsToAGUIMessageCorrectly()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.User, "Hello") { MessageId = "msg1" }
];
// Act
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages();
// Assert
AGUIMessage message = Assert.Single(aguiMessages);
Assert.Equal("msg1", message.Id);
Assert.Equal(AGUIRoles.User, message.Role);
Assert.Equal("Hello", message.Content);
}
[Fact]
public void AsAGUIMessages_WithMultipleMessages_PreservesOrder()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Second"),
new ChatMessage(ChatRole.User, "Third")
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages().ToList();
// Assert
Assert.Equal(3, aguiMessages.Count);
Assert.Equal("First", aguiMessages[0].Content);
Assert.Equal("Second", aguiMessages[1].Content);
Assert.Equal("Third", aguiMessages[2].Content);
}
[Fact]
public void AsAGUIMessages_PreservesMessageId_WhenPresent()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.User, "Hello") { MessageId = "msg123" }
];
// Act
IEnumerable<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages();
// 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<InvalidOperationException>(() => AGUIChatMessageExtensions.MapChatRole("unknown"));
}
}
@@ -0,0 +1,198 @@
// 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;
/// <summary>
/// Unit tests for the <see cref="AGUIHttpService"/> class.
/// </summary>
public sealed class AGUIHttpServiceTests
{
[Fact]
public async Task PostRunAsync_SendsRequestAndParsesSSEStream_SuccessfullyAsync()
{
// Arrange
BaseEvent[] events = new BaseEvent[]
{
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 = this.CreateMockHttpClient(events, HttpStatusCode.OK);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
};
// Act
List<BaseEvent> resultEvents = [];
await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None))
{
resultEvents.Add(evt);
}
// Assert
Assert.Equal(5, resultEvents.Count);
Assert.IsType<RunStartedEvent>(resultEvents[0]);
Assert.IsType<TextMessageStartEvent>(resultEvents[1]);
Assert.IsType<TextMessageContentEvent>(resultEvents[2]);
Assert.IsType<TextMessageEndEvent>(resultEvents[3]);
Assert.IsType<RunFinishedEvent>(resultEvents[4]);
}
[Fact]
public async Task PostRunAsync_WithNonSuccessStatusCode_ThrowsHttpRequestExceptionAsync()
{
// Arrange
HttpClient httpClient = this.CreateMockHttpClient([], HttpStatusCode.InternalServerError);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
};
// Act & Assert
await Assert.ThrowsAsync<HttpRequestException>(async () =>
{
await foreach (var _ in service.PostRunAsync(input, CancellationToken.None))
{
// Consume the stream
}
});
}
[Fact]
public async Task PostRunAsync_DeserializesMultipleEventTypes_CorrectlyAsync()
{
// Arrange
BaseEvent[] events = new BaseEvent[]
{
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunErrorEvent { Message = "Error occurred", Code = "ERR001" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonDocument.Parse("\"Success\"").RootElement.Clone() }
};
HttpClient httpClient = this.CreateMockHttpClient(events, HttpStatusCode.OK);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
};
// Act
List<BaseEvent> resultEvents = [];
await foreach (BaseEvent evt in service.PostRunAsync(input, CancellationToken.None))
{
resultEvents.Add(evt);
}
// Assert
Assert.Equal(3, resultEvents.Count);
RunStartedEvent startedEvent = Assert.IsType<RunStartedEvent>(resultEvents[0]);
Assert.Equal("thread1", startedEvent.ThreadId);
RunErrorEvent errorEvent = Assert.IsType<RunErrorEvent>(resultEvents[1]);
Assert.Equal("Error occurred", errorEvent.Message);
RunFinishedEvent finishedEvent = Assert.IsType<RunFinishedEvent>(resultEvents[2]);
Assert.Equal("Success", finishedEvent.Result?.GetString());
}
[Fact]
public async Task PostRunAsync_WithEmptyEventStream_CompletesSuccessfullyAsync()
{
// Arrange
HttpClient httpClient = this.CreateMockHttpClient([], HttpStatusCode.OK);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
};
// Act
List<BaseEvent> 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<HttpMessageHandler> handlerMock = new(MockBehavior.Strict);
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new TaskCanceledException());
HttpClient httpClient = new(handlerMock.Object);
AGUIHttpService service = new(httpClient, "http://localhost/agent");
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
};
// Act & Assert
await Assert.ThrowsAsync<TaskCanceledException>(async () =>
{
await foreach (var _ in service.PostRunAsync(input, cts.Token))
{
// Intentionally empty - consuming stream to trigger cancellation
}
});
}
private HttpClient CreateMockHttpClient(BaseEvent[] events, HttpStatusCode statusCode)
{
string sseContent = string.Join("", events.Select(e =>
$"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
Mock<HttpMessageHandler> handlerMock = new(MockBehavior.Strict);
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(sseContent)
});
return new HttpClient(handlerMock.Object);
}
}
@@ -0,0 +1,843 @@
// 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;
/// <summary>
/// Unit tests for the <see cref="AGUIJsonSerializerContext"/> class and JSON serialization.
/// </summary>
public sealed class AGUIJsonSerializerContextTests
{
[Fact]
public void RunAgentInput_Serializes_WithAllRequiredFields()
{
// Arrange
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
};
// Act
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
JsonElement jsonElement = JsonSerializer.Deserialize<JsonElement>(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_HandlesOptionalFields_StateContextAndForwardedProperties()
{
// Arrange
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }],
State = JsonSerializer.SerializeToElement(new { key = "value" }),
Context = new Dictionary<string, string> { ["ctx1"] = "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 AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "First" },
new AGUIMessage { Id = "m2", Role = AGUIRoles.Assistant, Content = "Second" }
],
Context = new Dictionary<string, string> { ["key1"] = "value1", ["key2"] = "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.Count);
}
[Fact]
public void RunStartedEvent_Serializes_WithCorrectEventType()
{
// Arrange
RunStartedEvent evt = new() { ThreadId = "thread1", RunId = "run1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunStartedEvent);
// Assert
Assert.Contains($"\"type\":\"{AGUIEventTypes.RunStarted}\"", json);
}
[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 = JsonSerializer.Deserialize<JsonElement>(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
Assert.Contains($"\"type\":\"{AGUIEventTypes.RunFinished}\"", json);
}
[Fact]
public void RunFinishedEvent_Includes_ThreadIdRunIdAndOptionalResult()
{
// Arrange
RunFinishedEvent evt = new() { ThreadId = "thread1", RunId = "run1", Result = JsonDocument.Parse("\"Success\"").RootElement.Clone() };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunFinishedEvent);
JsonElement jsonElement = JsonSerializer.Deserialize<JsonElement>(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 = JsonDocument.Parse("\"Done\"").RootElement.Clone() };
// 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
Assert.Contains($"\"type\":\"{AGUIEventTypes.RunError}\"", json);
}
[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 = JsonSerializer.Deserialize<JsonElement>(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
Assert.Contains($"\"type\":\"{AGUIEventTypes.TextMessageStart}\"", json);
}
[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 = JsonSerializer.Deserialize<JsonElement>(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
Assert.Contains($"\"type\":\"{AGUIEventTypes.TextMessageContent}\"", json);
}
[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 = JsonSerializer.Deserialize<JsonElement>(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
Assert.Contains($"\"type\":\"{AGUIEventTypes.TextMessageEnd}\"", json);
}
[Fact]
public void TextMessageEndEvent_Includes_MessageId()
{
// Arrange
TextMessageEndEvent evt = new() { MessageId = "msg1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageEndEvent);
JsonElement jsonElement = JsonSerializer.Deserialize<JsonElement>(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() { Id = "m1", Role = AGUIRoles.User, Content = "Hello" };
// Act
string json = JsonSerializer.Serialize(message, AGUIJsonSerializerContext.Default.AGUIMessage);
JsonElement jsonElement = JsonSerializer.Deserialize<JsonElement>(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", message.Content);
}
[Fact]
public void AGUIMessage_RoundTrip_PreservesData()
{
// Arrange
AGUIMessage original = new() { Id = "msg123", Role = AGUIRoles.Assistant, 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(original.Content, 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(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<RunStartedEvent>(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<RunFinishedEvent>(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<RunErrorEvent>(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<TextMessageStartEvent>(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<TextMessageContentEvent>(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<TextMessageEndEvent>(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<BaseEvent> 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<RunStartedEvent>(events[0]);
Assert.IsType<RunFinishedEvent>(events[1]);
Assert.IsType<RunErrorEvent>(events[2]);
Assert.IsType<TextMessageStartEvent>(events[3]);
Assert.IsType<TextMessageContentEvent>(events[4]);
Assert.IsType<TextMessageEndEvent>(events[5]);
}
[Fact]
public void AGUIAgentThreadState_Serializes_WithThreadIdAndWrappedState()
{
// Arrange
AGUIAgentThread.AGUIAgentThreadState state = new()
{
ThreadId = "thread1",
WrappedState = JsonSerializer.SerializeToElement(new { test = "data" })
};
// Act
string json = JsonSerializer.Serialize(state, AGUIJsonSerializerContext.Default.AGUIAgentThreadState);
JsonElement jsonElement = JsonSerializer.Deserialize<JsonElement>(json);
// Assert
Assert.True(jsonElement.TryGetProperty("ThreadId", out JsonElement threadIdProp));
Assert.Equal("thread1", threadIdProp.GetString());
Assert.True(jsonElement.TryGetProperty("WrappedState", out JsonElement wrappedStateProp));
Assert.NotEqual(JsonValueKind.Null, wrappedStateProp.ValueKind);
}
[Fact]
public void AGUIAgentThreadState_Deserializes_FromJsonCorrectly()
{
// Arrange
const string Json = """
{
"ThreadId": "thread1",
"WrappedState": {"test": "data"}
}
""";
// Act
AGUIAgentThread.AGUIAgentThreadState? state = JsonSerializer.Deserialize(
Json,
AGUIJsonSerializerContext.Default.AGUIAgentThreadState);
// Assert
Assert.NotNull(state);
Assert.Equal("thread1", state.ThreadId);
Assert.NotEqual(JsonValueKind.Undefined, state.WrappedState.ValueKind);
}
[Fact]
public void AGUIAgentThreadState_RoundTrip_PreservesThreadIdAndNestedState()
{
// Arrange
AGUIAgentThread.AGUIAgentThreadState original = new()
{
ThreadId = "thread123",
WrappedState = JsonSerializer.SerializeToElement(new { key1 = "value1", key2 = 42 })
};
// Act
string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.AGUIAgentThreadState);
AGUIAgentThread.AGUIAgentThreadState? deserialized = JsonSerializer.Deserialize(
json,
AGUIJsonSerializerContext.Default.AGUIAgentThreadState);
// Assert
Assert.NotNull(deserialized);
Assert.Equal(original.ThreadId, deserialized.ThreadId);
Assert.Equal(original.WrappedState.GetProperty("key1").GetString(),
deserialized.WrappedState.GetProperty("key1").GetString());
Assert.Equal(original.WrappedState.GetProperty("key2").GetInt32(),
deserialized.WrappedState.GetProperty("key2").GetInt32());
}
}
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
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 AgentRunResponseUpdateAGUIExtensionsTests
{
[Fact]
public async Task AsAgentRunResponseUpdatesAsync_ConvertsRunStartedEvent_ToResponseUpdateWithMetadataAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in events.ToAsyncEnumerableAsync().AsAgentRunResponseUpdatesAsync())
{
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);
// ConversationId is stored in the underlying ChatResponseUpdate
ChatResponseUpdate chatUpdate = Assert.IsType<ChatResponseUpdate>(updates[0].RawRepresentation);
Assert.Equal("thread1", chatUpdate.ConversationId);
}
[Fact]
public async Task AsAgentRunResponseUpdatesAsync_ConvertsRunFinishedEvent_ToResponseUpdateWithMetadataAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", Result = JsonSerializer.SerializeToElement("Success") }
];
// Act
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in events.ToAsyncEnumerableAsync().AsAgentRunResponseUpdatesAsync())
{
updates.Add(update);
}
// Assert
Assert.Equal(2, updates.Count);
// First update is RunStarted
Assert.Equal(ChatRole.Assistant, updates[0].Role);
Assert.Equal("run1", updates[0].ResponseId);
// Second update is RunFinished
Assert.Equal(ChatRole.Assistant, updates[1].Role);
Assert.Equal("run1", updates[1].ResponseId);
Assert.NotNull(updates[1].CreatedAt);
TextContent content = Assert.IsType<TextContent>(updates[1].Contents[0]);
Assert.Equal("\"Success\"", content.Text); // JSON string representation includes quotes
// ConversationId is stored in the underlying ChatResponseUpdate
ChatResponseUpdate chatUpdate = Assert.IsType<ChatResponseUpdate>(updates[1].RawRepresentation);
Assert.Equal("thread1", chatUpdate.ConversationId);
}
[Fact]
public async Task AsAgentRunResponseUpdatesAsync_ConvertsRunErrorEvent_ToErrorContentAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunErrorEvent { Message = "Error occurred", Code = "ERR001" }
];
// Act
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in events.ToAsyncEnumerableAsync().AsAgentRunResponseUpdatesAsync())
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
Assert.Equal(ChatRole.Assistant, updates[0].Role);
ErrorContent content = Assert.IsType<ErrorContent>(updates[0].Contents[0]);
Assert.Equal("Error occurred", content.Message);
// Code is stored in ErrorCode property
Assert.Equal("ERR001", content.ErrorCode);
}
[Fact]
public async Task AsAgentRunResponseUpdatesAsync_ConvertsTextMessageSequence_ToTextUpdatesWithCorrectRoleAsync()
{
// Arrange
List<BaseEvent> events =
[
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new TextMessageContentEvent { MessageId = "msg1", Delta = " World" },
new TextMessageEndEvent { MessageId = "msg1" }
];
// Act
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in events.ToAsyncEnumerableAsync().AsAgentRunResponseUpdatesAsync())
{
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 AsAgentRunResponseUpdatesAsync_WithTextMessageStartWhileMessageInProgress_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.User }
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsAgentRunResponseUpdatesAsync())
{
// Intentionally empty - consuming stream to trigger exception
}
});
}
[Fact]
public async Task AsAgentRunResponseUpdatesAsync_WithTextMessageEndForWrongMessageId_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new TextMessageEndEvent { MessageId = "msg2" }
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsAgentRunResponseUpdatesAsync())
{
// Intentionally empty - consuming stream to trigger exception
}
});
}
[Fact]
public async Task AsAgentRunResponseUpdatesAsync_MaintainsMessageContext_AcrossMultipleContentEventsAsync()
{
// Arrange
List<BaseEvent> events =
[
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new TextMessageContentEvent { MessageId = "msg1", Delta = " " },
new TextMessageContentEvent { MessageId = "msg1", Delta = "World" },
new TextMessageEndEvent { MessageId = "msg1" }
];
// Act
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in events.ToAsyncEnumerableAsync().AsAgentRunResponseUpdatesAsync())
{
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));
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Text.Json" />
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.AGUI.UnitTests;
internal static class TestHelpers
{
/// <summary>
/// Extension method to convert a synchronous enumerable to an async enumerable for testing purposes.
/// </summary>
public static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(this IEnumerable<T> source)
{
foreach (T item in source)
{
yield return item;
await Task.CompletedTask;
}
}
}
@@ -0,0 +1,465 @@
// 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.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.AGUI;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests;
public sealed class BasicStreamingTests : IAsyncDisposable
{
private WebApplication? _app;
private HttpClient? _client;
[Fact]
public async Task ClientReceivesStreamedAssistantMessageAsync()
{
// Arrange
await this.SetupTestServerAsync();
AGUIAgent agent = new("assistant", "Sample assistant", this._client!, "");
AgentThread thread = agent.GetNewThread();
ChatMessage userMessage = new(ChatRole.User, "hello");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
InMemoryAgentThread? inMemoryThread = thread.GetService<InMemoryAgentThread>();
inMemoryThread.Should().NotBeNull();
inMemoryThread!.MessageStore.Should().HaveCount(2);
inMemoryThread.MessageStore[0].Role.Should().Be(ChatRole.User);
inMemoryThread.MessageStore[0].Text.Should().Be("hello");
inMemoryThread.MessageStore[1].Role.Should().Be(ChatRole.Assistant);
inMemoryThread.MessageStore[1].Text.Should().Be("Hello from fake agent!");
updates.Should().NotBeEmpty();
updates.Should().AllSatisfy(u => u.Role.Should().Be(ChatRole.Assistant));
}
[Fact]
public async Task ClientReceivesRunLifecycleEventsAsync()
{
// Arrange
await this.SetupTestServerAsync();
AGUIAgent agent = new("assistant", "Sample assistant", this._client!, "");
AgentThread thread = agent.GetNewThread();
ChatMessage userMessage = new(ChatRole.User, "test");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert - RunStarted should be the first update
updates.Should().NotBeEmpty();
updates[0].ResponseId.Should().NotBeNullOrEmpty();
ChatResponseUpdate firstUpdate = updates[0].AsChatResponseUpdate();
string? threadId = firstUpdate.ConversationId;
string? runId = updates[0].ResponseId;
threadId.Should().NotBeNullOrEmpty();
runId.Should().NotBeNullOrEmpty();
// Should have received text updates
updates.Should().Contain(u => !string.IsNullOrEmpty(u.Text));
// All text content updates should have the same message ID
List<AgentRunResponseUpdate> textUpdates = updates.Where(u => !string.IsNullOrEmpty(u.Text)).ToList();
textUpdates.Should().NotBeEmpty();
string? firstMessageId = textUpdates.FirstOrDefault()?.MessageId;
firstMessageId.Should().NotBeNullOrEmpty();
textUpdates.Should().AllSatisfy(u => u.MessageId.Should().Be(firstMessageId));
// RunFinished should be the last update
AgentRunResponseUpdate lastUpdate = updates[^1];
lastUpdate.ResponseId.Should().Be(runId);
ChatResponseUpdate lastChatUpdate = lastUpdate.AsChatResponseUpdate();
lastChatUpdate.ConversationId.Should().Be(threadId);
}
[Fact]
public async Task RunAsyncAggregatesStreamingUpdatesAsync()
{
// Arrange
await this.SetupTestServerAsync();
AGUIAgent agent = new("assistant", "Sample assistant", this._client!, "");
AgentThread thread = agent.GetNewThread();
ChatMessage userMessage = new(ChatRole.User, "hello");
// Act
AgentRunResponse response = await agent.RunAsync([userMessage], thread, new AgentRunOptions(), CancellationToken.None);
// Assert
response.Messages.Should().NotBeEmpty();
response.Messages.Should().Contain(m => m.Role == ChatRole.Assistant);
response.Messages.Should().Contain(m => m.Text == "Hello from fake agent!");
}
[Fact]
public async Task MultiTurnConversationPreservesAllMessagesInThreadAsync()
{
// Arrange
await this.SetupTestServerAsync();
AGUIAgent agent = new("assistant", "Sample assistant", this._client!, "");
AgentThread thread = agent.GetNewThread();
ChatMessage firstUserMessage = new(ChatRole.User, "First question");
// Act - First turn
List<AgentRunResponseUpdate> firstTurnUpdates = [];
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([firstUserMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
firstTurnUpdates.Add(update);
}
// Assert first turn completed
firstTurnUpdates.Should().Contain(u => !string.IsNullOrEmpty(u.Text));
// Act - Second turn with another message
ChatMessage secondUserMessage = new(ChatRole.User, "Second question");
List<AgentRunResponseUpdate> secondTurnUpdates = [];
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
secondTurnUpdates.Add(update);
}
// Assert second turn completed
secondTurnUpdates.Should().Contain(u => !string.IsNullOrEmpty(u.Text));
// Assert - Thread should contain all 4 messages (2 user + 2 assistant)
InMemoryAgentThread? inMemoryThread = thread.GetService<InMemoryAgentThread>();
inMemoryThread.Should().NotBeNull();
inMemoryThread!.MessageStore.Should().HaveCount(4);
// Verify message order and content
inMemoryThread.MessageStore[0].Role.Should().Be(ChatRole.User);
inMemoryThread.MessageStore[0].Text.Should().Be("First question");
inMemoryThread.MessageStore[1].Role.Should().Be(ChatRole.Assistant);
inMemoryThread.MessageStore[1].Text.Should().Be("Hello from fake agent!");
inMemoryThread.MessageStore[2].Role.Should().Be(ChatRole.User);
inMemoryThread.MessageStore[2].Text.Should().Be("Second question");
inMemoryThread.MessageStore[3].Role.Should().Be(ChatRole.Assistant);
inMemoryThread.MessageStore[3].Text.Should().Be("Hello from fake agent!");
}
[Fact]
public async Task AgentSendsMultipleMessagesInOneTurnAsync()
{
// Arrange
await this.SetupTestServerAsync(useMultiMessageAgent: true);
AGUIAgent agent = new("assistant", "Sample assistant", this._client!, "");
AgentThread thread = agent.GetNewThread();
ChatMessage userMessage = new(ChatRole.User, "Tell me a story");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert - Should have received text updates with different message IDs
List<AgentRunResponseUpdate> textUpdates = updates.Where(u => !string.IsNullOrEmpty(u.Text)).ToList();
textUpdates.Should().NotBeEmpty();
// Extract unique message IDs
List<string> messageIds = textUpdates.Select(u => u.MessageId).Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList()!;
messageIds.Should().HaveCountGreaterThan(1, "agent should send multiple messages");
// Verify thread contains user message plus multiple assistant messages
InMemoryAgentThread? inMemoryThread = thread.GetService<InMemoryAgentThread>();
inMemoryThread.Should().NotBeNull();
inMemoryThread!.MessageStore.Should().HaveCountGreaterThan(2);
inMemoryThread.MessageStore[0].Role.Should().Be(ChatRole.User);
inMemoryThread.MessageStore.Skip(1).Should().AllSatisfy(m => m.Role.Should().Be(ChatRole.Assistant));
}
[Fact]
public async Task UserSendsMultipleMessagesAtOnceAsync()
{
// Arrange
await this.SetupTestServerAsync();
AGUIAgent agent = new("assistant", "Sample assistant", this._client!, "");
AgentThread thread = agent.GetNewThread();
// Multiple user messages sent in one turn
ChatMessage[] userMessages =
[
new ChatMessage(ChatRole.User, "First part of question"),
new ChatMessage(ChatRole.User, "Second part of question"),
new ChatMessage(ChatRole.User, "Third part of question")
];
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(userMessages, thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert - Should have received assistant response
updates.Should().Contain(u => !string.IsNullOrEmpty(u.Text));
// Verify thread contains all user messages plus assistant response
InMemoryAgentThread? inMemoryThread = thread.GetService<InMemoryAgentThread>();
inMemoryThread.Should().NotBeNull();
inMemoryThread!.MessageStore.Should().HaveCount(4); // 3 user + 1 assistant
inMemoryThread.MessageStore[0].Role.Should().Be(ChatRole.User);
inMemoryThread.MessageStore[0].Text.Should().Be("First part of question");
inMemoryThread.MessageStore[1].Role.Should().Be(ChatRole.User);
inMemoryThread.MessageStore[1].Text.Should().Be("Second part of question");
inMemoryThread.MessageStore[2].Role.Should().Be(ChatRole.User);
inMemoryThread.MessageStore[2].Text.Should().Be("Third part of question");
inMemoryThread.MessageStore[3].Role.Should().Be(ChatRole.Assistant);
inMemoryThread.MessageStore[3].Text.Should().Be("Hello from fake agent!");
}
private async Task SetupTestServerAsync(bool useMultiMessageAgent = false)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
if (useMultiMessageAgent)
{
builder.Services.AddSingleton<FakeMultiMessageAgent>();
}
else
{
builder.Services.AddSingleton<FakeChatClientAgent>();
}
this._app = builder.Build();
AIAgent agent = useMultiMessageAgent
? this._app.Services.GetRequiredService<FakeMultiMessageAgent>()
: this._app.Services.GetRequiredService<FakeChatClientAgent>();
this._app.MapAGUI("/agent", agent);
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
this._client = testServer.CreateClient();
this._client.BaseAddress = new Uri("http://localhost/agent");
}
public async ValueTask DisposeAsync()
{
this._client?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
}
}
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
internal sealed class FakeChatClientAgent : AIAgent
{
private readonly string _agentId;
private readonly string _description;
public FakeChatClientAgent()
{
this._agentId = "fake-agent";
this._description = "A fake agent for testing";
}
public override string Id => this._agentId;
public override string? Description => this._description;
public override AgentThread GetNewThread()
{
return new FakeInMemoryAgentThread();
}
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
}
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in this.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
}
return updates.ToAgentRunResponse();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string messageId = Guid.NewGuid().ToString("N");
// Simulate streaming a deterministic response
foreach (string chunk in new[] { "Hello", " ", "from", " ", "fake", " ", "agent", "!" })
{
yield return new AgentRunResponseUpdate
{
MessageId = messageId,
Role = ChatRole.Assistant,
Contents = [new TextContent(chunk)]
};
await Task.Yield();
}
}
private sealed class FakeInMemoryAgentThread : InMemoryAgentThread
{
public FakeInMemoryAgentThread()
: base()
{
}
public FakeInMemoryAgentThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThread, jsonSerializerOptions)
{
}
}
}
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
internal sealed class FakeMultiMessageAgent : AIAgent
{
private readonly string _agentId;
private readonly string _description;
public FakeMultiMessageAgent()
{
this._agentId = "fake-multi-message-agent";
this._description = "A fake agent that sends multiple messages for testing";
}
public override string Id => this._agentId;
public override string? Description => this._description;
public override AgentThread GetNewThread()
{
return new FakeInMemoryAgentThread();
}
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
}
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
List<AgentRunResponseUpdate> updates = [];
await foreach (AgentRunResponseUpdate update in this.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
}
return updates.ToAgentRunResponse();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Simulate sending first message
string messageId1 = Guid.NewGuid().ToString("N");
foreach (string chunk in new[] { "First", " ", "message" })
{
yield return new AgentRunResponseUpdate
{
MessageId = messageId1,
Role = ChatRole.Assistant,
Contents = [new TextContent(chunk)]
};
await Task.Yield();
}
// Simulate sending second message
string messageId2 = Guid.NewGuid().ToString("N");
foreach (string chunk in new[] { "Second", " ", "message" })
{
yield return new AgentRunResponseUpdate
{
MessageId = messageId2,
Role = ChatRole.Assistant,
Contents = [new TextContent(chunk)]
};
await Task.Yield();
}
// Simulate sending third message
string messageId3 = Guid.NewGuid().ToString("N");
foreach (string chunk in new[] { "Third", " ", "message" })
{
yield return new AgentRunResponseUpdate
{
MessageId = messageId3,
Role = ChatRole.Assistant,
Contents = [new TextContent(chunk)]
};
await Task.Yield();
}
}
private sealed class FakeInMemoryAgentThread : InMemoryAgentThread
{
public FakeInMemoryAgentThread()
: base()
{
}
public FakeInMemoryAgentThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThread, jsonSerializerOptions)
{
}
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<PropertyGroup>
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
<InjectSharedBuildTestCode>true</InjectSharedBuildTestCode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" VersionOverride="8.0.21" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' != 'net8.0'" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Text.Json" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,277 @@
// 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.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.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AGUIEndpointRouteBuilderExtensions"/> class.
/// </summary>
public sealed class AGUIEndpointRouteBuilderExtensionsTests
{
[Fact]
public void MapAGUIAgent_MapsEndpoint_AtSpecifiedPattern()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);
const string Pattern = "/api/agent";
AIAgent agent = new TestAgent();
// Act
IEndpointConventionBuilder? result = AGUIEndpointRouteBuilderExtensions.MapAGUI(endpointsMock.Object, Pattern, agent);
// Assert
Assert.NotNull(result);
}
[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<ChatMessage>? capturedMessages = null;
IEnumerable<KeyValuePair<string, string>>? capturedContext = null;
AIAgent factory(IEnumerable<ChatMessage> messages, IEnumerable<AITool> tools, IEnumerable<KeyValuePair<string, string>> context, JsonElement props)
{
capturedMessages = messages.ToList();
capturedContext = context;
return new TestAgent();
}
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }],
Context = new Dictionary<string, string> { ["key1"] = "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 AGUIMessage { Id = "m1", Role = AGUIRoles.User, 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 AGUIMessage { Id = "m1", Role = AGUIRoles.User, 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<OperationCanceledException>(() => handler(httpContext));
}
[Fact]
public async Task MapAGUIAgent_ConvertsInputMessages_ToChatMessagesBeforeFactoryAsync()
{
// Arrange
List<ChatMessage>? capturedMessages = null;
AIAgent factory(IEnumerable<ChatMessage> messages, IEnumerable<AITool> tools, IEnumerable<KeyValuePair<string, string>> context, JsonElement props)
{
capturedMessages = messages.ToList();
return new TestAgent();
}
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages =
[
new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "First" },
new AGUIMessage { Id = "m2", Role = AGUIRoles.Assistant, 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);
}
private RequestDelegate CreateRequestDelegate(
Func<IEnumerable<ChatMessage>, IEnumerable<AITool>, IEnumerable<KeyValuePair<string, string>>, 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<ChatMessage> messages = input.Messages.AsChatMessages();
IEnumerable<KeyValuePair<string, string>> contextValues = input.Context;
JsonElement forwardedProps = input.ForwardedProperties;
AIAgent agent = factory(messages, [], contextValues, forwardedProps);
IAsyncEnumerable<BaseEvent> events = agent.RunStreamingAsync(
messages,
cancellationToken: cancellationToken)
.AsAGUIEventStreamAsync(
input.ThreadId,
input.RunId,
cancellationToken);
ILogger<AGUIServerSentEventsResult> logger = NullLogger<AGUIServerSentEventsResult>.Instance;
await new AGUIServerSentEventsResult(events, logger).ExecuteAsync(context).ConfigureAwait(false);
};
}
private sealed class TestInMemoryAgentThread : InMemoryAgentThread
{
public TestInMemoryAgentThread()
: base()
{
}
public TestInMemoryAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThreadState, jsonSerializerOptions, null)
{
}
}
private sealed class TestAgent : AIAgent
{
public override string Id => "test-agent";
public override string? Description => "Test agent";
public override AgentThread GetNewThread() => new TestInMemoryAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions);
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response"));
}
}
}
@@ -0,0 +1,152 @@
// Copyright (c) Microsoft. All rights reserved.
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 Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AGUIServerSentEventsResult"/> class.
/// </summary>
public sealed class AGUIServerSentEventsResultTests
{
[Fact]
public async Task ExecuteAsync_SetsCorrectResponseHeaders_ContentTypeAndCacheControlAsync()
{
// Arrange
List<BaseEvent> events = [];
ILogger<AGUIServerSentEventsResult> logger = NullLogger<AGUIServerSentEventsResult>.Instance;
AGUIServerSentEventsResult result = new(events.ToAsyncEnumerableAsync(), logger);
DefaultHttpContext httpContext = new();
httpContext.Response.Body = new MemoryStream();
// Act
await result.ExecuteAsync(httpContext);
// Assert
Assert.Equal("text/event-stream", httpContext.Response.ContentType);
Assert.Equal("no-cache,no-store", httpContext.Response.Headers.CacheControl.ToString());
Assert.Equal("no-cache", httpContext.Response.Headers.Pragma.ToString());
}
[Fact]
public async Task ExecuteAsync_SerializesEventsInSSEFormat_WithDataPrefixAndNewlinesAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
ILogger<AGUIServerSentEventsResult> logger = NullLogger<AGUIServerSentEventsResult>.Instance;
AGUIServerSentEventsResult result = new(events.ToAsyncEnumerableAsync(), logger);
DefaultHttpContext httpContext = new();
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
// Act
await result.ExecuteAsync(httpContext);
// Assert
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
Assert.Contains("data: ", responseContent);
Assert.Contains("\n\n", responseContent);
string[] eventStrings = responseContent.Split("\n\n", StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, eventStrings.Length);
}
[Fact]
public async Task ExecuteAsync_FlushesResponse_AfterEachEventAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
ILogger<AGUIServerSentEventsResult> logger = NullLogger<AGUIServerSentEventsResult>.Instance;
AGUIServerSentEventsResult result = new(events.ToAsyncEnumerableAsync(), logger);
DefaultHttpContext httpContext = new();
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
// Act
await result.ExecuteAsync(httpContext);
// Assert
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
string[] eventStrings = responseContent.Split("\n\n", StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(3, eventStrings.Length);
}
[Fact]
public async Task ExecuteAsync_WithEmptyEventStream_CompletesSuccessfullyAsync()
{
// Arrange
List<BaseEvent> events = [];
ILogger<AGUIServerSentEventsResult> logger = NullLogger<AGUIServerSentEventsResult>.Instance;
AGUIServerSentEventsResult result = new(events.ToAsyncEnumerableAsync(), logger);
DefaultHttpContext httpContext = new();
httpContext.Response.Body = new MemoryStream();
// Act
await result.ExecuteAsync(httpContext);
// Assert
Assert.Equal(StatusCodes.Status200OK, result.StatusCode);
}
[Fact]
public async Task ExecuteAsync_RespectsCancellationToken_WhenCancelledAsync()
{
// Arrange
using CancellationTokenSource cts = new();
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" }
];
async IAsyncEnumerable<BaseEvent> GetEventsWithCancellationAsync()
{
foreach (BaseEvent evt in events)
{
yield return evt;
await Task.Delay(10);
}
}
ILogger<AGUIServerSentEventsResult> logger = NullLogger<AGUIServerSentEventsResult>.Instance;
AGUIServerSentEventsResult result = new(GetEventsWithCancellationAsync(), logger);
DefaultHttpContext httpContext = new();
httpContext.Response.Body = new MemoryStream();
httpContext.RequestAborted = cts.Token;
// Act
cts.Cancel();
// Assert
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => result.ExecuteAsync(httpContext));
}
[Fact]
public async Task ExecuteAsync_WithNullHttpContext_ThrowsArgumentNullExceptionAsync()
{
// Arrange
List<BaseEvent> events = [];
ILogger<AGUIServerSentEventsResult> logger = NullLogger<AGUIServerSentEventsResult>.Instance;
AGUIServerSentEventsResult result = new(events.ToAsyncEnumerableAsync(), logger);
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => result.ExecuteAsync(null!));
}
}
@@ -0,0 +1,165 @@
// 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 AgentRunResponseUpdateAGUIExtensionsTests
{
[Fact]
public async Task AsAGUIEventStreamAsync_YieldsRunStartedEvent_AtBeginningWithCorrectIdsAsync()
{
// Arrange
const string ThreadId = "thread1";
const string RunId = "run1";
List<AgentRunResponseUpdate> updates = [];
// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, CancellationToken.None))
{
events.Add(evt);
}
// Assert
Assert.NotEmpty(events);
RunStartedEvent startEvent = Assert.IsType<RunStartedEvent>(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<AgentRunResponseUpdate> updates = [];
// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, CancellationToken.None))
{
events.Add(evt);
}
// Assert
Assert.NotEmpty(events);
RunFinishedEvent finishEvent = Assert.IsType<RunFinishedEvent>(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<AgentRunResponseUpdate> updates =
[
new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg1" }),
new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " World") { MessageId = "msg1" })
];
// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, 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<AgentRunResponseUpdate> updates =
[
new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = MessageId }),
new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " ") { MessageId = MessageId }),
new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "World") { MessageId = MessageId })
];
// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, CancellationToken.None))
{
events.Add(evt);
}
// Assert
List<TextMessageStartEvent> startEvents = events.OfType<TextMessageStartEvent>().ToList();
List<TextMessageEndEvent> endEvents = events.OfType<TextMessageEndEvent>().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<AgentRunResponseUpdate> updates =
[
new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg1" }),
new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.User, "Hi") { MessageId = "msg2" })
];
// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, CancellationToken.None))
{
events.Add(evt);
}
// Assert
List<TextMessageStartEvent> startEvents = events.OfType<TextMessageStartEvent>().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<AgentRunResponseUpdate> updates =
[
new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "First") { MessageId = "msg1" }),
new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Second") { MessageId = "msg2" })
];
// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, CancellationToken.None))
{
events.Add(evt);
}
// Assert
List<TextMessageEndEvent> endEvents = events.OfType<TextMessageEndEvent>().ToList();
Assert.NotEmpty(endEvents);
Assert.Contains(endEvents, e => e.MessageId == "msg1");
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Text.Json" />
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
internal static class TestHelpers
{
/// <summary>
/// Extension method to convert a synchronous enumerable to an async enumerable for testing purposes.
/// </summary>
public static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(this IEnumerable<T> source)
{
foreach (T item in source)
{
yield return item;
await Task.CompletedTask;
}
}
}
@@ -8,8 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" VersionOverride="8.0.21" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Condition="'$(TargetFramework)' != 'net8.0'" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" VersionOverride="8.0.21" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' != 'net8.0'" />
<PackageReference Include="OpenAI" />
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />