mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-python-foundry-agents
This commit is contained in:
@@ -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
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
+43
@@ -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());
|
||||
}
|
||||
}
|
||||
+191
@@ -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));
|
||||
}
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+465
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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>
|
||||
+277
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -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!));
|
||||
}
|
||||
}
|
||||
+165
@@ -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");
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user