.NET [AG-UI]: Adds support for shared state. (#1996)

* Product changes

* Tests

* Dojo project

* Cleanups
This commit is contained in:
Javier Calvarro Nelson
2025-11-10 10:50:11 +01:00
committed by GitHub
Unverified
parent 0e7183dbd8
commit 45dc0ff073
25 changed files with 2105 additions and 15 deletions
+1
View File
@@ -20,6 +20,7 @@
</Folder>
<Folder Name="/Samples/AGUIClientServer/">
<Project Path="samples/AGUIClientServer/AGUIClient/AGUIClient.csproj" />
<Project Path="samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
@@ -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>b9c3f1e1-2fb4-5g29-0e52-53e2b7g9gf21</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,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
[JsonSerializable(typeof(WeatherInfo))]
[JsonSerializable(typeof(Recipe))]
[JsonSerializable(typeof(Ingredient))]
[JsonSerializable(typeof(RecipeResponse))]
internal sealed partial class AGUIDojoServerSerializerContext : JsonSerializerContext;
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ChatClient = OpenAI.Chat.ChatClient;
namespace AGUIDojoServer;
internal static class ChatClientAgentFactory
{
private static AzureOpenAIClient? s_azureOpenAIClient;
private static string? s_deploymentName;
public static void Initialize(IConfiguration configuration)
{
string endpoint = configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
s_deploymentName = configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
s_azureOpenAIClient = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential());
}
public static ChatClientAgent CreateAgenticChat()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "AgenticChat",
description: "A simple chat agent using Azure OpenAI");
}
public static ChatClientAgent CreateBackendToolRendering()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "BackendToolRenderer",
description: "An agent that can render backend tools using Azure OpenAI",
tools: [AIFunctionFactory.Create(
GetWeather,
name: "get_weather",
description: "Get the weather for a given location.",
AGUIDojoServerSerializerContext.Default.Options)]);
}
public static ChatClientAgent CreateHumanInTheLoop()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "HumanInTheLoopAgent",
description: "An agent that involves human feedback in its decision-making process using Azure OpenAI");
}
public static ChatClientAgent CreateToolBasedGenerativeUI()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "ToolBasedGenerativeUIAgent",
description: "An agent that uses tools to generate user interfaces using Azure OpenAI");
}
public static ChatClientAgent CreateAgenticUI()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "AgenticUIAgent",
description: "An agent that generates agentic user interfaces using Azure OpenAI");
}
public static AIAgent CreateSharedState(JsonSerializerOptions options)
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsIChatClient().CreateAIAgent(
name: "SharedStateAgent",
description: "An agent that demonstrates shared state patterns using Azure OpenAI");
return new SharedStateAgent(baseAgent, options);
}
[Description("Get the weather for a given location.")]
private static WeatherInfo GetWeather([Description("The location to get the weather for.")] string location) => new()
{
Temperature = 20,
Conditions = "sunny",
Humidity = 50,
WindSpeed = 10,
FeelsLike = 25
};
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
internal sealed class Ingredient
{
[JsonPropertyName("icon")]
public string Icon { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("amount")]
public string Amount { get; set; } = string.Empty;
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using AGUIDojoServer;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.Extensions.Options;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpLogging(logging =>
{
logging.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders | HttpLoggingFields.RequestBody
| HttpLoggingFields.ResponsePropertiesAndHeaders | HttpLoggingFields.ResponseBody;
logging.RequestBodyLogLimit = int.MaxValue;
logging.ResponseBodyLogLimit = int.MaxValue;
});
builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
builder.Services.AddAGUI();
WebApplication app = builder.Build();
app.UseHttpLogging();
// Initialize the factory
ChatClientAgentFactory.Initialize(app.Configuration);
// Map the AG-UI agent endpoints for different scenarios
app.MapAGUI("/agentic_chat", ChatClientAgentFactory.CreateAgenticChat());
app.MapAGUI("/backend_tool_rendering", ChatClientAgentFactory.CreateBackendToolRendering());
app.MapAGUI("/human_in_the_loop", ChatClientAgentFactory.CreateHumanInTheLoop());
app.MapAGUI("/tool_based_generative_ui", ChatClientAgentFactory.CreateToolBasedGenerativeUI());
app.MapAGUI("/agentic_generative_ui", ChatClientAgentFactory.CreateAgenticUI());
var jsonOptions = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
app.MapAGUI("/shared_state", ChatClientAgentFactory.CreateSharedState(jsonOptions.Value.SerializerOptions));
await app.RunAsync();
public partial class Program { }
@@ -0,0 +1,12 @@
{
"profiles": {
"AGUIDojoServer": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5018"
}
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
internal sealed class Recipe
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("skill_level")]
public string SkillLevel { get; set; } = string.Empty;
[JsonPropertyName("cooking_time")]
public string CookingTime { get; set; } = string.Empty;
[JsonPropertyName("special_preferences")]
public List<string> SpecialPreferences { get; set; } = [];
[JsonPropertyName("ingredients")]
public List<Ingredient> Ingredients { get; set; } = [];
[JsonPropertyName("instructions")]
public List<string> Instructions { get; set; } = [];
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
#pragma warning disable CA1812 // Used for the JsonSchema response format
internal sealed class RecipeResponse
#pragma warning restore CA1812
{
[JsonPropertyName("recipe")]
public Recipe Recipe { get; set; } = new();
}
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AGUIDojoServer;
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ChatClientAgentFactory.CreateSharedState")]
internal sealed class SharedStateAgent : DelegatingAIAgent
{
private readonly JsonSerializerOptions _jsonSerializerOptions;
public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
: base(innerAgent)
{
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions ||
!properties.TryGetValue("ag_ui_state", out JsonElement state))
{
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
yield break;
}
var firstRunOptions = new ChatClientAgentRunOptions
{
ChatOptions = chatRunOptions.ChatOptions.Clone(),
AllowBackgroundResponses = chatRunOptions.AllowBackgroundResponses,
ContinuationToken = chatRunOptions.ContinuationToken,
ChatClientFactory = chatRunOptions.ChatClientFactory,
};
// Configure JSON schema response format for structured state output
firstRunOptions.ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<RecipeResponse>(
schemaName: "RecipeResponse",
schemaDescription: "A response containing a recipe with title, skill level, cooking time, preferences, ingredients, and instructions");
ChatMessage stateUpdateMessage = new(
ChatRole.System,
[
new TextContent("Here is the current state in JSON format:"),
new TextContent(state.GetRawText()),
new TextContent("The new state is:")
]);
var firstRunMessages = messages.Append(stateUpdateMessage);
var allUpdates = new List<AgentRunResponseUpdate>();
await foreach (var update in this.InnerAgent.RunStreamingAsync(firstRunMessages, thread, firstRunOptions, cancellationToken).ConfigureAwait(false))
{
allUpdates.Add(update);
// Yield all non-text updates (tool calls, etc.)
bool hasNonTextContent = update.Contents.Any(c => c is not TextContent);
if (hasNonTextContent)
{
yield return update;
}
}
var response = allUpdates.ToAgentRunResponse();
if (response.TryDeserialize(this._jsonSerializerOptions, out JsonElement stateSnapshot))
{
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot,
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
yield return new AgentRunResponseUpdate
{
Contents = [new DataContent(stateBytes, "application/json")]
};
}
else
{
yield break;
}
var secondRunMessages = messages.Concat(response.Messages).Append(
new ChatMessage(
ChatRole.System,
[new TextContent("Please provide a concise summary of the state changes in at most two sentences.")]));
await foreach (var update in this.InnerAgent.RunStreamingAsync(secondRunMessages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
internal sealed class WeatherInfo
{
[JsonPropertyName("temperature")]
public int Temperature { get; init; }
[JsonPropertyName("conditions")]
public string Conditions { get; init; } = string.Empty;
[JsonPropertyName("humidity")]
public int Humidity { get; init; }
[JsonPropertyName("wind_speed")]
public int WindSpeed { get; init; }
[JsonPropertyName("feelsLike")]
public int FeelsLike { get; init; }
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
}
}
}
@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
}
},
"AllowedHosts": "*"
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -152,6 +153,8 @@ public sealed class AGUIChatClient : DelegatingChatClient
private sealed class AGUIChatClientHandler : IChatClient
{
private static readonly MediaTypeHeaderValue s_json = new("application/json");
private readonly AGUIHttpService _httpService;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger _logger;
@@ -199,6 +202,9 @@ public sealed class AGUIChatClient : DelegatingChatClient
var threadId = ExtractTemporaryThreadId(messagesList) ??
ExtractThreadIdFromOptions(options) ?? $"thread_{Guid.NewGuid():N}";
// Extract state from the last message if it contains DataContent with application/json
JsonElement state = this.ExtractAndRemoveStateFromMessages(messagesList);
// Create the input for the AGUI service
var input = new RunAgentInput
{
@@ -207,6 +213,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
ThreadId = threadId,
RunId = runId,
Messages = messagesList.AsAGUIMessages(this._jsonSerializerOptions),
State = state,
};
// Add tools if provided
@@ -300,6 +307,51 @@ public sealed class AGUIChatClient : DelegatingChatClient
return threadId;
}
// Extract state from the last message's DataContent with application/json media type
// and remove that message from the list
private JsonElement ExtractAndRemoveStateFromMessages(List<ChatMessage> messagesList)
{
if (messagesList.Count == 0)
{
return default;
}
// Check the last message for state DataContent
ChatMessage lastMessage = messagesList[messagesList.Count - 1];
for (int i = 0; i < lastMessage.Contents.Count; i++)
{
if (lastMessage.Contents[i] is DataContent dataContent &&
MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) &&
mediaType.Equals(s_json))
{
// Deserialize the state JSON directly from UTF-8 bytes
try
{
JsonElement stateElement = (JsonElement)JsonSerializer.Deserialize(
dataContent.Data.Span,
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))!;
// Remove the DataContent from the message contents
lastMessage.Contents.RemoveAt(i);
// If no contents remain, remove the entire message
if (lastMessage.Contents.Count == 0)
{
messagesList.RemoveAt(messagesList.Count - 1);
}
return stateElement;
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Failed to deserialize state JSON from DataContent: {ex.Message}", ex);
}
}
}
return default;
}
public void Dispose()
{
// No resources to dispose
@@ -316,7 +368,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
}
}
private class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
private sealed class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
{
public FunctionCallContent FunctionCallContent { get; } = functionCall;
}
@@ -27,4 +27,8 @@ internal static class AGUIEventTypes
public const string ToolCallEnd = "TOOL_CALL_END";
public const string ToolCallResult = "TOOL_CALL_RESULT";
public const string StateSnapshot = "STATE_SNAPSHOT";
public const string StateDelta = "STATE_DELTA";
}
@@ -44,6 +44,8 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(ToolCallArgsEvent))]
[JsonSerializable(typeof(ToolCallEndEvent))]
[JsonSerializable(typeof(ToolCallResultEvent))]
[JsonSerializable(typeof(StateSnapshotEvent))]
[JsonSerializable(typeof(StateDeltaEvent))]
[JsonSerializable(typeof(IDictionary<string, object?>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(IDictionary<string, System.Text.Json.JsonElement?>))]
@@ -57,6 +59,6 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(float))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(decimal))]
internal partial class AGUIJsonSerializerContext : JsonSerializerContext
internal sealed partial class AGUIJsonSerializerContext : JsonSerializerContext
{
}
@@ -46,6 +46,7 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
AGUIEventTypes.ToolCallArgs => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallArgsEvent))) as ToolCallArgsEvent,
AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent,
AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent,
AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent,
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
};
@@ -95,8 +96,14 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
case ToolCallResultEvent toolCallResult:
JsonSerializer.Serialize(writer, toolCallResult, options.GetTypeInfo(typeof(ToolCallResultEvent)));
break;
case StateSnapshotEvent stateSnapshot:
JsonSerializer.Serialize(writer, stateSnapshot, options.GetTypeInfo(typeof(StateSnapshotEvent)));
break;
case StateDeltaEvent stateDelta:
JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent)));
break;
default:
throw new JsonException($"Unknown BaseEvent type: {value.GetType().Name}");
throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}");
}
}
}
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
@@ -18,6 +19,9 @@ namespace Microsoft.Agents.AI.AGUI.Shared;
internal static class ChatResponseUpdateAGUIExtensions
{
private static readonly MediaTypeHeaderValue? s_jsonPatchMediaType = new("application/json-patch+json");
private static readonly MediaTypeHeaderValue? s_json = new("application/json");
public static async IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
this IAsyncEnumerable<BaseEvent> events,
JsonSerializerOptions jsonSerializerOptions,
@@ -70,11 +74,73 @@ internal static class ChatResponseUpdateAGUIExtensions
case ToolCallResultEvent toolCallResult:
yield return toolCallAccumulator.EmitToolCallResult(toolCallResult, jsonSerializerOptions);
break;
// State snapshot events
case StateSnapshotEvent stateSnapshot:
if (stateSnapshot.Snapshot.HasValue)
{
yield return CreateStateSnapshotUpdate(stateSnapshot, conversationId, responseId, jsonSerializerOptions);
}
break;
case StateDeltaEvent stateDelta:
if (stateDelta.Delta.HasValue)
{
yield return CreateStateDeltaUpdate(stateDelta, conversationId, responseId, jsonSerializerOptions);
}
break;
}
}
}
private class TextMessageBuilder()
private static ChatResponseUpdate CreateStateSnapshotUpdate(
StateSnapshotEvent stateSnapshot,
string? conversationId,
string? responseId,
JsonSerializerOptions jsonSerializerOptions)
{
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot.Snapshot!.Value,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
DataContent dataContent = new(jsonBytes, "application/json");
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["is_state_snapshot"] = true
}
};
}
private static ChatResponseUpdate CreateStateDeltaUpdate(
StateDeltaEvent stateDelta,
string? conversationId,
string? responseId,
JsonSerializerOptions jsonSerializerOptions)
{
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
stateDelta.Delta!.Value,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["is_state_delta"] = true
}
};
}
private sealed class TextMessageBuilder()
{
private ChatRole _currentRole;
private string? _currentMessageId;
@@ -154,7 +220,7 @@ internal static class ChatResponseUpdateAGUIExtensions
};
}
private class ToolCallBuilder
private sealed class ToolCallBuilder
{
private string? _conversationId;
private string? _responseId;
@@ -348,6 +414,55 @@ internal static class ChatResponseUpdateAGUIExtensions
Role = AGUIRoles.Tool
};
}
else if (content is DataContent dataContent)
{
if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json))
{
// State snapshot event
yield return new StateSnapshotEvent
{
#if NET472 || NETSTANDARD2_0
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.ToArray(),
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#else
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.Span,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#endif
};
}
else if (mediaType is { } && mediaType.Equals(s_jsonPatchMediaType))
{
// State snapshot patch event must be a valid JSON patch,
// but its not up to us to validate that here.
yield return new StateDeltaEvent
{
#if NET472 || NETSTANDARD2_0
Delta = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.ToArray(),
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#else
Delta = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.Span,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#endif
};
}
else
{
// Text content event
yield return new TextMessageContentEvent
{
MessageId = chatResponse.MessageId!,
#if NET472 || NETSTANDARD2_0
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
#else
Delta = Encoding.UTF8.GetString(dataContent.Data.Span)
#endif
};
}
}
}
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class StateDeltaEvent : BaseEvent
{
public StateDeltaEvent()
{
this.Type = AGUIEventTypes.StateDelta;
}
[JsonPropertyName("delta")]
public JsonElement? Delta { get; set; }
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class StateSnapshotEvent : BaseEvent
{
public StateSnapshotEvent()
{
this.Type = AGUIEventTypes.StateSnapshot;
}
[JsonPropertyName("snapshot")]
public JsonElement? Snapshot { get; set; }
}
@@ -44,22 +44,27 @@ public static class AGUIEndpointRouteBuilderExtensions
var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
var messages = input.Messages.AsChatMessages(jsonSerializerOptions);
var agent = aiAgent;
var clientTools = input.Tools?.AsAITools().ToList();
ChatClientAgentRunOptions? runOptions = null;
List<AITool>? clientTools = input.Tools?.AsAITools().ToList();
if (clientTools?.Count > 0)
// Create run options with AG-UI context in AdditionalProperties
var runOptions = new ChatClientAgentRunOptions
{
runOptions = new ChatClientAgentRunOptions
ChatOptions = new ChatOptions
{
ChatOptions = new ChatOptions
Tools = clientTools,
AdditionalProperties = new AdditionalPropertiesDictionary
{
Tools = clientTools
["ag_ui_state"] = input.State,
["ag_ui_context"] = input.Context?.Select(c => new KeyValuePair<string, string>(c.Description, c.Value)).ToArray(),
["ag_ui_forwarded_properties"] = input.ForwardedProperties,
["ag_ui_thread_id"] = input.ThreadId,
["ag_ui_run_id"] = input.RunId
}
};
}
}
};
var events = agent.RunStreamingAsync(
// Run the agent and convert to AG-UI events
var events = aiAgent.RunStreamingAsync(
messages,
options: runOptions,
cancellationToken: cancellationToken)
@@ -1282,6 +1282,312 @@ public sealed class AGUIAgentTests
// AG-UI requirement: full history on every turn (which happens when ConversationId is null for FunctionInvokingChatClient)
Assert.True(captureHandler.RequestWasMade);
}
[Fact]
public async Task GetStreamingResponseAsync_ExtractsStateFromDataContent_AndRemovesStateMessageAsync()
{
// Arrange
var stateData = new { counter = 42, status = "active" };
string stateJson = JsonSerializer.Serialize(stateData);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
var dataContent = new DataContent(stateBytes, "application/json");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Response" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.System, [dataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.NotNull(captureHandler.CapturedState);
Assert.Equal(42, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32());
Assert.Equal("active", captureHandler.CapturedState.Value.GetProperty("status").GetString());
// Verify state message was removed - only user message should be in the request
Assert.Equal(1, captureHandler.CapturedMessageCount);
}
[Fact]
public async Task GetStreamingResponseAsync_WithNoStateDataContent_SendsEmptyStateAsync()
{
// Arrange
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Response" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.Null(captureHandler.CapturedState);
}
[Fact]
public async Task GetStreamingResponseAsync_WithMalformedStateJson_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
byte[] invalidJson = System.Text.Encoding.UTF8.GetBytes("{invalid json");
var dataContent = new DataContent(invalidJson, "application/json");
using HttpClient httpClient = this.CreateMockHttpClient([]);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.System, [dataContent])
];
// Act & Assert
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
});
Assert.Contains("Failed to deserialize state JSON", ex.Message);
}
[Fact]
public async Task GetStreamingResponseAsync_WithEmptyStateObject_SendsEmptyObjectAsync()
{
// Arrange
var emptyState = new { };
string stateJson = JsonSerializer.Serialize(emptyState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
var dataContent = new DataContent(stateBytes, "application/json");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.System, [dataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.NotNull(captureHandler.CapturedState);
Assert.Equal(JsonValueKind.Object, captureHandler.CapturedState.Value.ValueKind);
}
[Fact]
public async Task GetStreamingResponseAsync_OnlyProcessesDataContentFromLastMessage_IgnoresEarlierOnesAsync()
{
// Arrange
var oldState = new { counter = 10 };
string oldStateJson = JsonSerializer.Serialize(oldState);
byte[] oldStateBytes = System.Text.Encoding.UTF8.GetBytes(oldStateJson);
var oldDataContent = new DataContent(oldStateBytes, "application/json");
var newState = new { counter = 20 };
string newStateJson = JsonSerializer.Serialize(newState);
byte[] newStateBytes = System.Text.Encoding.UTF8.GetBytes(newStateJson);
var newDataContent = new DataContent(newStateBytes, "application/json");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "First message"),
new ChatMessage(ChatRole.System, [oldDataContent]),
new ChatMessage(ChatRole.User, "Second message"),
new ChatMessage(ChatRole.System, [newDataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.NotNull(captureHandler.CapturedState);
// Should use the new state from the last message
Assert.Equal(20, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32());
// Should have removed only the last state message
Assert.Equal(3, captureHandler.CapturedMessageCount);
}
[Fact]
public async Task GetStreamingResponseAsync_WithNonJsonMediaType_IgnoresDataContentAsync()
{
// Arrange
byte[] imageData = System.Text.Encoding.UTF8.GetBytes("fake image data");
var dataContent = new DataContent(imageData, "image/png");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, [new TextContent("Hello"), dataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.Null(captureHandler.CapturedState);
// Message should not be removed since it's not state
Assert.Equal(1, captureHandler.CapturedMessageCount);
}
[Fact]
public async Task GetStreamingResponseAsync_RoundTripState_PreservesJsonStructureAsync()
{
// Arrange - Server returns state snapshot
var returnedState = new { counter = 100, nested = new { value = "test" } };
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(returnedState);
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Done" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act - First turn: receive state
DataContent? receivedStateContent = null;
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
{
if (update.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"))
{
receivedStateContent = (DataContent)update.Contents.First(c => c is DataContent);
}
}
// Second turn: send the received state back
Assert.NotNull(receivedStateContent);
messages.Add(new ChatMessage(ChatRole.System, [receivedStateContent]));
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert - Verify the round-tripped state
Assert.NotNull(captureHandler.CapturedState);
Assert.Equal(100, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32());
Assert.Equal("test", captureHandler.CapturedState.Value.GetProperty("nested").GetProperty("value").GetString());
}
[Fact]
public async Task GetStreamingResponseAsync_ReceivesStateSnapshot_AsDataContentWithAdditionalPropertiesAsync()
{
// Arrange
var state = new { sessionId = "abc123", step = 5 };
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(state);
using HttpClient httpClient = this.CreateMockHttpClient(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
Assert.NotNull(stateUpdate.AdditionalProperties);
Assert.True((bool)stateUpdate.AdditionalProperties!["is_state_snapshot"]!);
DataContent dataContent = (DataContent)stateUpdate.Contents[0];
Assert.Equal("application/json", dataContent.MediaType);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedState = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal("abc123", deserializedState.GetProperty("sessionId").GetString());
Assert.Equal(5, deserializedState.GetProperty("step").GetInt32());
}
}
internal sealed class TestDelegatingHandler : DelegatingHandler
@@ -1376,3 +1682,58 @@ internal sealed class CapturingTestDelegatingHandler : DelegatingHandler
};
}
}
internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler
{
private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responseFactories = new();
public bool RequestWasMade { get; private set; }
public JsonElement? CapturedState { get; private set; }
public int CapturedMessageCount { get; private set; }
public void AddResponse(BaseEvent[] events)
{
this._responseFactories.Enqueue(_ => Task.FromResult(CreateResponse(events)));
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.RequestWasMade = true;
// Capture the state and message count from the request
#if NET472 || NETSTANDARD2_0
string requestBody = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
#else
string requestBody = await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#endif
RunAgentInput? input = JsonSerializer.Deserialize(requestBody, AGUIJsonSerializerContext.Default.RunAgentInput);
if (input != null)
{
if (input.State.ValueKind != JsonValueKind.Undefined && input.State.ValueKind != JsonValueKind.Null)
{
this.CapturedState = input.State;
}
this.CapturedMessageCount = input.Messages.Count();
}
if (this._responseFactories.Count == 0)
{
throw new InvalidOperationException("No more responses configured for StateCapturingTestDelegatingHandler.");
}
var factory = this._responseFactories.Dequeue();
return await factory(request);
}
private static HttpResponseMessage CreateResponse(BaseEvent[] events)
{
string sseContent = string.Join("", events.Select(e =>
$"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(sseContent)
};
}
}
@@ -369,4 +369,412 @@ public sealed class ChatResponseUpdateAGUIExtensionsTests
Assert.Equal("call_2", functionCalls[1].CallId);
Assert.Equal("Tool2", functionCalls[1].Name);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsStateSnapshotEvent_ToDataContentWithJsonAsync()
{
// Arrange
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(new { counter = 42, status = "active" });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
Assert.Equal(ChatRole.Assistant, stateUpdate.Role);
Assert.Equal("thread1", stateUpdate.ConversationId);
Assert.Equal("run1", stateUpdate.ResponseId);
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
Assert.Equal("application/json", dataContent.MediaType);
// Verify the JSON content
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedState = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal(42, deserializedState.GetProperty("counter").GetInt32());
Assert.Equal("active", deserializedState.GetProperty("status").GetString());
// Verify additional properties
Assert.NotNull(stateUpdate.AdditionalProperties);
Assert.True((bool)stateUpdate.AdditionalProperties["is_state_snapshot"]!);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithNullStateSnapshot_DoesNotEmitUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = null },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithEmptyObjectStateSnapshot_EmitsDataContentAsync()
{
// Arrange
JsonElement emptyState = JsonSerializer.SerializeToElement(new { });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = emptyState },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
Assert.Equal("{}", jsonText);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithComplexStateSnapshot_PreservesJsonStructureAsync()
{
// Arrange
var complexState = new
{
user = new { name = "Alice", age = 30 },
items = new[] { "item1", "item2", "item3" },
metadata = new { timestamp = "2024-01-01T00:00:00Z", version = 2 }
};
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(complexState);
List<BaseEvent> events =
[
new StateSnapshotEvent { Snapshot = stateSnapshot }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First();
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement roundTrippedState = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal("Alice", roundTrippedState.GetProperty("user").GetProperty("name").GetString());
Assert.Equal(30, roundTrippedState.GetProperty("user").GetProperty("age").GetInt32());
Assert.Equal(3, roundTrippedState.GetProperty("items").GetArrayLength());
Assert.Equal("item1", roundTrippedState.GetProperty("items")[0].GetString());
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithStateSnapshotAndTextMessages_EmitsBothAsync()
{
// Arrange
JsonElement state = JsonSerializer.SerializeToElement(new { step = 1 });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Processing..." },
new TextMessageEndEvent { MessageId = "msg1" },
new StateSnapshotEvent { Snapshot = state },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Contains(updates, u => u.Contents.Any(c => c is TextContent));
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent));
}
#region State Delta Tests
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsStateDeltaEvent_ToDataContentWithJsonPatchAsync()
{
// Arrange - Create JSON Patch operations (RFC 6902)
JsonElement stateDelta = JsonSerializer.SerializeToElement(new object[]
{
new { op = "replace", path = "/counter", value = 43 },
new { op = "add", path = "/newField", value = "test" }
});
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = stateDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate deltaUpdate = updates.First(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
Assert.Equal(ChatRole.Assistant, deltaUpdate.Role);
Assert.Equal("thread1", deltaUpdate.ConversationId);
Assert.Equal("run1", deltaUpdate.ResponseId);
DataContent dataContent = Assert.IsType<DataContent>(deltaUpdate.Contents[0]);
Assert.Equal("application/json-patch+json", dataContent.MediaType);
// Verify the JSON Patch content
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedDelta = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal(JsonValueKind.Array, deserializedDelta.ValueKind);
Assert.Equal(2, deserializedDelta.GetArrayLength());
// Verify first operation
JsonElement firstOp = deserializedDelta[0];
Assert.Equal("replace", firstOp.GetProperty("op").GetString());
Assert.Equal("/counter", firstOp.GetProperty("path").GetString());
Assert.Equal(43, firstOp.GetProperty("value").GetInt32());
// Verify second operation
JsonElement secondOp = deserializedDelta[1];
Assert.Equal("add", secondOp.GetProperty("op").GetString());
Assert.Equal("/newField", secondOp.GetProperty("path").GetString());
Assert.Equal("test", secondOp.GetProperty("value").GetString());
// Verify additional properties
Assert.NotNull(deltaUpdate.AdditionalProperties);
Assert.True((bool)deltaUpdate.AdditionalProperties["is_state_delta"]!);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithNullStateDelta_DoesNotEmitUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = null },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert - Only run started and finished should be present
Assert.Equal(2, updates.Count);
Assert.IsType<ChatResponseUpdate>(updates[0]); // Run started
Assert.IsType<ChatResponseUpdate>(updates[1]); // Run finished
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithEmptyStateDelta_EmitsUpdateAsync()
{
// Arrange - Empty JSON Patch array is valid
JsonElement emptyDelta = JsonSerializer.SerializeToElement(Array.Empty<object>());
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = emptyDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithMultipleStateDeltaEvents_ConvertsAllAsync()
{
// Arrange
JsonElement delta1 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
JsonElement delta2 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 2 } });
JsonElement delta3 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 3 } });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = delta1 },
new StateDeltaEvent { Delta = delta2 },
new StateDeltaEvent { Delta = delta3 },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
var deltaUpdates = updates.Where(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json")).ToList();
Assert.Equal(3, deltaUpdates.Count);
}
[Fact]
public async Task AsAGUIEventStreamAsync_ConvertsDataContentWithJsonPatch_ToStateDeltaEventAsync()
{
// Arrange - Create a ChatResponseUpdate with JSON Patch DataContent
JsonElement patchOps = JsonSerializer.SerializeToElement(new object[]
{
new { op = "remove", path = "/oldField" },
new { op = "add", path = "/newField", value = "newValue" }
});
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(patchOps);
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
MessageId = "msg1"
}
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
StateDeltaEvent? deltaEvent = outputEvents.OfType<StateDeltaEvent>().FirstOrDefault();
Assert.NotNull(deltaEvent);
Assert.NotNull(deltaEvent.Delta);
Assert.Equal(JsonValueKind.Array, deltaEvent.Delta.Value.ValueKind);
// Verify patch operations
JsonElement delta = deltaEvent.Delta.Value;
Assert.Equal(2, delta.GetArrayLength());
Assert.Equal("remove", delta[0].GetProperty("op").GetString());
Assert.Equal("/oldField", delta[0].GetProperty("path").GetString());
Assert.Equal("add", delta[1].GetProperty("op").GetString());
Assert.Equal("/newField", delta[1].GetProperty("path").GetString());
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithBothSnapshotAndDelta_EmitsBothEventsAsync()
{
// Arrange
JsonElement snapshot = JsonSerializer.SerializeToElement(new { counter = 0 });
byte[] snapshotBytes = JsonSerializer.SerializeToUtf8Bytes(snapshot);
DataContent snapshotContent = new(snapshotBytes, "application/json");
JsonElement delta = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
byte[] deltaBytes = JsonSerializer.SerializeToUtf8Bytes(delta);
DataContent deltaContent = new(deltaBytes, "application/json-patch+json");
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, [snapshotContent]) { MessageId = "msg1" },
new ChatResponseUpdate(ChatRole.Assistant, [deltaContent]) { MessageId = "msg2" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.Contains(outputEvents, e => e is StateSnapshotEvent);
Assert.Contains(outputEvents, e => e is StateDeltaEvent);
}
[Fact]
public async Task StateDeltaEvent_RoundTrip_PreservesJsonPatchOperationsAsync()
{
// Arrange - Create complex JSON Patch with various operations
JsonElement originalDelta = JsonSerializer.SerializeToElement(new object[]
{
new { op = "add", path = "/user/email", value = "test@example.com" },
new { op = "remove", path = "/user/tempData" },
new { op = "replace", path = "/user/lastLogin", value = "2025-11-09T12:00:00Z" },
new { op = "move", from = "/user/oldAddress", path = "/user/previousAddress" },
new { op = "copy", from = "/user/name", path = "/user/displayName" },
new { op = "test", path = "/user/version", value = 2 }
});
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = originalDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act - Convert to ChatResponseUpdate and back to events
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
List<BaseEvent> roundTripEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
roundTripEvents.Add(evt);
}
// Assert
StateDeltaEvent? roundTripDelta = roundTripEvents.OfType<StateDeltaEvent>().FirstOrDefault();
Assert.NotNull(roundTripDelta);
Assert.NotNull(roundTripDelta.Delta);
JsonElement delta = roundTripDelta.Delta.Value;
Assert.Equal(6, delta.GetArrayLength());
// Verify each operation type
Assert.Equal("add", delta[0].GetProperty("op").GetString());
Assert.Equal("remove", delta[1].GetProperty("op").GetString());
Assert.Equal("replace", delta[2].GetProperty("op").GetString());
Assert.Equal("move", delta[3].GetProperty("op").GetString());
Assert.Equal("copy", delta[4].GetProperty("op").GetString());
Assert.Equal("test", delta[5].GetProperty("op").GetString());
}
#endregion State Delta Tests
}
@@ -0,0 +1,441 @@
// 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 SharedStateTests : IAsyncDisposable
{
private WebApplication? _app;
private HttpClient? _client;
[Fact]
public async Task StateSnapshot_IsReturnedAsDataContent_WithCorrectMediaTypeAsync()
{
// Arrange
var initialState = new { counter = 42, status = "active" };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "update state");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
updates.Should().NotBeEmpty();
// Should receive state snapshot as DataContent with application/json media type
AgentRunResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateUpdate.Should().NotBeNull("should receive state snapshot update");
DataContent? dataContent = stateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
dataContent.Should().NotBeNull();
// Verify the state content
string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray());
JsonElement receivedState = JsonSerializer.Deserialize<JsonElement>(receivedJson);
receivedState.GetProperty("counter").GetInt32().Should().Be(43, "state should be incremented");
receivedState.GetProperty("status").GetString().Should().Be("active");
}
[Fact]
public async Task StateSnapshot_HasCorrectAdditionalPropertiesAsync()
{
// Arrange
var initialState = new { step = 1 };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "process");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
AgentRunResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateUpdate.Should().NotBeNull();
ChatResponseUpdate chatUpdate = stateUpdate!.AsChatResponseUpdate();
chatUpdate.AdditionalProperties.Should().NotBeNull();
chatUpdate.AdditionalProperties.Should().ContainKey("is_state_snapshot");
((bool)chatUpdate.AdditionalProperties!["is_state_snapshot"]!).Should().BeTrue();
}
[Fact]
public async Task ComplexState_WithNestedObjectsAndArrays_RoundTripsCorrectlyAsync()
{
// Arrange
var complexState = new
{
sessionId = "test-123",
nested = new { value = "test", count = 10 },
array = new[] { 1, 2, 3 },
tags = new[] { "tag1", "tag2" }
};
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(complexState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "process complex state");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
AgentRunResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateUpdate.Should().NotBeNull();
DataContent? dataContent = stateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray());
JsonElement receivedState = JsonSerializer.Deserialize<JsonElement>(receivedJson);
receivedState.GetProperty("sessionId").GetString().Should().Be("test-123");
receivedState.GetProperty("nested").GetProperty("count").GetInt32().Should().Be(10);
receivedState.GetProperty("array").GetArrayLength().Should().Be(3);
receivedState.GetProperty("tags").GetArrayLength().Should().Be(2);
}
[Fact]
public async Task StateSnapshot_CanBeUsedInSubsequentRequest_ForStateRoundTripAsync()
{
// Arrange
var initialState = new { counter = 1, sessionId = "round-trip-test" };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "increment");
List<AgentRunResponseUpdate> firstRoundUpdates = [];
// Act - First round
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
firstRoundUpdates.Add(update);
}
// Extract state snapshot from first round
AgentRunResponseUpdate? firstStateUpdate = firstRoundUpdates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
firstStateUpdate.Should().NotBeNull();
DataContent? firstStateContent = firstStateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
// Second round - use returned state
ChatMessage secondStateMessage = new(ChatRole.System, [firstStateContent!]);
ChatMessage secondUserMessage = new(ChatRole.User, "increment again");
List<AgentRunResponseUpdate> secondRoundUpdates = [];
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([secondUserMessage, secondStateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
secondRoundUpdates.Add(update);
}
// Assert - Second round should have incremented counter again
AgentRunResponseUpdate? secondStateUpdate = secondRoundUpdates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
secondStateUpdate.Should().NotBeNull();
DataContent? secondStateContent = secondStateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
string secondStateJson = System.Text.Encoding.UTF8.GetString(secondStateContent!.Data.ToArray());
JsonElement secondState = JsonSerializer.Deserialize<JsonElement>(secondStateJson);
secondState.GetProperty("counter").GetInt32().Should().Be(3, "counter should be incremented twice: 1 -> 2 -> 3");
}
[Fact]
public async Task WithoutState_AgentBehavesNormally_NoStateSnapshotReturnedAsync()
{
// Arrange
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)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
updates.Should().NotBeEmpty();
// Should NOT have state snapshot when no state is sent
bool hasStateSnapshot = updates.Any(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
hasStateSnapshot.Should().BeFalse("should not return state snapshot when no state is provided");
// Should have normal text response
updates.Should().Contain(u => u.Contents.Any(c => c is TextContent));
}
[Fact]
public async Task EmptyState_DoesNotTriggerStateHandlingAsync()
{
// Arrange
var emptyState = new { };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(emptyState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "hello");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
updates.Should().NotBeEmpty();
// Empty state {} should not trigger state snapshot mechanism
bool hasEmptyStateSnapshot = updates.Any(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
hasEmptyStateSnapshot.Should().BeFalse("empty state should be treated as no state");
// Should have normal response
updates.Should().Contain(u => u.Contents.Any(c => c is TextContent));
}
[Fact]
public async Task NonStreamingRunAsync_WithState_ReturnsStateInResponseAsync()
{
// Arrange
var initialState = new { counter = 5 };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "process");
// Act
AgentRunResponse response = await agent.RunAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None);
// Assert
response.Should().NotBeNull();
response.Messages.Should().NotBeEmpty();
// Should have message with DataContent containing state
bool hasStateMessage = response.Messages.Any(m => m.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
hasStateMessage.Should().BeTrue("response should contain state message");
ChatMessage? stateResponseMessage = response.Messages.FirstOrDefault(m => m.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateResponseMessage.Should().NotBeNull();
DataContent? dataContent = stateResponseMessage!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray());
JsonElement receivedState = JsonSerializer.Deserialize<JsonElement>(receivedJson);
receivedState.GetProperty("counter").GetInt32().Should().Be(6);
}
private async Task SetupTestServerAsync(FakeStateAgent fakeAgent)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddAGUI();
builder.WebHost.UseTestServer();
this._app = builder.Build();
this._app.MapAGUI("/agent", fakeAgent);
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 in tests")]
internal sealed class FakeStateAgent : AIAgent
{
public override string? Description => "Agent for state testing";
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Check for state in ChatOptions.AdditionalProperties (set by AG-UI hosting layer)
if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } &&
properties.TryGetValue("ag_ui_state", out object? stateObj) &&
stateObj is JsonElement state &&
state.ValueKind == JsonValueKind.Object)
{
// Check if state object has properties (not empty {})
bool hasProperties = false;
foreach (JsonProperty _ in state.EnumerateObject())
{
hasProperties = true;
break;
}
if (hasProperties)
{
// State is present and non-empty - modify it and return as DataContent
Dictionary<string, object?> modifiedState = [];
foreach (JsonProperty prop in state.EnumerateObject())
{
if (prop.Name == "counter" && prop.Value.ValueKind == JsonValueKind.Number)
{
modifiedState[prop.Name] = prop.Value.GetInt32() + 1;
}
else if (prop.Value.ValueKind == JsonValueKind.Number)
{
modifiedState[prop.Name] = prop.Value.GetInt32();
}
else if (prop.Value.ValueKind == JsonValueKind.String)
{
modifiedState[prop.Name] = prop.Value.GetString();
}
else if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
{
modifiedState[prop.Name] = prop.Value;
}
}
// Return modified state as DataContent
string modifiedStateJson = JsonSerializer.Serialize(modifiedState);
byte[] modifiedStateBytes = System.Text.Encoding.UTF8.GetBytes(modifiedStateJson);
DataContent modifiedStateContent = new(modifiedStateBytes, "application/json");
yield return new AgentRunResponseUpdate
{
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Assistant,
Contents = [modifiedStateContent]
};
}
}
// Always return a text response
string messageId = Guid.NewGuid().ToString("N");
yield return new AgentRunResponseUpdate
{
MessageId = messageId,
Role = ChatRole.Assistant,
Contents = [new TextContent("State processed")]
};
await Task.CompletedTask;
}
public override AgentThread GetNewThread() => new FakeInMemoryAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
}
private sealed class FakeInMemoryAgentThread : InMemoryAgentThread
{
public FakeInMemoryAgentThread()
: base()
{
}
public FakeInMemoryAgentThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThread, jsonSerializerOptions)
{
}
}
public override object? GetService(Type serviceType, object? serviceKey = null) => null;
}
@@ -190,6 +190,264 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
Assert.Equal("Second", capturedMessages[1].Text);
}
[Fact]
public async Task MapAGUIAgent_ProducesValidAGUIEventStream_WithRunStartAndFinishAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
JsonElement runStarted = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunStarted);
JsonElement runFinished = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunFinished);
Assert.Equal("thread1", runStarted.GetProperty("threadId").GetString());
Assert.Equal("run1", runStarted.GetProperty("runId").GetString());
Assert.Equal("thread1", runFinished.GetProperty("threadId").GetString());
Assert.Equal("run1", runFinished.GetProperty("runId").GetString());
}
[Fact]
public async Task MapAGUIAgent_ProducesTextMessageEvents_InCorrectOrderAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Hello" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
List<string?> eventTypes = new(events.Count);
foreach (JsonElement evt in events)
{
eventTypes.Add(evt.GetProperty("type").GetString());
}
Assert.Contains(AGUIEventTypes.RunStarted, eventTypes);
Assert.Contains(AGUIEventTypes.TextMessageContent, eventTypes);
Assert.Contains(AGUIEventTypes.RunFinished, eventTypes);
int runStartIndex = eventTypes.IndexOf(AGUIEventTypes.RunStarted);
int firstContentIndex = eventTypes.IndexOf(AGUIEventTypes.TextMessageContent);
int runFinishIndex = eventTypes.LastIndexOf(AGUIEventTypes.RunFinished);
Assert.True(runStartIndex < firstContentIndex, "Run start should precede text content.");
Assert.True(firstContentIndex < runFinishIndex, "Text content should precede run finish.");
}
[Fact]
public async Task MapAGUIAgent_EmitsTextMessageContent_WithCorrectDeltaAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
JsonElement textContentEvent = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.TextMessageContent);
Assert.Equal("Test response", textContentEvent.GetProperty("delta").GetString());
}
[Fact]
public async Task MapAGUIAgent_WithCustomAgent_ProducesExpectedStreamStructureAsync()
{
// Arrange
AIAgent customAgentFactory(IEnumerable<ChatMessage> messages, IEnumerable<AITool> tools, IEnumerable<KeyValuePair<string, string>> context, JsonElement props)
{
return new MultiResponseAgent();
}
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "custom_thread",
RunId = "custom_run",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Multi" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate(customAgentFactory);
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
List<JsonElement> contentEvents = new();
foreach (JsonElement evt in events)
{
if (evt.GetProperty("type").GetString() == AGUIEventTypes.TextMessageContent)
{
contentEvents.Add(evt);
}
}
Assert.True(contentEvents.Count >= 3, $"Expected at least 3 text_message.content events, got {contentEvents.Count}");
List<string?> deltas = new(contentEvents.Count);
foreach (JsonElement contentEvent in contentEvents)
{
deltas.Add(contentEvent.GetProperty("delta").GetString());
}
Assert.Contains("First", deltas);
Assert.Contains(" part", deltas);
Assert.Contains(" of response", deltas);
}
[Fact]
public async Task MapAGUIAgent_ProducesCorrectThreadAndRunIds_InAllEventsAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "test_thread_123",
RunId = "test_run_456",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
JsonElement runStarted = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunStarted);
Assert.Equal("test_thread_123", runStarted.GetProperty("threadId").GetString());
Assert.Equal("test_run_456", runStarted.GetProperty("runId").GetString());
}
private static List<JsonElement> ParseSseEvents(string responseContent)
{
List<JsonElement> events = [];
using StringReader reader = new(responseContent);
StringBuilder dataBuilder = new();
string? line;
while ((line = reader.ReadLine()) != null)
{
if (line.StartsWith("data:", StringComparison.Ordinal))
{
string payload = line.Length > 5 && line[5] == ' '
? line.Substring(6)
: line.Substring(5);
dataBuilder.Append(payload);
}
else if (line.Length == 0 && dataBuilder.Length > 0)
{
using JsonDocument document = JsonDocument.Parse(dataBuilder.ToString());
events.Add(document.RootElement.Clone());
dataBuilder.Clear();
}
}
if (dataBuilder.Length > 0)
{
using JsonDocument document = JsonDocument.Parse(dataBuilder.ToString());
events.Add(document.RootElement.Clone());
}
return events;
}
private sealed class MultiResponseAgent : AIAgent
{
public override string Id => "multi-response-agent";
public override string? Description => "Agent that produces multiple text chunks";
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, "First"));
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " part"));
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " of response"));
}
}
private RequestDelegate CreateRequestDelegate(
Func<IEnumerable<ChatMessage>, IEnumerable<AITool>, IEnumerable<KeyValuePair<string, string>>, JsonElement, AIAgent> factory)
{