diff --git a/dotnet/samples/AGUIClientServer/AGUIClient/AGUIClient.csproj b/dotnet/samples/AGUIClientServer/AGUIClient/AGUIClient.csproj
index db07df5504..01ce32a62a 100644
--- a/dotnet/samples/AGUIClientServer/AGUIClient/AGUIClient.csproj
+++ b/dotnet/samples/AGUIClientServer/AGUIClient/AGUIClient.csproj
@@ -16,6 +16,7 @@
+
diff --git a/dotnet/samples/AGUIClientServer/AGUIClient/AGUIClientSerializerContext.cs b/dotnet/samples/AGUIClientServer/AGUIClient/AGUIClientSerializerContext.cs
new file mode 100644
index 0000000000..1cc4fb8f53
--- /dev/null
+++ b/dotnet/samples/AGUIClientServer/AGUIClient/AGUIClientSerializerContext.cs
@@ -0,0 +1,12 @@
+// 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.Text.Json.Serialization;
+
+namespace AGUIClient;
+
+[JsonSerializable(typeof(SensorRequest))]
+[JsonSerializable(typeof(SensorResponse))]
+internal sealed partial class AGUIClientSerializerContext : JsonSerializerContext;
diff --git a/dotnet/samples/AGUIClientServer/AGUIClient/Program.cs b/dotnet/samples/AGUIClientServer/AGUIClient/Program.cs
index 0c6a6539a8..0cbf15d6e4 100644
--- a/dotnet/samples/AGUIClientServer/AGUIClient/Program.cs
+++ b/dotnet/samples/AGUIClientServer/AGUIClient/Program.cs
@@ -4,7 +4,9 @@
// and display streaming updates including conversation/response metadata, text content, and errors.
using System.CommandLine;
+using System.ComponentModel;
using System.Reflection;
+using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.AGUI;
using Microsoft.Extensions.AI;
@@ -51,11 +53,40 @@ public static class Program
Timeout = TimeSpan.FromSeconds(60)
};
- AGUIAgent agent = new(
- id: "agui-client",
+ var changeBackground = AIFunctionFactory.Create(
+ () =>
+ {
+ Console.ForegroundColor = ConsoleColor.DarkBlue;
+ Console.WriteLine("Changing color to blue");
+ },
+ name: "change_background_color",
+ description: "Change the console background color to dark blue."
+ );
+
+ var readClientClimateSensors = AIFunctionFactory.Create(
+ ([Description("The sensors measurements to include in the response")] SensorRequest request) =>
+ {
+ return new SensorResponse()
+ {
+ Temperature = 22.5,
+ Humidity = 45.0,
+ AirQualityIndex = 75
+ };
+ },
+ name: "read_client_climate_sensors",
+ description: "Reads the climate sensor data from the client device.",
+ serializerOptions: AGUIClientSerializerContext.Default.Options
+ );
+
+ var chatClient = new AGUIChatClient(
+ httpClient,
+ serverUrl,
+ jsonSerializerOptions: AGUIClientSerializerContext.Default.Options);
+
+ AIAgent agent = chatClient.CreateAIAgent(
+ name: "agui-client",
description: "AG-UI Client Agent",
- httpClient: httpClient,
- endpoint: serverUrl);
+ tools: [changeBackground, readClientClimateSensors]);
AgentThread thread = agent.GetNewThread();
List messages = [new(ChatRole.System, "You are a helpful assistant.")];
@@ -82,10 +113,12 @@ public static class Program
// Call RunStreamingAsync to get streaming updates
bool isFirstUpdate = true;
string? threadId = null;
+ var updates = new List();
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages, thread, cancellationToken: cancellationToken))
{
// Use AsChatResponseUpdate to access ChatResponseUpdate properties
ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();
+ updates.Add(chatUpdate);
if (chatUpdate.ConversationId != null)
{
threadId = chatUpdate.ConversationId;
@@ -111,6 +144,25 @@ public static class Program
Console.ResetColor();
break;
+ case FunctionCallContent functionCallContent:
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine($"\n[Function Call - Name: {functionCallContent.Name}, Arguments: {PrintArguments(functionCallContent.Arguments)}]");
+ Console.ResetColor();
+ break;
+
+ case FunctionResultContent functionResultContent:
+ Console.ForegroundColor = ConsoleColor.Magenta;
+ if (functionResultContent.Exception != null)
+ {
+ Console.WriteLine($"\n[Function Result - Exception: {functionResultContent.Exception}]");
+ }
+ else
+ {
+ Console.WriteLine($"\n[Function Result - Result: {functionResultContent.Result}]");
+ }
+ Console.ResetColor();
+ break;
+
case ErrorContent errorContent:
Console.ForegroundColor = ConsoleColor.Red;
string code = errorContent.AdditionalProperties?["Code"] as string ?? "Unknown";
@@ -120,6 +172,14 @@ public static class Program
}
}
}
+ if (updates.Count > 0 && !updates[^1].Contents.Any(c => c is TextContent))
+ {
+ var lastUpdate = updates[^1];
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine();
+ Console.WriteLine($"[Run Ended - Thread: {threadId}, Run: {lastUpdate.ResponseId}]");
+ Console.ResetColor();
+ }
messages.Clear();
Console.WriteLine();
}
@@ -134,4 +194,20 @@ public static class Program
return;
}
}
+
+ private static string PrintArguments(IDictionary? arguments)
+ {
+ if (arguments == null)
+ {
+ return "";
+ }
+ var builder = new StringBuilder();
+ builder.AppendLine();
+ foreach (var kvp in arguments)
+ {
+ builder.AppendLine($" Name: {kvp.Key}");
+ builder.AppendLine($" Value: {kvp.Value}");
+ }
+ return builder.ToString();
+ }
}
diff --git a/dotnet/samples/AGUIClientServer/AGUIClient/SensorRequest.cs b/dotnet/samples/AGUIClientServer/AGUIClient/SensorRequest.cs
new file mode 100644
index 0000000000..76e6efa8de
--- /dev/null
+++ b/dotnet/samples/AGUIClientServer/AGUIClient/SensorRequest.cs
@@ -0,0 +1,13 @@
+// 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.
+
+namespace AGUIClient;
+
+internal sealed class SensorRequest
+{
+ public bool IncludeTemperature { get; set; } = true;
+ public bool IncludeHumidity { get; set; } = true;
+ public bool IncludeAirQualityIndex { get; set; } = true;
+}
diff --git a/dotnet/samples/AGUIClientServer/AGUIClient/SensorResponse.cs b/dotnet/samples/AGUIClientServer/AGUIClient/SensorResponse.cs
new file mode 100644
index 0000000000..09ade6a0c7
--- /dev/null
+++ b/dotnet/samples/AGUIClientServer/AGUIClient/SensorResponse.cs
@@ -0,0 +1,13 @@
+// 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.
+
+namespace AGUIClient;
+
+internal sealed class SensorResponse
+{
+ public double Temperature { get; set; }
+ public double Humidity { get; set; }
+ public int AirQualityIndex { get; set; }
+}
diff --git a/dotnet/samples/AGUIClientServer/AGUIServer/AGUIServerSerializerContext.cs b/dotnet/samples/AGUIClientServer/AGUIServer/AGUIServerSerializerContext.cs
new file mode 100644
index 0000000000..1ca6ad7bdc
--- /dev/null
+++ b/dotnet/samples/AGUIClientServer/AGUIServer/AGUIServerSerializerContext.cs
@@ -0,0 +1,9 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace AGUIServer;
+
+[JsonSerializable(typeof(ServerWeatherForecastRequest))]
+[JsonSerializable(typeof(ServerWeatherForecastResponse))]
+internal sealed partial class AGUIServerSerializerContext : JsonSerializerContext;
diff --git a/dotnet/samples/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/AGUIClientServer/AGUIServer/Program.cs
index f26ace30a1..4ecf9a8429 100644
--- a/dotnet/samples/AGUIClientServer/AGUIServer/Program.cs
+++ b/dotnet/samples/AGUIClientServer/AGUIServer/Program.cs
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.ComponentModel;
+using AGUIServer;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
@@ -8,17 +10,40 @@ using OpenAI;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
+builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default));
+builder.Services.AddAGUI();
+
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
+// Create the AI agent with tools
var agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
- .CreateAIAgent(name: "AGUIAssistant");
+ .CreateAIAgent(
+ name: "AGUIAssistant",
+ tools: [
+ AIFunctionFactory.Create(
+ () => DateTimeOffset.UtcNow,
+ name: "get_current_time",
+ description: "Get the current UTC time."
+ ),
+ AIFunctionFactory.Create(
+ ([Description("The weather forecast request")]ServerWeatherForecastRequest request) => {
+ return new ServerWeatherForecastResponse()
+ {
+ Summary = "Sunny",
+ TemperatureC = 25,
+ Date = request.Date
+ };
+ },
+ name: "get_server_weather_forecast",
+ description: "Gets the forecast for a specific location and date",
+ AGUIServerSerializerContext.Default.Options)
+ ]);
// Map the AG-UI agent endpoint
app.MapAGUI("/", agent);
diff --git a/dotnet/samples/AGUIClientServer/AGUIServer/ServerWeatherForecastRequest.cs b/dotnet/samples/AGUIClientServer/AGUIServer/ServerWeatherForecastRequest.cs
new file mode 100644
index 0000000000..a4e3d983ca
--- /dev/null
+++ b/dotnet/samples/AGUIClientServer/AGUIServer/ServerWeatherForecastRequest.cs
@@ -0,0 +1,9 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace AGUIServer;
+
+internal sealed class ServerWeatherForecastRequest
+{
+ public DateTime Date { get; set; }
+ public string Location { get; set; } = "Seattle";
+}
diff --git a/dotnet/samples/AGUIClientServer/AGUIServer/ServerWeatherForecastResponse.cs b/dotnet/samples/AGUIClientServer/AGUIServer/ServerWeatherForecastResponse.cs
new file mode 100644
index 0000000000..2bc5d8fbb9
--- /dev/null
+++ b/dotnet/samples/AGUIClientServer/AGUIServer/ServerWeatherForecastResponse.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace AGUIServer;
+
+internal sealed class ServerWeatherForecastResponse
+{
+ public string Summary { get; set; } = "";
+
+ public int TemperatureC { get; set; }
+
+ public DateTime Date { get; set; }
+}
diff --git a/dotnet/samples/AGUIClientServer/README.md b/dotnet/samples/AGUIClientServer/README.md
index dabc841542..b0ad2265d0 100644
--- a/dotnet/samples/AGUIClientServer/README.md
+++ b/dotnet/samples/AGUIClientServer/README.md
@@ -134,15 +134,21 @@ This automatically handles:
### Client Side
-The `AGUIClient` uses the `AGUIAgent` class to connect to the remote server:
+The `AGUIClient` uses the `AGUIChatClient` to connect to the remote server:
```csharp
-AGUIAgent agent = new(
- id: "agui-client",
+using HttpClient httpClient = new();
+var chatClient = new AGUIChatClient(
+ httpClient,
+ endpoint: serverUrl,
+ modelId: "agui-client",
+ jsonSerializerOptions: null);
+
+AIAgent agent = chatClient.CreateAIAgent(
+ instructions: null,
+ name: "agui-client",
description: "AG-UI Client Agent",
- messages: [],
- httpClient: httpClient,
- endpoint: serverUrl);
+ tools: []);
bool isFirstUpdate = true;
AgentRunResponseUpdate? currentUpdate = null;
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIAgent.cs
deleted file mode 100644
index e86fac7429..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIAgent.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-// 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;
-
-///
-/// Provides an implementation that communicates with an AG-UI compliant server.
-///
-public sealed class AGUIAgent : AIAgent
-{
- private readonly AGUIHttpService _client;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The agent ID.
- /// Optional description of the agent.
- /// The HTTP client to use for communication with the AG-UI server.
- /// The URL for the AG-UI server.
- 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));
- }
-
- ///
- public override string Id { get; }
-
- ///
- public override string? Description { get; }
-
- ///
- public override AgentThread GetNewThread() => new AGUIAgentThread();
-
- ///
- public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
- new AGUIAgentThread(serializedThread, jsonSerializerOptions);
-
- ///
- public override async Task RunAsync(
- IEnumerable messages,
- AgentThread? thread = null,
- AgentRunOptions? options = null,
- CancellationToken cancellationToken = default)
- {
- return await this.RunStreamingAsync(messages, thread, null, cancellationToken)
- .ToAgentRunResponseAsync(cancellationToken)
- .ConfigureAwait(false);
- }
-
- ///
- public override async IAsyncEnumerable RunStreamingAsync(
- IEnumerable messages,
- AgentThread? thread = null,
- AgentRunOptions? options = null,
- [EnumeratorCancellation] CancellationToken cancellationToken = default)
- {
- List 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);
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIAgentThread.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIAgentThread.cs
deleted file mode 100644
index 5b2f29897a..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIAgentThread.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-// 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; }
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs
new file mode 100644
index 0000000000..11894eb488
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/AGUIChatClient.cs
@@ -0,0 +1,323 @@
+// 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.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.AGUI;
+
+///
+/// Provides an implementation that communicates with an AG-UI compliant server.
+///
+public sealed class AGUIChatClient : DelegatingChatClient
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HTTP client to use for communication with the AG-UI server.
+ /// The URL for the AG-UI server.
+ /// The to use for logging.
+ /// JSON serializer options for tool call argument serialization. If null, AGUIJsonSerializerContext.Default.Options will be used.
+ /// Optional service provider for resolving dependencies like ILogger.
+ public AGUIChatClient(
+ HttpClient httpClient,
+ string endpoint,
+ ILoggerFactory? loggerFactory = null,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ IServiceProvider? serviceProvider = null) : base(CreateInnerClient(
+ httpClient,
+ endpoint,
+ CombineJsonSerializerOptions(jsonSerializerOptions),
+ loggerFactory,
+ serviceProvider))
+ {
+ }
+
+ private static JsonSerializerOptions CombineJsonSerializerOptions(JsonSerializerOptions? jsonSerializerOptions)
+ {
+ if (jsonSerializerOptions == null)
+ {
+ return AGUIJsonSerializerContext.Default.Options;
+ }
+
+ // Create a new JsonSerializerOptions based on the provided one
+ var combinedOptions = new JsonSerializerOptions(jsonSerializerOptions);
+
+ // Add the AGUI context to the type info resolver chain if not already present
+ if (!combinedOptions.TypeInfoResolverChain.Any(r => r == AGUIJsonSerializerContext.Default))
+ {
+ combinedOptions.TypeInfoResolverChain.Insert(0, AGUIJsonSerializerContext.Default);
+ }
+
+ return combinedOptions;
+ }
+
+ private static FunctionInvokingChatClient CreateInnerClient(
+ HttpClient httpClient,
+ string endpoint,
+ JsonSerializerOptions jsonSerializerOptions,
+ ILoggerFactory? loggerFactory,
+ IServiceProvider? serviceProvider)
+ {
+ Throw.IfNull(httpClient);
+ Throw.IfNull(endpoint);
+ var handler = new AGUIChatClientHandler(httpClient, endpoint, jsonSerializerOptions, serviceProvider);
+ return new FunctionInvokingChatClient(handler, loggerFactory, serviceProvider);
+ }
+
+ ///
+ public override Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
+ this.GetStreamingResponseAsync(messages, options, cancellationToken)
+ .ToChatResponseAsync(cancellationToken);
+
+ ///
+ public async override IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ ChatResponseUpdate? firstUpdate = null;
+ string? conversationId = null;
+ // AG-UI requires the full message history on every turn, so we clear the conversation id here
+ // and restore it for the caller.
+ var innerOptions = options;
+ if (options?.ConversationId != null)
+ {
+ conversationId = options.ConversationId;
+
+ // Clone the options and set the conversation ID to null so the FunctionInvokingChatClient doesn't see it.
+ innerOptions = options.Clone();
+ innerOptions.AdditionalProperties ??= [];
+ innerOptions.AdditionalProperties["agui_thread_id"] = options.ConversationId;
+ innerOptions.ConversationId = null;
+ }
+
+ await foreach (var update in base.GetStreamingResponseAsync(messages, innerOptions, cancellationToken).ConfigureAwait(false))
+ {
+ if (conversationId == null && firstUpdate == null)
+ {
+ firstUpdate = update;
+ if (firstUpdate.AdditionalProperties?.TryGetValue("agui_thread_id", out string? threadId) is true)
+ {
+ // Capture the thread id from the first update to use as conversation id if none was provided
+ conversationId = threadId;
+ }
+ }
+
+ // Cleanup any temporary approach we used by the handler to avoid issues with FunctionInvokingChatClient
+ for (var i = 0; i < update.Contents.Count; i++)
+ {
+ var content = update.Contents[i];
+ if (content is FunctionCallContent functionCallContent)
+ {
+ functionCallContent.AdditionalProperties?.Remove("agui_thread_id");
+ }
+ if (content is ServerFunctionCallContent serverFunctionCallContent)
+ {
+ update.Contents[i] = serverFunctionCallContent.FunctionCallContent;
+ }
+ }
+
+ var finalUpdate = CopyResponseUpdate(update);
+
+ finalUpdate.ConversationId = conversationId;
+ yield return finalUpdate;
+ }
+ }
+
+ private static ChatResponseUpdate CopyResponseUpdate(ChatResponseUpdate source)
+ {
+ return new ChatResponseUpdate
+ {
+ AuthorName = source.AuthorName,
+ Role = source.Role,
+ Contents = source.Contents,
+ RawRepresentation = source.RawRepresentation,
+ AdditionalProperties = source.AdditionalProperties,
+ ResponseId = source.ResponseId,
+ MessageId = source.MessageId,
+ CreatedAt = source.CreatedAt,
+ };
+ }
+
+ private sealed class AGUIChatClientHandler : IChatClient
+ {
+ private readonly AGUIHttpService _httpService;
+ private readonly JsonSerializerOptions _jsonSerializerOptions;
+ private readonly ILogger _logger;
+
+ public AGUIChatClientHandler(
+ HttpClient httpClient,
+ string endpoint,
+ JsonSerializerOptions? jsonSerializerOptions,
+ IServiceProvider? serviceProvider)
+ {
+ this._httpService = new AGUIHttpService(httpClient, endpoint);
+ this._jsonSerializerOptions = jsonSerializerOptions ?? AGUIJsonSerializerContext.Default.Options;
+ this._logger = serviceProvider?.GetService(typeof(ILogger)) as ILogger ?? NullLogger.Instance;
+
+ // Use BaseAddress if endpoint is empty, otherwise parse as relative or absolute
+ Uri metadataUri = string.IsNullOrEmpty(endpoint) && httpClient.BaseAddress is not null
+ ? httpClient.BaseAddress
+ : new Uri(endpoint, UriKind.RelativeOrAbsolute);
+ this.Metadata = new ChatClientMetadata("ag-ui", metadataUri, null);
+ }
+
+ public ChatClientMetadata Metadata { get; }
+
+ public Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ return this.GetStreamingResponseAsync(messages, options, cancellationToken)
+ .ToChatResponseAsync(cancellationToken);
+ }
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (messages is null)
+ {
+ throw new ArgumentNullException(nameof(messages));
+ }
+
+ var runId = $"run_{Guid.NewGuid():N}";
+ var messagesList = messages.ToList(); // Avoid triggering the enumerator multiple times.
+ var threadId = ExtractTemporaryThreadId(messagesList) ??
+ ExtractThreadIdFromOptions(options) ?? $"thread_{Guid.NewGuid():N}";
+
+ // Create the input for the AGUI service
+ var input = new RunAgentInput
+ {
+ // AG-UI requires a thread ID to work, but for FunctionInvokingChatClient that
+ // implies the underlying client is managing the history.
+ ThreadId = threadId,
+ RunId = runId,
+ Messages = messagesList.AsAGUIMessages(this._jsonSerializerOptions),
+ };
+
+ // Add tools if provided
+ if (options?.Tools is { Count: > 0 })
+ {
+ input.Tools = options.Tools.AsAGUITools();
+ this._logger.LogDebug("[AGUIChatClient] Tool count: {ToolCount}", options.Tools.Count);
+ }
+
+ var clientToolSet = new HashSet();
+ foreach (var tool in options?.Tools ?? [])
+ {
+ clientToolSet.Add(tool.Name);
+ }
+
+ ChatResponseUpdate? firstUpdate = null;
+ await foreach (var update in this._httpService.PostRunAsync(input, cancellationToken)
+ .AsChatResponseUpdatesAsync(this._jsonSerializerOptions, cancellationToken).ConfigureAwait(false))
+ {
+ if (firstUpdate == null)
+ {
+ firstUpdate = update;
+ if (!string.IsNullOrEmpty(firstUpdate.ConversationId) && !string.Equals(firstUpdate.ConversationId, threadId, StringComparison.Ordinal))
+ {
+ threadId = firstUpdate.ConversationId;
+ }
+ firstUpdate.AdditionalProperties ??= [];
+ firstUpdate.AdditionalProperties["agui_thread_id"] = threadId;
+ }
+
+ if (update.Contents is { Count: 1 } && update.Contents[0] is FunctionCallContent fcc)
+ {
+ if (clientToolSet.Contains(fcc.Name))
+ {
+ // Prepare to let the wrapping FunctionInvokingChatClient handle this function call.
+ // We want to retain the original thread id that either the server sent us or that we set
+ // in this turn on the next turn, but we can't make it visible to FunctionInvokeingChatClient
+ // because it would then not send the full history on the next turn as required by AG-UI.
+ // We store it on additional properties of the function call content, which will be passed down
+ // in the next turn.
+ fcc.AdditionalProperties ??= [];
+ fcc.AdditionalProperties["agui_thread_id"] = threadId;
+ }
+ else
+ {
+ // Hide the server result call from the FunctionInvokingChatClient.
+ // The wrapping client will unwrap it and present it as a normal function result.
+ update.Contents[0] = new ServerFunctionCallContent(fcc);
+ }
+ }
+
+ // Remove the conversation id before yielding so that the wrapping FunctionInvokingChatClient
+ // sends the whole message history on every turn as per AG-UI requirements.
+ update.ConversationId = null;
+ yield return update;
+ }
+ }
+
+ // Extract the thread id from the options additional properties
+ private static string? ExtractThreadIdFromOptions(ChatOptions? options)
+ {
+ if (options?.AdditionalProperties is null ||
+ !options.AdditionalProperties.TryGetValue("agui_thread_id", out string? threadId) ||
+ string.IsNullOrEmpty(threadId))
+ {
+ return null;
+ }
+ return threadId;
+ }
+
+ // Extract the thread id from the second last message's function call content additional properties
+ private static string? ExtractTemporaryThreadId(List messagesList)
+ {
+ if (messagesList.Count < 2)
+ {
+ return null;
+ }
+ var functionCall = messagesList[messagesList.Count - 2];
+ if (functionCall.Contents.Count < 1 || functionCall.Contents[0] is not FunctionCallContent content)
+ {
+ return null;
+ }
+
+ if (content.AdditionalProperties is null ||
+ !content.AdditionalProperties.TryGetValue("agui_thread_id", out string? threadId) ||
+ string.IsNullOrEmpty(threadId))
+ {
+ return null;
+ }
+
+ return threadId;
+ }
+
+ public void Dispose()
+ {
+ // No resources to dispose
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ if (serviceType == typeof(ChatClientMetadata))
+ {
+ return this.Metadata;
+ }
+
+ return null;
+ }
+ }
+
+ private class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
+ {
+ public FunctionCallContent FunctionCallContent { get; } = functionCall;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj
index 8992aaf4fb..35f89f889f 100644
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj
@@ -8,11 +8,6 @@
-
-
- false
-
-
true
@@ -28,6 +23,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIAssistantMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIAssistantMessage.cs
new file mode 100644
index 0000000000..4bf1fdfef4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIAssistantMessage.cs
@@ -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 AGUIAssistantMessage : AGUIMessage
+{
+ public AGUIAssistantMessage()
+ {
+ this.Role = AGUIRoles.Assistant;
+ }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("toolCalls")]
+ public AGUIToolCall[]? ToolCalls { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIChatMessageExtensions.cs
index 2b09fb8da2..506956cac8 100644
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIChatMessageExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIChatMessageExtensions.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Text.Json;
using Microsoft.Extensions.AI;
#if ASPNETCORE
@@ -15,28 +16,194 @@ internal static class AGUIChatMessageExtensions
private static readonly ChatRole s_developerChatRole = new("developer");
public static IEnumerable AsChatMessages(
- this IEnumerable aguiMessages)
+ this IEnumerable aguiMessages,
+ JsonSerializerOptions jsonSerializerOptions)
{
foreach (var message in aguiMessages)
{
- yield return new ChatMessage(
- MapChatRole(message.Role),
- message.Content);
+ var role = MapChatRole(message.Role);
+
+ switch (message)
+ {
+ case AGUIToolMessage toolMessage:
+ {
+ object? result;
+ if (string.IsNullOrEmpty(toolMessage.Content))
+ {
+ result = toolMessage.Content;
+ }
+ else
+ {
+ // Try to deserialize as JSON, but fall back to string if it fails
+ try
+ {
+ result = JsonSerializer.Deserialize(toolMessage.Content, AGUIJsonSerializerContext.Default.JsonElement);
+ }
+ catch (JsonException)
+ {
+ result = toolMessage.Content;
+ }
+ }
+
+ yield return new ChatMessage(
+ role,
+ [
+ new FunctionResultContent(
+ toolMessage.ToolCallId,
+ result)
+ ]);
+ break;
+ }
+
+ case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
+ {
+ var contents = new List();
+
+ if (!string.IsNullOrEmpty(assistantMessage.Content))
+ {
+ contents.Add(new TextContent(assistantMessage.Content));
+ }
+
+ // Add tool calls
+ foreach (var toolCall in assistantMessage.ToolCalls)
+ {
+ Dictionary? arguments = null;
+ if (!string.IsNullOrEmpty(toolCall.Function.Arguments))
+ {
+ arguments = (Dictionary?)JsonSerializer.Deserialize(
+ toolCall.Function.Arguments,
+ jsonSerializerOptions.GetTypeInfo(typeof(Dictionary)));
+ }
+
+ contents.Add(new FunctionCallContent(
+ toolCall.Id,
+ toolCall.Function.Name,
+ arguments));
+ }
+
+ yield return new ChatMessage(role, contents)
+ {
+ MessageId = message.Id
+ };
+ break;
+ }
+
+ default:
+ {
+ string content = message switch
+ {
+ AGUIDeveloperMessage dev => dev.Content,
+ AGUISystemMessage sys => sys.Content,
+ AGUIUserMessage user => user.Content,
+ AGUIAssistantMessage asst => asst.Content,
+ _ => string.Empty
+ };
+
+ yield return new ChatMessage(role, content)
+ {
+ MessageId = message.Id
+ };
+ break;
+ }
+ }
}
}
public static IEnumerable AsAGUIMessages(
- this IEnumerable chatMessages)
+ this IEnumerable chatMessages,
+ JsonSerializerOptions jsonSerializerOptions)
{
foreach (var message in chatMessages)
{
- yield return new AGUIMessage
+ message.MessageId ??= Guid.NewGuid().ToString("N");
+ if (message.Role == ChatRole.Tool)
+ {
+ foreach (var toolMessage in MapToolMessages(jsonSerializerOptions, message))
+ {
+ yield return toolMessage;
+ }
+ }
+ else if (message.Role == ChatRole.Assistant)
+ {
+ var assistantMessage = MapAssistantMessage(jsonSerializerOptions, message);
+ if (assistantMessage != null)
+ {
+ yield return assistantMessage;
+ }
+ }
+ else
+ {
+ yield return message.Role.Value switch
+ {
+ AGUIRoles.Developer => new AGUIDeveloperMessage { Id = message.MessageId, Content = message.Text ?? string.Empty },
+ AGUIRoles.System => new AGUISystemMessage { Id = message.MessageId, Content = message.Text ?? string.Empty },
+ AGUIRoles.User => new AGUIUserMessage { Id = message.MessageId, Content = message.Text ?? string.Empty },
+ _ => throw new InvalidOperationException($"Unknown role: {message.Role.Value}")
+ };
+ }
+ }
+ }
+
+ private static AGUIAssistantMessage? MapAssistantMessage(JsonSerializerOptions jsonSerializerOptions, ChatMessage message)
+ {
+ List? toolCalls = null;
+ string? textContent = null;
+
+ foreach (var content in message.Contents)
+ {
+ if (content is FunctionCallContent functionCall)
+ {
+ var argumentsJson = functionCall.Arguments is null ?
+ "{}" :
+ JsonSerializer.Serialize(functionCall.Arguments, jsonSerializerOptions.GetTypeInfo(typeof(IDictionary)));
+ toolCalls ??= [];
+ toolCalls.Add(new AGUIToolCall
+ {
+ Id = functionCall.CallId,
+ Type = "function",
+ Function = new AGUIFunctionCall
+ {
+ Name = functionCall.Name,
+ Arguments = argumentsJson
+ }
+ });
+ }
+ else if (content is TextContent textContentItem)
+ {
+ textContent = textContentItem.Text;
+ }
+ }
+
+ // Create message with tool calls and/or text content
+ if (toolCalls?.Count > 0 || !string.IsNullOrEmpty(textContent))
+ {
+ return new AGUIAssistantMessage
{
Id = message.MessageId,
- Role = message.Role.Value,
- Content = message.Text,
+ Content = textContent ?? string.Empty,
+ ToolCalls = toolCalls?.Count > 0 ? toolCalls.ToArray() : null
};
}
+
+ return null;
+ }
+
+ private static IEnumerable MapToolMessages(JsonSerializerOptions jsonSerializerOptions, ChatMessage message)
+ {
+ foreach (var content in message.Contents)
+ {
+ if (content is FunctionResultContent functionResult)
+ {
+ yield return new AGUIToolMessage
+ {
+ Id = functionResult.CallId,
+ ToolCallId = functionResult.CallId,
+ Content = functionResult.Result is null ?
+ string.Empty :
+ JsonSerializer.Serialize(functionResult.Result, jsonSerializerOptions.GetTypeInfo(functionResult.Result.GetType()))
+ };
+ }
+ }
}
public static ChatRole MapChatRole(string role) =>
@@ -44,5 +211,6 @@ internal static class AGUIChatMessageExtensions
string.Equals(role, AGUIRoles.User, StringComparison.OrdinalIgnoreCase) ? ChatRole.User :
string.Equals(role, AGUIRoles.Assistant, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
string.Equals(role, AGUIRoles.Developer, StringComparison.OrdinalIgnoreCase) ? s_developerChatRole :
+ string.Equals(role, AGUIRoles.Tool, StringComparison.OrdinalIgnoreCase) ? ChatRole.Tool :
throw new InvalidOperationException($"Unknown chat role: {role}");
}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIContextItem.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIContextItem.cs
new file mode 100644
index 0000000000..54be56f880
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIContextItem.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal sealed class AGUIContextItem
+{
+ [JsonPropertyName("description")]
+ public string Description { get; set; } = string.Empty;
+
+ [JsonPropertyName("value")]
+ public string Value { get; set; } = string.Empty;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIDeveloperMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIDeveloperMessage.cs
new file mode 100644
index 0000000000..e41f375b9c
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIDeveloperMessage.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal sealed class AGUIDeveloperMessage : AGUIMessage
+{
+ public AGUIDeveloperMessage()
+ {
+ this.Role = AGUIRoles.Developer;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs
index 74ff3da37f..731d8a8f42 100644
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIEventTypes.cs
@@ -19,4 +19,12 @@ internal static class AGUIEventTypes
public const string TextMessageContent = "TEXT_MESSAGE_CONTENT";
public const string TextMessageEnd = "TEXT_MESSAGE_END";
+
+ public const string ToolCallStart = "TOOL_CALL_START";
+
+ public const string ToolCallArgs = "TOOL_CALL_ARGS";
+
+ public const string ToolCallEnd = "TOOL_CALL_END";
+
+ public const string ToolCallResult = "TOOL_CALL_RESULT";
}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIFunctionCall.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIFunctionCall.cs
new file mode 100644
index 0000000000..f69dbcbac6
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIFunctionCall.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal sealed class AGUIFunctionCall
+{
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("arguments")]
+ public string Arguments { get; set; } = string.Empty;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs
index fa2e0ced1a..7c4338f0c9 100644
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIJsonSerializerContext.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Generic;
using System.Text.Json.Serialization;
#if ASPNETCORE
@@ -12,18 +13,50 @@ using Microsoft.Agents.AI.AGUI.Shared;
namespace Microsoft.Agents.AI.AGUI;
#endif
+// All JsonSerializable attributes below are required for AG-UI functionality:
+// - AG-UI message types (AGUIMessage, AGUIUserMessage, etc.) for protocol communication
+// - Event types (BaseEvent, RunStartedEvent, etc.) for server-sent events streaming
+// - Tool-related types (AGUITool, AGUIToolCall, AGUIFunctionCall) for tool calling support
+// - Primitive and dictionary types (string, int, Dictionary, JsonElement) are required for
+// serializing tool call parameters and results which can contain arbitrary data types
[JsonSourceGenerationOptions(WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
[JsonSerializable(typeof(RunAgentInput))]
+[JsonSerializable(typeof(AGUIMessage))]
+[JsonSerializable(typeof(AGUIMessage[]))]
+[JsonSerializable(typeof(AGUIDeveloperMessage))]
+[JsonSerializable(typeof(AGUISystemMessage))]
+[JsonSerializable(typeof(AGUIUserMessage))]
+[JsonSerializable(typeof(AGUIAssistantMessage))]
+[JsonSerializable(typeof(AGUIToolMessage))]
+[JsonSerializable(typeof(AGUITool))]
+[JsonSerializable(typeof(AGUIToolCall))]
+[JsonSerializable(typeof(AGUIToolCall[]))]
+[JsonSerializable(typeof(AGUIFunctionCall))]
[JsonSerializable(typeof(BaseEvent))]
+[JsonSerializable(typeof(BaseEvent[]))]
[JsonSerializable(typeof(RunStartedEvent))]
[JsonSerializable(typeof(RunFinishedEvent))]
[JsonSerializable(typeof(RunErrorEvent))]
[JsonSerializable(typeof(TextMessageStartEvent))]
[JsonSerializable(typeof(TextMessageContentEvent))]
[JsonSerializable(typeof(TextMessageEndEvent))]
-#if !ASPNETCORE
-[JsonSerializable(typeof(AGUIAgentThread.AGUIAgentThreadState))]
-#endif
+[JsonSerializable(typeof(ToolCallStartEvent))]
+[JsonSerializable(typeof(ToolCallArgsEvent))]
+[JsonSerializable(typeof(ToolCallEndEvent))]
+[JsonSerializable(typeof(ToolCallResultEvent))]
+[JsonSerializable(typeof(IDictionary))]
+[JsonSerializable(typeof(Dictionary))]
+[JsonSerializable(typeof(IDictionary))]
+[JsonSerializable(typeof(Dictionary))]
+[JsonSerializable(typeof(System.Text.Json.JsonElement))]
+[JsonSerializable(typeof(Dictionary))]
+[JsonSerializable(typeof(string))]
+[JsonSerializable(typeof(int))]
+[JsonSerializable(typeof(long))]
+[JsonSerializable(typeof(double))]
+[JsonSerializable(typeof(float))]
+[JsonSerializable(typeof(bool))]
+[JsonSerializable(typeof(decimal))]
internal partial class AGUIJsonSerializerContext : JsonSerializerContext
{
}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessage.cs
index b32c1efcfa..01ccb07b15 100644
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessage.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessage.cs
@@ -8,7 +8,8 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
-internal sealed class AGUIMessage
+[JsonConverter(typeof(AGUIMessageJsonConverter))]
+internal abstract class AGUIMessage
{
[JsonPropertyName("id")]
public string? Id { get; set; }
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessageJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessageJsonConverter.cs
new file mode 100644
index 0000000000..ceb0504c63
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIMessageJsonConverter.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal sealed class AGUIMessageJsonConverter : JsonConverter
+{
+ private const string RoleDiscriminatorPropertyName = "role";
+
+ public override bool CanConvert(Type typeToConvert) =>
+ typeof(AGUIMessage).IsAssignableFrom(typeToConvert);
+
+ public override AGUIMessage Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options)
+ {
+ var jsonElementTypeInfo = options.GetTypeInfo(typeof(JsonElement));
+ JsonElement jsonElement = (JsonElement)JsonSerializer.Deserialize(ref reader, jsonElementTypeInfo)!;
+
+ // Try to get the discriminator property
+ if (!jsonElement.TryGetProperty(RoleDiscriminatorPropertyName, out JsonElement discriminatorElement))
+ {
+ throw new JsonException($"Missing required property '{RoleDiscriminatorPropertyName}' for AGUIMessage deserialization");
+ }
+
+ string? discriminator = discriminatorElement.GetString();
+
+ // Map discriminator to concrete type and deserialize using type info from options
+ AGUIMessage? result = discriminator switch
+ {
+ AGUIRoles.Developer => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIDeveloperMessage))) as AGUIDeveloperMessage,
+ AGUIRoles.System => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUISystemMessage))) as AGUISystemMessage,
+ AGUIRoles.User => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIUserMessage))) as AGUIUserMessage,
+ AGUIRoles.Assistant => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIAssistantMessage))) as AGUIAssistantMessage,
+ AGUIRoles.Tool => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIToolMessage))) as AGUIToolMessage,
+ _ => throw new JsonException($"Unknown AGUIMessage role discriminator: '{discriminator}'")
+ };
+
+ if (result == null)
+ {
+ throw new JsonException($"Failed to deserialize AGUIMessage with role discriminator: '{discriminator}'");
+ }
+
+ return result;
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ AGUIMessage value,
+ JsonSerializerOptions options)
+ {
+ // Serialize the concrete type directly using type info from options
+ switch (value)
+ {
+ case AGUIDeveloperMessage developer:
+ JsonSerializer.Serialize(writer, developer, options.GetTypeInfo(typeof(AGUIDeveloperMessage)));
+ break;
+ case AGUISystemMessage system:
+ JsonSerializer.Serialize(writer, system, options.GetTypeInfo(typeof(AGUISystemMessage)));
+ break;
+ case AGUIUserMessage user:
+ JsonSerializer.Serialize(writer, user, options.GetTypeInfo(typeof(AGUIUserMessage)));
+ break;
+ case AGUIAssistantMessage assistant:
+ JsonSerializer.Serialize(writer, assistant, options.GetTypeInfo(typeof(AGUIAssistantMessage)));
+ break;
+ case AGUIToolMessage tool:
+ JsonSerializer.Serialize(writer, tool, options.GetTypeInfo(typeof(AGUIToolMessage)));
+ break;
+ default:
+ throw new JsonException($"Unknown AGUIMessage type: {value.GetType().Name}");
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIRoles.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIRoles.cs
index fe67224efe..f702d5ec8d 100644
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIRoles.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIRoles.cs
@@ -15,4 +15,6 @@ internal static class AGUIRoles
public const string Assistant = "assistant";
public const string Developer = "developer";
+
+ public const string Tool = "tool";
}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUISystemMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUISystemMessage.cs
new file mode 100644
index 0000000000..f2d053c23e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUISystemMessage.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal sealed class AGUISystemMessage : AGUIMessage
+{
+ public AGUISystemMessage()
+ {
+ this.Role = AGUIRoles.System;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUITool.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUITool.cs
new file mode 100644
index 0000000000..c42556dcb0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUITool.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal sealed class AGUITool
+{
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("parameters")]
+ public JsonElement Parameters { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolCall.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolCall.cs
new file mode 100644
index 0000000000..ca28d956d3
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolCall.cs
@@ -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 AGUIToolCall
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "function";
+
+ [JsonPropertyName("function")]
+ public AGUIFunctionCall Function { get; set; } = new();
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolMessage.cs
new file mode 100644
index 0000000000..bcd49d2b6f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIToolMessage.cs
@@ -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 AGUIToolMessage : AGUIMessage
+{
+ public AGUIToolMessage()
+ {
+ this.Role = AGUIRoles.Tool;
+ }
+
+ [JsonPropertyName("toolCallId")]
+ public string ToolCallId { get; set; } = string.Empty;
+
+ [JsonPropertyName("error")]
+ public string? Error { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIUserMessage.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIUserMessage.cs
new file mode 100644
index 0000000000..e8e9f2ed57
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AGUIUserMessage.cs
@@ -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 AGUIUserMessage : AGUIMessage
+{
+ public AGUIUserMessage()
+ {
+ this.Role = AGUIRoles.User;
+ }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AIToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AIToolExtensions.cs
new file mode 100644
index 0000000000..8952f38a28
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AIToolExtensions.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using Microsoft.Extensions.AI;
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal static class AIToolExtensions
+{
+ public static IEnumerable AsAGUITools(this IEnumerable tools)
+ {
+ if (tools is null)
+ {
+ yield break;
+ }
+
+ foreach (var tool in tools)
+ {
+ // Convert both AIFunctionDeclaration and AIFunction (which extends it) to AGUITool
+ // For AIFunction, we send only the metadata (Name, Description, JsonSchema)
+ // The actual executable implementation stays on the client side
+ if (tool is AIFunctionDeclaration function)
+ {
+ yield return new AGUITool
+ {
+ Name = function.Name,
+ Description = function.Description,
+ Parameters = function.JsonSchema
+ };
+ }
+ }
+ }
+
+ public static IEnumerable AsAITools(this IEnumerable tools)
+ {
+ if (tools is null)
+ {
+ yield break;
+ }
+
+ foreach (var tool in tools)
+ {
+ // Create a function declaration from the AG-UI tool definition
+ // Note: These are declaration-only and cannot be invoked, as the actual
+ // implementation exists on the client side
+ yield return AIFunctionFactory.CreateDeclaration(
+ name: tool.Name,
+ description: tool.Description,
+ jsonSchema: tool.Parameters);
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AgentRunResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AgentRunResponseUpdateAGUIExtensions.cs
deleted file mode 100644
index 59755d7b5a..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/AgentRunResponseUpdateAGUIExtensions.cs
+++ /dev/null
@@ -1,161 +0,0 @@
-// 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 AsAgentRunResponseUpdatesAsync(
- this IAsyncEnumerable 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 AsAGUIEventStreamAsync(
- this IAsyncEnumerable 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,
- };
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs
index 58624ac45c..af2414d7f0 100644
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/BaseEventJsonConverter.cs
@@ -10,10 +10,6 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
-///
-/// 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.
-///
internal sealed class BaseEventJsonConverter : JsonConverter
{
private const string TypeDiscriminatorPropertyName = "type";
@@ -26,9 +22,8 @@ internal sealed class BaseEventJsonConverter : JsonConverter
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();
+ var jsonElementTypeInfo = options.GetTypeInfo(typeof(JsonElement));
+ JsonElement jsonElement = (JsonElement)JsonSerializer.Deserialize(ref reader, jsonElementTypeInfo)!;
// Try to get the discriminator property
if (!jsonElement.TryGetProperty(TypeDiscriminatorPropertyName, out JsonElement discriminatorElement))
@@ -38,21 +33,19 @@ internal sealed class BaseEventJsonConverter : JsonConverter
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
+ // Map discriminator to concrete type and deserialize using type info from options
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),
+ AGUIEventTypes.RunStarted => jsonElement.Deserialize(options.GetTypeInfo(typeof(RunStartedEvent))) as RunStartedEvent,
+ AGUIEventTypes.RunFinished => jsonElement.Deserialize(options.GetTypeInfo(typeof(RunFinishedEvent))) as RunFinishedEvent,
+ AGUIEventTypes.RunError => jsonElement.Deserialize(options.GetTypeInfo(typeof(RunErrorEvent))) as RunErrorEvent,
+ AGUIEventTypes.TextMessageStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(TextMessageStartEvent))) as TextMessageStartEvent,
+ AGUIEventTypes.TextMessageContent => jsonElement.Deserialize(options.GetTypeInfo(typeof(TextMessageContentEvent))) as TextMessageContentEvent,
+ AGUIEventTypes.TextMessageEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(TextMessageEndEvent))) as TextMessageEndEvent,
+ AGUIEventTypes.ToolCallStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallStartEvent))) as ToolCallStartEvent,
+ AGUIEventTypes.ToolCallArgs => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallArgsEvent))) as ToolCallArgsEvent,
+ AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent,
+ AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent,
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
};
@@ -69,32 +62,38 @@ internal sealed class BaseEventJsonConverter : JsonConverter
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
+ // Serialize the concrete type directly using type info from options
switch (value)
{
case RunStartedEvent runStarted:
- JsonSerializer.Serialize(writer, runStarted, context.RunStartedEvent);
+ JsonSerializer.Serialize(writer, runStarted, options.GetTypeInfo(typeof(RunStartedEvent)));
break;
case RunFinishedEvent runFinished:
- JsonSerializer.Serialize(writer, runFinished, context.RunFinishedEvent);
+ JsonSerializer.Serialize(writer, runFinished, options.GetTypeInfo(typeof(RunFinishedEvent)));
break;
case RunErrorEvent runError:
- JsonSerializer.Serialize(writer, runError, context.RunErrorEvent);
+ JsonSerializer.Serialize(writer, runError, options.GetTypeInfo(typeof(RunErrorEvent)));
break;
case TextMessageStartEvent textStart:
- JsonSerializer.Serialize(writer, textStart, context.TextMessageStartEvent);
+ JsonSerializer.Serialize(writer, textStart, options.GetTypeInfo(typeof(TextMessageStartEvent)));
break;
case TextMessageContentEvent textContent:
- JsonSerializer.Serialize(writer, textContent, context.TextMessageContentEvent);
+ JsonSerializer.Serialize(writer, textContent, options.GetTypeInfo(typeof(TextMessageContentEvent)));
break;
case TextMessageEndEvent textEnd:
- JsonSerializer.Serialize(writer, textEnd, context.TextMessageEndEvent);
+ JsonSerializer.Serialize(writer, textEnd, options.GetTypeInfo(typeof(TextMessageEndEvent)));
+ break;
+ case ToolCallStartEvent toolCallStart:
+ JsonSerializer.Serialize(writer, toolCallStart, options.GetTypeInfo(typeof(ToolCallStartEvent)));
+ break;
+ case ToolCallArgsEvent toolCallArgs:
+ JsonSerializer.Serialize(writer, toolCallArgs, options.GetTypeInfo(typeof(ToolCallArgsEvent)));
+ break;
+ case ToolCallEndEvent toolCallEnd:
+ JsonSerializer.Serialize(writer, toolCallEnd, options.GetTypeInfo(typeof(ToolCallEndEvent)));
+ break;
+ case ToolCallResultEvent toolCallResult:
+ JsonSerializer.Serialize(writer, toolCallResult, options.GetTypeInfo(typeof(ToolCallResultEvent)));
break;
default:
throw new JsonException($"Unknown BaseEvent type: {value.GetType().Name}");
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs
new file mode 100644
index 0000000000..9b865afabe
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs
@@ -0,0 +1,381 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal static class ChatResponseUpdateAGUIExtensions
+{
+ public static async IAsyncEnumerable AsChatResponseUpdatesAsync(
+ this IAsyncEnumerable events,
+ JsonSerializerOptions jsonSerializerOptions,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ string? conversationId = null;
+ string? responseId = null;
+ var textMessageBuilder = new TextMessageBuilder();
+ var toolCallAccumulator = new ToolCallBuilder();
+ await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
+ {
+ switch (evt)
+ {
+ // Lifecycle events
+ case RunStartedEvent runStarted:
+ conversationId = runStarted.ThreadId;
+ responseId = runStarted.RunId;
+ toolCallAccumulator.SetConversationAndResponseIds(conversationId, responseId);
+ textMessageBuilder.SetConversationAndResponseIds(conversationId, responseId);
+ yield return ValidateAndEmitRunStart(runStarted);
+ break;
+ case RunFinishedEvent runFinished:
+ yield return ValidateAndEmitRunFinished(conversationId, responseId, runFinished);
+ break;
+ case RunErrorEvent runError:
+ yield return new ChatResponseUpdate(ChatRole.Assistant, [(new ErrorContent(runError.Message) { ErrorCode = runError.Code })]);
+ break;
+
+ // Text events
+ case TextMessageStartEvent textStart:
+ textMessageBuilder.AddTextStart(textStart);
+ break;
+ case TextMessageContentEvent textContent:
+ yield return textMessageBuilder.EmitTextUpdate(textContent);
+ break;
+ case TextMessageEndEvent textEnd:
+ textMessageBuilder.EndCurrentMessage(textEnd);
+ break;
+
+ // Tool call events
+ case ToolCallStartEvent toolCallStart:
+ toolCallAccumulator.AddToolCallStart(toolCallStart);
+ break;
+ case ToolCallArgsEvent toolCallArgs:
+ toolCallAccumulator.AddToolCallArgs(toolCallArgs, jsonSerializerOptions);
+ break;
+ case ToolCallEndEvent toolCallEnd:
+ yield return toolCallAccumulator.EmitToolCallUpdate(toolCallEnd, jsonSerializerOptions);
+ break;
+ case ToolCallResultEvent toolCallResult:
+ yield return toolCallAccumulator.EmitToolCallResult(toolCallResult, jsonSerializerOptions);
+ break;
+ }
+ }
+ }
+
+ private class TextMessageBuilder()
+ {
+ private ChatRole _currentRole;
+ private string? _currentMessageId;
+ private string? _conversationId;
+ private string? _responseId;
+
+ public void SetConversationAndResponseIds(string? conversationId, string? responseId)
+ {
+ this._conversationId = conversationId;
+ this._responseId = responseId;
+ }
+
+ public void AddTextStart(TextMessageStartEvent textStart)
+ {
+ if (this._currentRole != default || this._currentMessageId != null)
+ {
+ throw new InvalidOperationException("Received TextMessageStartEvent while another message is being processed.");
+ }
+
+ this._currentRole = AGUIChatMessageExtensions.MapChatRole(textStart.Role);
+ this._currentMessageId = textStart.MessageId;
+ }
+
+ internal ChatResponseUpdate EmitTextUpdate(TextMessageContentEvent textContent)
+ {
+ return new ChatResponseUpdate(
+ this._currentRole,
+ textContent.Delta)
+ {
+ ConversationId = this._conversationId,
+ ResponseId = this._responseId,
+ MessageId = textContent.MessageId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ }
+
+ internal void EndCurrentMessage(TextMessageEndEvent textEnd)
+ {
+ if (this._currentMessageId != textEnd.MessageId)
+ {
+ throw new InvalidOperationException("Received TextMessageEndEvent for a different message than the current one.");
+ }
+ this._currentRole = default;
+ this._currentMessageId = null;
+ }
+ }
+
+ private static ChatResponseUpdate ValidateAndEmitRunStart(RunStartedEvent runStarted)
+ {
+ return new ChatResponseUpdate(
+ ChatRole.Assistant,
+ [])
+ {
+ ConversationId = runStarted.ThreadId,
+ ResponseId = runStarted.RunId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ }
+
+ private static ChatResponseUpdate ValidateAndEmitRunFinished(string? conversationId, string? responseId, RunFinishedEvent runFinished)
+ {
+ if (!string.Equals(runFinished.ThreadId, conversationId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException($"The run finished event didn't match the run started event thread ID: {runFinished.ThreadId}, {conversationId}");
+ }
+ if (!string.Equals(runFinished.RunId, responseId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException($"The run finished event didn't match the run started event run ID: {runFinished.RunId}, {responseId}");
+ }
+
+ return new ChatResponseUpdate(
+ ChatRole.Assistant, runFinished.Result?.GetRawText())
+ {
+ ConversationId = conversationId,
+ ResponseId = responseId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ }
+
+ private class ToolCallBuilder
+ {
+ private string? _conversationId;
+ private string? _responseId;
+ private StringBuilder? _accumulatedArgs;
+ private FunctionCallContent? _currentFunctionCall;
+
+ public void AddToolCallStart(ToolCallStartEvent toolCallStart)
+ {
+ if (this._currentFunctionCall != null)
+ {
+ throw new InvalidOperationException("Received ToolCallStartEvent while another tool call is being processed.");
+ }
+ this._accumulatedArgs ??= new StringBuilder();
+ this._currentFunctionCall = new(
+ toolCallStart.ToolCallId,
+ toolCallStart.ToolCallName,
+ null);
+ }
+
+ public void AddToolCallArgs(ToolCallArgsEvent toolCallArgs, JsonSerializerOptions options)
+ {
+ if (this._currentFunctionCall == null)
+ {
+ throw new InvalidOperationException("Received ToolCallArgsEvent without a current tool call.");
+ }
+
+ if (!string.Equals(this._currentFunctionCall.CallId, toolCallArgs.ToolCallId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("Received ToolCallArgsEvent for a different tool call than the current one.");
+ }
+
+ Debug.Assert(this._accumulatedArgs != null, "Accumulated args should have been initialized in ToolCallStartEvent.");
+ this._accumulatedArgs.Append(toolCallArgs.Delta);
+ }
+
+ internal ChatResponseUpdate EmitToolCallUpdate(ToolCallEndEvent toolCallEnd, JsonSerializerOptions jsonSerializerOptions)
+ {
+ if (this._currentFunctionCall == null)
+ {
+ throw new InvalidOperationException("Received ToolCallEndEvent without a current tool call.");
+ }
+ if (!string.Equals(this._currentFunctionCall.CallId, toolCallEnd.ToolCallId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("Received ToolCallEndEvent for a different tool call than the current one.");
+ }
+ Debug.Assert(this._accumulatedArgs != null, "Accumulated args should have been initialized in ToolCallStartEvent.");
+ var arguments = DeserializeArgumentsIfAvailable(this._accumulatedArgs.ToString(), jsonSerializerOptions);
+ this._accumulatedArgs.Clear();
+ this._currentFunctionCall.Arguments = arguments;
+ var invocation = this._currentFunctionCall;
+ this._currentFunctionCall = null;
+ return new ChatResponseUpdate(
+ ChatRole.Assistant,
+ [invocation])
+ {
+ ConversationId = this._conversationId,
+ ResponseId = this._responseId,
+ MessageId = invocation.CallId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ }
+
+ public ChatResponseUpdate EmitToolCallResult(ToolCallResultEvent toolCallResult, JsonSerializerOptions options)
+ {
+ return new ChatResponseUpdate(
+ ChatRole.Tool,
+ [new FunctionResultContent(
+ toolCallResult.ToolCallId,
+ DeserializeResultIfAvailable(toolCallResult, options))])
+ {
+ ConversationId = this._conversationId,
+ ResponseId = this._responseId,
+ MessageId = toolCallResult.MessageId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ }
+
+ internal void SetConversationAndResponseIds(string conversationId, string responseId)
+ {
+ this._conversationId = conversationId;
+ this._responseId = responseId;
+ }
+ }
+
+ private static IDictionary? DeserializeArgumentsIfAvailable(string argsJson, JsonSerializerOptions options)
+ {
+ if (!string.IsNullOrEmpty(argsJson))
+ {
+ return (IDictionary?)JsonSerializer.Deserialize(
+ argsJson,
+ options.GetTypeInfo(typeof(IDictionary)));
+ }
+
+ return null;
+ }
+
+ private static object? DeserializeResultIfAvailable(ToolCallResultEvent toolCallResult, JsonSerializerOptions options)
+ {
+ if (!string.IsNullOrEmpty(toolCallResult.Content))
+ {
+ return JsonSerializer.Deserialize(toolCallResult.Content, options.GetTypeInfo(typeof(JsonElement)));
+ }
+
+ return null;
+ }
+
+ public static async IAsyncEnumerable AsAGUIEventStreamAsync(
+ this IAsyncEnumerable updates,
+ string threadId,
+ string runId,
+ JsonSerializerOptions jsonSerializerOptions,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ yield return new RunStartedEvent
+ {
+ ThreadId = threadId,
+ RunId = runId
+ };
+
+ string? currentMessageId = null;
+ await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
+ {
+ 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 &&
+ !string.IsNullOrEmpty(textContent.Text))
+ {
+ yield return new TextMessageContentEvent
+ {
+ MessageId = chatResponse.MessageId!,
+ Delta = textContent.Text
+ };
+ }
+
+ // Emit tool call events and tool result events
+ if (chatResponse is { Contents.Count: > 0 })
+ {
+ foreach (var content in chatResponse.Contents)
+ {
+ if (content is FunctionCallContent functionCallContent)
+ {
+ yield return new ToolCallStartEvent
+ {
+ ToolCallId = functionCallContent.CallId,
+ ToolCallName = functionCallContent.Name,
+ ParentMessageId = chatResponse.MessageId
+ };
+
+ yield return new ToolCallArgsEvent
+ {
+ ToolCallId = functionCallContent.CallId,
+ Delta = JsonSerializer.Serialize(
+ functionCallContent.Arguments,
+ jsonSerializerOptions.GetTypeInfo(typeof(IDictionary)))
+ };
+
+ yield return new ToolCallEndEvent
+ {
+ ToolCallId = functionCallContent.CallId
+ };
+ }
+ else if (content is FunctionResultContent functionResultContent)
+ {
+ yield return new ToolCallResultEvent
+ {
+ MessageId = chatResponse.MessageId,
+ ToolCallId = functionResultContent.CallId,
+ Content = SerializeResultContent(functionResultContent, jsonSerializerOptions) ?? "",
+ Role = AGUIRoles.Tool
+ };
+ }
+ }
+ }
+ }
+
+ // End the last message if there was one
+ if (currentMessageId is not null)
+ {
+ yield return new TextMessageEndEvent
+ {
+ MessageId = currentMessageId
+ };
+ }
+
+ yield return new RunFinishedEvent
+ {
+ ThreadId = threadId,
+ RunId = runId,
+ };
+ }
+
+ private static string? SerializeResultContent(FunctionResultContent functionResultContent, JsonSerializerOptions options)
+ {
+ return functionResultContent.Result switch
+ {
+ null => null,
+ string str => str,
+ JsonElement jsonElement => jsonElement.GetRawText(),
+ _ => JsonSerializer.Serialize(functionResultContent.Result, options.GetTypeInfo(functionResultContent.Result.GetType())),
+ };
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs
index ad0d41cd8d..a9396ff722 100644
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/RunAgentInput.cs
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
-using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -26,8 +25,12 @@ internal sealed class RunAgentInput
[JsonPropertyName("messages")]
public IEnumerable Messages { get; set; } = [];
+ [JsonPropertyName("tools")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public IEnumerable? Tools { get; set; }
+
[JsonPropertyName("context")]
- public Dictionary Context { get; set; } = new(StringComparer.Ordinal);
+ public AGUIContextItem[] Context { get; set; } = [];
[JsonPropertyName("forwardedProperties")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallArgsEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallArgsEvent.cs
new file mode 100644
index 0000000000..27b0593699
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallArgsEvent.cs
@@ -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 ToolCallArgsEvent : BaseEvent
+{
+ public ToolCallArgsEvent()
+ {
+ this.Type = AGUIEventTypes.ToolCallArgs;
+ }
+
+ [JsonPropertyName("toolCallId")]
+ public string ToolCallId { get; set; } = string.Empty;
+
+ [JsonPropertyName("delta")]
+ public string Delta { get; set; } = string.Empty;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallEndEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallEndEvent.cs
new file mode 100644
index 0000000000..e78e6b89d9
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallEndEvent.cs
@@ -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 ToolCallEndEvent : BaseEvent
+{
+ public ToolCallEndEvent()
+ {
+ this.Type = AGUIEventTypes.ToolCallEnd;
+ }
+
+ [JsonPropertyName("toolCallId")]
+ public string ToolCallId { get; set; } = string.Empty;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallResultEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallResultEvent.cs
new file mode 100644
index 0000000000..e60265be68
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallResultEvent.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal sealed class ToolCallResultEvent : BaseEvent
+{
+ public ToolCallResultEvent()
+ {
+ this.Type = AGUIEventTypes.ToolCallResult;
+ }
+
+ [JsonPropertyName("messageId")]
+ public string? MessageId { get; set; }
+
+ [JsonPropertyName("toolCallId")]
+ public string ToolCallId { get; set; } = string.Empty;
+
+ [JsonPropertyName("content")]
+ public string Content { get; set; } = string.Empty;
+
+ [JsonPropertyName("role")]
+ public string? Role { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallStartEvent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallStartEvent.cs
new file mode 100644
index 0000000000..e2f7bed120
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ToolCallStartEvent.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+#if ASPNETCORE
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+#else
+namespace Microsoft.Agents.AI.AGUI.Shared;
+#endif
+
+internal sealed class ToolCallStartEvent : BaseEvent
+{
+ public ToolCallStartEvent()
+ {
+ this.Type = AGUIEventTypes.ToolCallStart;
+ }
+
+ [JsonPropertyName("toolCallId")]
+ public string ToolCallId { get; set; } = string.Empty;
+
+ [JsonPropertyName("toolCallName")]
+ public string ToolCallName { get; set; } = string.Empty;
+
+ [JsonPropertyName("parentMessageId")]
+ public string? ParentMessageId { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIChatResponseUpdateStreamExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIChatResponseUpdateStreamExtensions.cs
new file mode 100644
index 0000000000..c824331f60
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIChatResponseUpdateStreamExtensions.cs
@@ -0,0 +1,90 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+
+internal static class AGUIChatResponseUpdateStreamExtensions
+{
+ public static async IAsyncEnumerable FilterServerToolsFromMixedToolInvocationsAsync(
+ this IAsyncEnumerable updates,
+ List? clientTools,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (clientTools is null || clientTools.Count == 0)
+ {
+ await foreach (var update in updates.WithCancellation(cancellationToken))
+ {
+ yield return update;
+ }
+ yield break;
+ }
+
+ var set = new HashSet(clientTools.Count);
+ foreach (var tool in clientTools)
+ {
+ set.Add(tool.Name);
+ }
+
+ await foreach (var update in updates.WithCancellation(cancellationToken))
+ {
+ if (update.FinishReason == ChatFinishReason.ToolCalls)
+ {
+ var containsClientTools = false;
+ var containsServerTools = false;
+ for (var i = update.Contents.Count - 1; i >= 0; i--)
+ {
+ var content = update.Contents[i];
+ if (content is FunctionCallContent functionCallContent)
+ {
+ containsClientTools |= set.Contains(functionCallContent.Name);
+ containsServerTools |= !set.Contains(functionCallContent.Name);
+ if (containsClientTools && containsServerTools)
+ {
+ break;
+ }
+ }
+ }
+
+ if (containsClientTools && containsServerTools)
+ {
+ var newContents = new List();
+ for (var i = update.Contents.Count - 1; i >= 0; i--)
+ {
+ var content = update.Contents[i];
+ if (content is not FunctionCallContent fcc ||
+ set.Contains(fcc.Name))
+ {
+ newContents.Add(content);
+ }
+ }
+
+ yield return new ChatResponseUpdate(update.Role, newContents)
+ {
+ ConversationId = update.ConversationId,
+ ResponseId = update.ResponseId,
+ FinishReason = update.FinishReason,
+ AdditionalProperties = update.AdditionalProperties,
+ AuthorName = update.AuthorName,
+ CreatedAt = update.CreatedAt,
+ MessageId = update.MessageId,
+ ModelId = update.ModelId
+ };
+ }
+ else
+ {
+ yield return update;
+ }
+ }
+ else
+ {
+ yield return update;
+ }
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
index 63b71620e2..6e356f531d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Linq;
using System.Threading;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
using Microsoft.AspNetCore.Builder;
@@ -10,6 +12,7 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
@@ -37,19 +40,39 @@ public static class AGUIEndpointRouteBuilderExtensions
return Results.BadRequest();
}
- var messages = input.Messages.AsChatMessages();
+ var jsonOptions = context.RequestServices.GetRequiredService>();
+ var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
+
+ var messages = input.Messages.AsChatMessages(jsonSerializerOptions);
var agent = aiAgent;
+ ChatClientAgentRunOptions? runOptions = null;
+ List? clientTools = input.Tools?.AsAITools().ToList();
+ if (clientTools?.Count > 0)
+ {
+ runOptions = new ChatClientAgentRunOptions
+ {
+ ChatOptions = new ChatOptions
+ {
+ Tools = clientTools
+ }
+ };
+ }
+
var events = agent.RunStreamingAsync(
messages,
+ options: runOptions,
cancellationToken: cancellationToken)
+ .AsChatResponseUpdatesAsync()
+ .FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken)
.AsAGUIEventStreamAsync(
input.ThreadId,
input.RunId,
+ jsonSerializerOptions,
cancellationToken);
- var logger = context.RequestServices.GetRequiredService>();
- return new AGUIServerSentEventsResult(events, logger);
+ var sseLogger = context.RequestServices.GetRequiredService>();
+ return new AGUIServerSentEventsResult(events, sseLogger);
});
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIJsonSerializerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIJsonSerializerOptions.cs
new file mode 100644
index 0000000000..822f6f27e7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIJsonSerializerOptions.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+
+///
+/// Extension methods for JSON serialization.
+///
+internal static class AGUIJsonSerializerOptions
+{
+ ///
+ /// Gets the default JSON serializer options.
+ ///
+ public static JsonSerializerOptions Default { get; } = Create();
+
+ private static JsonSerializerOptions Create()
+ {
+ JsonSerializerOptions options = new(AGUIJsonSerializerContext.Default.Options);
+ options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
+ options.MakeReadOnly();
+ return options;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj
index 522f7f77de..869b931a20 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj
@@ -12,11 +12,6 @@
-
-
- false
-
-
Microsoft Agent Framework Hosting AG-UI ASP.NET Core
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..e159c0727e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ServiceCollectionExtensions.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+using Microsoft.AspNetCore.Http.Json;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Extension methods for to configure AG-UI support.
+///
+public static class MicrosoftAgentAIHostingAGUIServiceCollectionExtensions
+{
+ ///
+ /// Adds support for exposing instances via AG-UI.
+ ///
+ /// The to configure.
+ /// The for method chaining.
+ public static IServiceCollection AddAGUI(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.Configure(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIJsonSerializerOptions.Default.TypeInfoResolver!));
+
+ return services;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIAgentTests.cs
deleted file mode 100644
index d6388ff711..0000000000
--- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIAgentTests.cs
+++ /dev/null
@@ -1,344 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net;
-using System.Net.Http;
-using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Agents.AI.AGUI.Shared;
-using Microsoft.Extensions.AI;
-using Moq;
-using Moq.Protected;
-
-namespace Microsoft.Agents.AI.AGUI.UnitTests;
-
-///
-/// Unit tests for the class.
-///
-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 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 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(() => 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 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 messages = [new ChatMessage(ChatRole.User, "Test")];
- AgentThread invalidThread = new TestInMemoryAgentThread();
-
- // Act & Assert
- await Assert.ThrowsAsync(() => 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 messages = [new ChatMessage(ChatRole.User, "Test")];
-
- // Act
- List 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(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 messages = [new ChatMessage(ChatRole.User, "Test")];
-
- // Act
- List 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 messages = [new ChatMessage(ChatRole.User, "Test")];
- AgentThread invalidThread = new TestInMemoryAgentThread();
-
- // Act & Assert
- await Assert.ThrowsAsync(async () =>
- {
- await foreach (var _ in agent.RunStreamingAsync(messages, thread: invalidThread))
- {
- // Consume the stream
- }
- });
- }
-
- [Fact]
- public async Task RunStreamingAsync_GeneratesUniqueRunId_ForEachInvocationAsync()
- {
- // Arrange
- List 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 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 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(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 handlerMock = new();
- handlerMock
- .Protected()
- .Setup>(
- "SendAsync",
- ItExpr.IsAny(),
- ItExpr.IsAny())
- .ReturnsAsync(new HttpResponseMessage
- {
- StatusCode = HttpStatusCode.OK,
- Content = new StringContent(sseContent)
- });
-
- return new HttpClient(handlerMock.Object);
- }
-
- private HttpClient CreateMockHttpClientWithCapture(BaseEvent[] events, List capturedRunIds)
- {
- string sseContent = string.Join("", events.Select(e =>
- $"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
-
- Mock handlerMock = new();
- handlerMock
- .Protected()
- .Setup>(
- "SendAsync",
- ItExpr.IsAny(),
- ItExpr.IsAny())
- .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
- {
- }
-}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIAgentThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIAgentThreadTests.cs
deleted file mode 100644
index 1ddc39cdfc..0000000000
--- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIAgentThreadTests.cs
+++ /dev/null
@@ -1,132 +0,0 @@
-// 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(Json);
-
- // Act & Assert
- Assert.Throws(() => new AGUIAgentThread(serialized));
- }
-
- [Fact]
- public void Constructor_WithMissingWrappedState_ThrowsArgumentException()
- {
- // Arrange
- const string Json = """
- {}
- """;
- JsonElement serialized = JsonSerializer.Deserialize(Json);
-
- // Act & Assert
- Assert.Throws(() => 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);
- }
- }
-}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs
new file mode 100644
index 0000000000..06045343c1
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatClientTests.cs
@@ -0,0 +1,1378 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.AGUI.Shared;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AGUI.UnitTests;
+
+public sealed class AGUIAgentTests
+{
+ [Fact]
+ public async Task RunAsync_AggregatesStreamingUpdates_ReturnsCompleteMessagesAsync()
+ {
+ // Arrange
+ using HttpClient httpClient = this.CreateMockHttpClient(new 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" }
+ });
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []);
+ List 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" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []);
+ List 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();
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: "Test agent", name: "agent1");
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => 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" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: "Test agent", name: "agent1");
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ AgentRunResponse response = await agent.RunAsync(messages, thread: null);
+
+ // Assert
+ Assert.NotNull(response);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_YieldsAllEvents_FromServerStreamAsync()
+ {
+ // Arrange
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: "Test agent", name: "agent1");
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List 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();
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: "Test agent", name: "agent1");
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var _ in agent.RunStreamingAsync(messages: null!))
+ {
+ // Intentionally empty - consuming stream to trigger exception
+ }
+ });
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_WithNullThread_CreatesNewThreadAsync()
+ {
+ // Arrange
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: "Test agent", name: "agent1");
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List 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_GeneratesUniqueRunId_ForEachInvocationAsync()
+ {
+ // Arrange
+ var handler = new TestDelegatingHandler();
+ handler.AddResponseWithCapture(new BaseEvent[]
+ {
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ });
+ handler.AddResponseWithCapture(new BaseEvent[]
+ {
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ });
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []);
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ await foreach (var _ in agent.RunStreamingAsync(messages))
+ {
+ // Consume the stream
+ }
+ await foreach (var _ in agent.RunStreamingAsync(messages))
+ {
+ // Consume the stream
+ }
+
+ // Assert
+ Assert.Equal(2, handler.CapturedRunIds.Count);
+ Assert.NotEqual(handler.CapturedRunIds[0], handler.CapturedRunIds[1]);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_ReturnsStreamingUpdates_AfterCompletionAsync()
+ {
+ // Arrange
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []);
+ AgentThread thread = agent.GetNewThread();
+ List messages = [new ChatMessage(ChatRole.User, "Hello")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in agent.RunStreamingAsync(messages, thread))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Verify streaming updates were received
+ Assert.NotEmpty(updates);
+ Assert.Contains(updates, u => u.Text == "Hello");
+ }
+
+ [Fact]
+ public void DeserializeThread_WithValidState_ReturnsChatClientAgentThread()
+ {
+ // Arrange
+ using var httpClient = new HttpClient();
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []);
+ AgentThread originalThread = agent.GetNewThread();
+ JsonElement serialized = originalThread.Serialize();
+
+ // Act
+ AgentThread deserialized = agent.DeserializeThread(serialized);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.IsType(deserialized);
+ }
+
+ private HttpClient CreateMockHttpClient(BaseEvent[] events)
+ {
+ var handler = new TestDelegatingHandler();
+ handler.AddResponse(events);
+ return new HttpClient(handler);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_InvokesTools_WhenFunctionCallsReturnedAsync()
+ {
+ // Arrange
+ bool toolInvoked = false;
+ AIFunction testTool = AIFunctionFactory.Create(
+ (string location) =>
+ {
+ toolInvoked = true;
+ return $"Weather in {location}: Sunny, 72°F";
+ },
+ "GetWeather",
+ "Gets the current weather for a location");
+
+ using HttpClient httpClient = this.CreateMockHttpClientForToolCalls(
+ firstResponse:
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "GetWeather", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"location\":\"Seattle\"}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ],
+ secondResponse:
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "The weather is nice!" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [testTool]);
+ List messages = [new ChatMessage(ChatRole.User, "What's the weather?")];
+
+ // Act
+ List allUpdates = [];
+ await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages))
+ {
+ allUpdates.Add(update);
+ }
+
+ // Assert
+ Assert.True(toolInvoked, "Tool should have been invoked");
+ Assert.NotEmpty(allUpdates);
+ // Should have updates from both the tool call and the final response
+ Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionCallContent));
+ Assert.Contains(allUpdates, u => u.Contents.Any(c => c is TextContent));
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_DoesNotInvokeTools_WhenSomeToolsNotAvailableAsync()
+ {
+ // Arrange
+ bool tool1Invoked = false;
+ AIFunction tool1 = AIFunctionFactory.Create(
+ () => { tool1Invoked = true; return "Result1"; },
+ "Tool1");
+
+ // FunctionInvokingChatClient makes two calls: first gets tool calls, second returns final response
+ // When not all tools are available, it invokes the ones that ARE available
+ var handler = new TestDelegatingHandler();
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Response" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [tool1]); // Only tool1, not tool2
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List allUpdates = [];
+ await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages))
+ {
+ allUpdates.Add(update);
+ }
+
+ // Assert
+ // FunctionInvokingChatClient invokes Tool1 since it's available, even though Tool2 is not
+ Assert.True(tool1Invoked, "Tool1 should be invoked even though Tool2 is not available");
+ // Should have tool call results for Tool1 and an error result for Tool2
+ Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "call_1"));
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_HandlesToolInvocationErrors_GracefullyAsync()
+ {
+ // Arrange
+ AIFunction faultyTool = AIFunctionFactory.Create(
+ () =>
+ {
+ throw new InvalidOperationException("Tool failed!");
+#pragma warning disable CS0162 // Unreachable code detected
+ return string.Empty;
+#pragma warning restore CS0162 // Unreachable code detected
+ },
+ "FaultyTool");
+
+ using HttpClient httpClient = this.CreateMockHttpClientForToolCalls(
+ firstResponse:
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "FaultyTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ],
+ secondResponse:
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "I encountered an error." },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [faultyTool]);
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List allUpdates = [];
+ await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(messages))
+ {
+ allUpdates.Add(update);
+ }
+
+ // Assert - should complete without throwing
+ Assert.NotEmpty(allUpdates);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_InvokesMultipleTools_InSingleTurnAsync()
+ {
+ // Arrange
+ int tool1CallCount = 0;
+ int tool2CallCount = 0;
+ AIFunction tool1 = AIFunctionFactory.Create(() => { tool1CallCount++; return "Result1"; }, "Tool1");
+ AIFunction tool2 = AIFunctionFactory.Create(() => { tool2CallCount++; return "Result2"; }, "Tool2");
+
+ using HttpClient httpClient = this.CreateMockHttpClientForToolCalls(
+ firstResponse:
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "Tool1", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "Tool2", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ],
+ secondResponse:
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [tool1, tool2]);
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ await foreach (var _ in agent.RunStreamingAsync(messages))
+ {
+ }
+
+ // Assert
+ Assert.Equal(1, tool1CallCount);
+ Assert.Equal(1, tool2CallCount);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_UpdatesThreadWithToolMessages_AfterCompletionAsync()
+ {
+ // Arrange
+ AIFunction testTool = AIFunctionFactory.Create(() => "Result", "TestTool");
+
+ using HttpClient httpClient = this.CreateMockHttpClientForToolCalls(
+ firstResponse:
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "TestTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ],
+ secondResponse:
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Complete" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: [testTool]);
+ AgentThread thread = agent.GetNewThread();
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in agent.RunStreamingAsync(messages, thread))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Verify we received updates including tool calls
+ Assert.NotEmpty(updates);
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent));
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionResultContent));
+ Assert.Contains(updates, u => u.Text == "Complete");
+ }
+
+ private HttpClient CreateMockHttpClientForToolCalls(BaseEvent[] firstResponse, BaseEvent[] secondResponse)
+ {
+ var handler = new TestDelegatingHandler();
+ handler.AddResponse(firstResponse);
+ handler.AddResponse(secondResponse);
+ return new HttpClient(handler);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_WrapsServerFunctionCalls_InServerFunctionCallContentAsync()
+ {
+ // Arrange - Server returns a function call for a tool not in the client tool set
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ServerTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"arg\":\"value\"}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ // No tools provided - any function call from server is a "server function"
+ var options = new ChatOptions();
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Server function call should be presented as FunctionCallContent (unwrapped)
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool"));
+ // Should NOT contain ServerFunctionCallContent (it's internal and unwrapped before yielding)
+ Assert.DoesNotContain(updates, u => u.Contents.Any(c => c.GetType().Name == "ServerFunctionCallContent"));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_DoesNotWrapClientFunctionCalls_WhenToolInClientSetAsync()
+ {
+ // Arrange
+ AIFunction clientTool = AIFunctionFactory.Create(() => "Result", "ClientTool");
+
+ var handler = new TestDelegatingHandler();
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ClientTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { Tools = [clientTool] };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Should have function call and result (FunctionInvokingChatClient processed it)
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ClientTool"));
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "call_1"));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_HandlesMixedClientAndServerFunctions_InSameResponseAsync()
+ {
+ // Arrange
+ AIFunction clientTool = AIFunctionFactory.Create(() => "ClientResult", "ClientTool");
+
+ var handler = new TestDelegatingHandler();
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ClientTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "ServerTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { Tools = [clientTool] };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Should have both client and server function calls
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ClientTool"));
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool"));
+ // Client tool should have result
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "call_1"));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_PreservesConversationId_AcrossMultipleTurnsAsync()
+ {
+ // Arrange
+ var handler = new TestDelegatingHandler();
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "First" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Second" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { ConversationId = "my-conversation-123" };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act - First turn
+ List updates1 = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ updates1.Add(update);
+ }
+
+ // Second turn with same conversation ID
+ List updates2 = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ updates2.Add(update);
+ }
+
+ // Assert - Both turns should preserve the conversation ID
+ Assert.All(updates1, u => Assert.Equal("my-conversation-123", u.ConversationId));
+ Assert.All(updates2, u => Assert.Equal("my-conversation-123", u.ConversationId));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_ExtractsThreadId_FromServerResponseAsync()
+ {
+ // Arrange
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "server-thread-456", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "server-thread-456", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ // No conversation ID provided
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Should use thread ID from server
+ Assert.All(updates, u => Assert.Equal("server-thread-456", u.ConversationId));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_GeneratesThreadId_WhenNoneProvidedAsync()
+ {
+ // Arrange
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Should have a conversation ID (either from server or generated)
+ Assert.All(updates, u => Assert.NotNull(u.ConversationId));
+ Assert.All(updates, u => Assert.NotEmpty(u.ConversationId!));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_RemovesThreadIdFromFunctionCallProperties_BeforeYieldingAsync()
+ {
+ // Arrange
+ AIFunction clientTool = AIFunctionFactory.Create(() => "Result", "ClientTool");
+
+ var handler = new TestDelegatingHandler();
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ClientTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { Tools = [clientTool] };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Function call content should not have agui_thread_id in additional properties
+ var functionCallUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is FunctionCallContent));
+ Assert.NotNull(functionCallUpdate);
+ var fcc = functionCallUpdate.Contents.OfType().First();
+ Assert.True(fcc.AdditionalProperties?.ContainsKey("agui_thread_id") != true);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_PreservesConversationId_ThroughStreamingPathAsync()
+ {
+ // Arrange
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { ConversationId = "my-conversation-456" };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ ChatResponse response = await chatClient.GetResponseAsync(messages, options);
+
+ // Assert
+ Assert.Equal("my-conversation-456", response.ConversationId);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_UsesServerThreadId_WhenDifferentFromClientAsync()
+ {
+ // Arrange - Server returns different thread ID
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "server-generated-thread", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "server-generated-thread", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { ConversationId = "client-thread-123" };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Should use client's conversation ID (we provided it explicitly)
+ Assert.All(updates, u => Assert.Equal("client-thread-123", u.ConversationId));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_FullConversationFlow_WithMixedFunctionsAsync()
+ {
+ // Arrange
+ AIFunction clientTool = AIFunctionFactory.Create(() => "ClientResult", "ClientTool");
+
+ var handler = new TestDelegatingHandler();
+ // First response: client function call (FunctionInvokingChatClient will handle this)
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_client", ToolCallName = "ClientTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_client", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_client" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ // Second response: after client function execution, return final text
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Complete" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { Tools = [clientTool] };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ string? conversationId = null;
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ updates.Add(update);
+ conversationId ??= update.ConversationId;
+ }
+
+ // Assert
+ // Should have client function call and result
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ClientTool"));
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "call_client"));
+ // Should have final text response
+ Assert.Contains(updates, u => u.Contents.Any(c => c is TextContent));
+ // All updates should have consistent conversation ID
+ Assert.NotNull(conversationId);
+ Assert.All(updates, u => Assert.Equal(conversationId, u.ConversationId));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_ExtractsThreadIdFromFunctionCall_OnSubsequentTurnsAsync()
+ {
+ // Arrange
+ AIFunction clientTool = AIFunctionFactory.Create(() => "Result", "ClientTool");
+
+ var handler = new TestDelegatingHandler();
+ // First turn: client function call
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ClientTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ // FunctionInvokingChatClient automatically calls again after function execution
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "First done" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ // Third turn: user makes another request with conversation history
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run3" },
+ new TextMessageStartEvent { MessageId = "msg3", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg3", Delta = "Second done" },
+ new TextMessageEndEvent { MessageId = "msg3" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run3" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { Tools = [clientTool] };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act - First turn
+ List conversation = new(messages);
+ string? conversationId = null;
+ await foreach (var update in chatClient.GetStreamingResponseAsync(conversation, options))
+ {
+ conversationId ??= update.ConversationId;
+ // Collect all updates to build the conversation history
+ foreach (var content in update.Contents)
+ {
+ if (content is FunctionCallContent fcc)
+ {
+ conversation.Add(new ChatMessage(ChatRole.Assistant, [fcc]));
+ }
+ else if (content is FunctionResultContent frc)
+ {
+ conversation.Add(new ChatMessage(ChatRole.Tool, [frc]));
+ }
+ else if (content is TextContent tc)
+ {
+ var existingAssistant = conversation.LastOrDefault(m => m.Role == ChatRole.Assistant && m.Contents.Any(c => c is TextContent));
+ if (existingAssistant == null)
+ {
+ conversation.Add(new ChatMessage(ChatRole.Assistant, [tc]));
+ }
+ }
+ }
+ }
+
+ // Act - Second turn with conversation history including function call
+ // The thread ID should be extracted from the function call in the conversation history
+ options.ConversationId = conversationId;
+ List secondTurnUpdates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(conversation, options))
+ {
+ secondTurnUpdates.Add(update);
+ }
+
+ // Assert - Second turn should maintain the same conversation ID
+ Assert.NotNull(conversationId);
+ Assert.All(secondTurnUpdates, u => Assert.Equal(conversationId, u.ConversationId));
+ Assert.Contains(secondTurnUpdates, u => u.Contents.Any(c => c is TextContent));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_MaintainsConsistentThreadId_AcrossMultipleTurnsAsync()
+ {
+ // Arrange
+ var handler = new TestDelegatingHandler();
+ // Turn 1
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Response 1" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ // Turn 2
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Response 2" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ // Turn 3
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run3" },
+ new TextMessageStartEvent { MessageId = "msg3", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg3", Delta = "Response 3" },
+ new TextMessageEndEvent { MessageId = "msg3" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run3" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { ConversationId = "my-conversation" };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act - Execute 3 turns
+ string? conversationId = null;
+ for (int i = 0; i < 3; i++)
+ {
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ conversationId ??= update.ConversationId;
+ Assert.Equal("my-conversation", update.ConversationId);
+ }
+ }
+
+ // Assert
+ Assert.Equal("my-conversation", conversationId);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_HandlesEmptyThreadId_GracefullyAsync()
+ {
+ // Arrange - Server returns empty thread ID
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = string.Empty, RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = string.Empty, RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Should generate a conversation ID even with empty server thread ID
+ Assert.NotEmpty(updates);
+ Assert.All(updates, u => Assert.NotNull(u.ConversationId));
+ Assert.All(updates, u => Assert.NotEmpty(u.ConversationId!));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_AdaptsToServerThreadIdChange_MidConversationAsync()
+ {
+ // Arrange
+ var handler = new TestDelegatingHandler();
+ // First turn: server returns thread-A
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread-A", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "First" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "thread-A", RunId = "run1" }
+ ]);
+ // Second turn: provide thread-A but server returns thread-B
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread-B", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Second" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread-B", RunId = "run2" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act - First turn
+ string? firstConversationId = null;
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
+ {
+ firstConversationId ??= update.ConversationId;
+ }
+
+ // Second turn - provide the conversation ID from first turn
+ var options = new ChatOptions { ConversationId = firstConversationId };
+ string? secondConversationId = null;
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ secondConversationId ??= update.ConversationId;
+ }
+
+ // Assert - Should use client-provided conversation ID, not server's changed ID
+ Assert.Equal("thread-A", firstConversationId);
+ Assert.Equal("thread-A", secondConversationId); // Client overrides server's thread-B
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_PresentsServerFunctionResults_AsRegularFunctionResultsAsync()
+ {
+ // Arrange - Server function (not in client tool set)
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ServerTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{\"arg\":\"value\"}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ List updates = [];
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - Server function should be presented as FunctionCallContent (unwrapped from ServerFunctionCallContent)
+ Assert.Contains(updates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool"));
+ // Verify it's NOT a ServerFunctionCallContent (internal type should be unwrapped)
+ Assert.All(updates, u => Assert.DoesNotContain(u.Contents, c => c.GetType().Name == "ServerFunctionCallContent"));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_HandlesMultipleServerFunctions_InSequenceAsync()
+ {
+ // Arrange
+ var handler = new TestDelegatingHandler();
+ // Turn 1: Server function 1
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ServerTool1", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ // Turn 2: Server function 2
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new ToolCallStartEvent { ToolCallId = "call_2", ToolCallName = "ServerTool2", ParentMessageId = "msg2" },
+ new ToolCallArgsEvent { ToolCallId = "call_2", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ // Turn 3: Final response
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run3" },
+ new TextMessageStartEvent { MessageId = "msg3", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg3", Delta = "Complete" },
+ new TextMessageEndEvent { MessageId = "msg3" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run3" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { ConversationId = "conv1" };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act - Execute all 3 turns
+ List allUpdates = [];
+ for (int i = 0; i < 3; i++)
+ {
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ allUpdates.Add(update);
+ }
+ }
+
+ // Assert
+ Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool1"));
+ Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "ServerTool2"));
+ Assert.Contains(allUpdates, u => u.Contents.Any(c => c is TextContent));
+ Assert.All(allUpdates, u => Assert.Equal("conv1", u.ConversationId));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_MaintainsThreadIdConsistency_WithOnlyServerFunctionsAsync()
+ {
+ // Arrange - Full conversation with only server functions
+ var handler = new TestDelegatingHandler();
+ // Turn 1: Server function
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new ToolCallStartEvent { ToolCallId = "call_1", ToolCallName = "ServerTool", ParentMessageId = "msg1" },
+ new ToolCallArgsEvent { ToolCallId = "call_1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "call_1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ // Turn 2: Final response
+ handler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
+ new TextMessageStartEvent { MessageId = "msg2", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg2", Delta = "Done" },
+ new TextMessageEndEvent { MessageId = "msg2" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
+ ]);
+ using HttpClient httpClient = new(handler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ string? conversationId = null;
+ List allUpdates = [];
+ for (int i = 0; i < 2; i++)
+ {
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
+ {
+ conversationId ??= update.ConversationId;
+ allUpdates.Add(update);
+ }
+ }
+
+ // Assert - Thread ID should be consistent without client function invocations
+ Assert.NotNull(conversationId);
+ Assert.All(allUpdates, u => Assert.Equal(conversationId, u.ConversationId));
+ Assert.Contains(allUpdates, u => u.Contents.Any(c => c is FunctionCallContent));
+ Assert.Contains(allUpdates, u => u.Contents.Any(c => c is TextContent));
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_StoresConversationIdInAdditionalProperties_WithoutMutatingOptionsAsync()
+ {
+ // Arrange
+ using HttpClient httpClient = this.CreateMockHttpClient(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { ConversationId = "my-conversation-123" };
+ var originalConversationId = options.ConversationId;
+ var originalAdditionalProperties = options.AdditionalProperties;
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ // Just consume the stream
+ }
+
+ // Assert - Original options should not be mutated
+ Assert.Equal(originalConversationId, options.ConversationId);
+ Assert.Equal(originalAdditionalProperties, options.AdditionalProperties);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_EnsuresConversationIdIsNull_ForInnerClientAsync()
+ {
+ // Arrange - Use a custom handler to capture what's sent to the inner layer
+ var captureHandler = new CapturingTestDelegatingHandler();
+ captureHandler.AddResponse(
+ [
+ new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
+ new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
+ new TextMessageContentEvent { MessageId = "msg1", Delta = "Hello" },
+ new TextMessageEndEvent { MessageId = "msg1" },
+ new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
+ ]);
+ using HttpClient httpClient = new(captureHandler);
+
+ var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
+ var options = new ChatOptions { ConversationId = "my-conversation-123" };
+ List messages = [new ChatMessage(ChatRole.User, "Test")];
+
+ // Act
+ await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, options))
+ {
+ // Just consume the stream
+ }
+
+ // Assert - The inner handler should see the full message history being sent
+ // This is implicitly tested by the fact that all messages are sent in the request
+ // AG-UI requirement: full history on every turn (which happens when ConversationId is null for FunctionInvokingChatClient)
+ Assert.True(captureHandler.RequestWasMade);
+ }
+}
+
+internal sealed class TestDelegatingHandler : DelegatingHandler
+{
+ private readonly Queue>> _responseFactories = new();
+ private readonly List _capturedRunIds = new();
+
+ public IReadOnlyList CapturedRunIds => this._capturedRunIds;
+
+ public void AddResponse(BaseEvent[] events)
+ {
+ this._responseFactories.Enqueue(_ => Task.FromResult(CreateResponse(events)));
+ }
+
+ public void AddResponseWithCapture(BaseEvent[] events)
+ {
+ this._responseFactories.Enqueue(async request =>
+ {
+ await this.CaptureRunIdAsync(request);
+ return CreateResponse(events);
+ });
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (this._responseFactories.Count == 0)
+ {
+ // Log request count for debugging
+ throw new InvalidOperationException($"No more responses configured for TestDelegatingHandler. Total requests made: {this._capturedRunIds.Count}");
+ }
+
+ var factory = this._responseFactories.Dequeue();
+ return await factory(request);
+ }
+
+ private static HttpResponseMessage CreateResponse(BaseEvent[] events)
+ {
+ string sseContent = string.Join("", events.Select(e =>
+ $"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
+
+ return new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent(sseContent)
+ };
+ }
+
+ private async Task CaptureRunIdAsync(HttpRequestMessage request)
+ {
+ string requestBody = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
+ RunAgentInput? input = JsonSerializer.Deserialize(requestBody, AGUIJsonSerializerContext.Default.RunAgentInput);
+ if (input != null)
+ {
+ this._capturedRunIds.Add(input.RunId);
+ }
+ }
+}
+
+internal sealed class CapturingTestDelegatingHandler : DelegatingHandler
+{
+ private readonly Queue>> _responseFactories = new();
+
+ public bool RequestWasMade { get; private set; }
+
+ public void AddResponse(BaseEvent[] events)
+ {
+ this._responseFactories.Enqueue(_ => Task.FromResult(CreateResponse(events)));
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ this.RequestWasMade = true;
+
+ if (this._responseFactories.Count == 0)
+ {
+ throw new InvalidOperationException("No more responses configured for CapturingTestDelegatingHandler.");
+ }
+
+ var factory = this._responseFactories.Dequeue();
+ return await factory(request);
+ }
+
+ private static HttpResponseMessage CreateResponse(BaseEvent[] events)
+ {
+ string sseContent = string.Join("", events.Select(e =>
+ $"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
+
+ return new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent(sseContent)
+ };
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs
index d57cac1990..4a8d7908e9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIChatMessageExtensionsTests.cs
@@ -3,11 +3,36 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Text.Json.Serialization;
using Microsoft.Agents.AI.AGUI.Shared;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.AGUI.UnitTests;
+// Custom complex type for testing tool call parameters
+public sealed class WeatherRequest
+{
+ public string Location { get; set; } = string.Empty;
+ public string Units { get; set; } = "celsius";
+ public bool IncludeForecast { get; set; }
+}
+
+// Custom complex type for testing tool call results
+public sealed class WeatherResponse
+{
+ public double Temperature { get; set; }
+ public string Conditions { get; set; } = string.Empty;
+ public DateTime Timestamp { get; set; }
+}
+
+// Custom JsonSerializerContext for the custom types
+[JsonSerializable(typeof(WeatherRequest))]
+[JsonSerializable(typeof(WeatherResponse))]
+[JsonSerializable(typeof(Dictionary))]
+internal sealed partial class CustomTypesContext : JsonSerializerContext
+{
+}
+
///
/// Unit tests for the class.
///
@@ -20,7 +45,7 @@ public sealed class AGUIChatMessageExtensionsTests
List aguiMessages = [];
// Act
- IEnumerable chatMessages = aguiMessages.AsChatMessages();
+ IEnumerable chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options);
// Assert
Assert.NotNull(chatMessages);
@@ -33,16 +58,15 @@ public sealed class AGUIChatMessageExtensionsTests
// Arrange
List aguiMessages =
[
- new AGUIMessage
+ new AGUIUserMessage
{
Id = "msg1",
- Role = AGUIRoles.User,
Content = "Hello"
}
];
// Act
- IEnumerable chatMessages = aguiMessages.AsChatMessages();
+ IEnumerable chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options);
// Assert
ChatMessage message = Assert.Single(chatMessages);
@@ -56,13 +80,13 @@ public sealed class AGUIChatMessageExtensionsTests
// Arrange
List 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" }
+ new AGUIUserMessage { Id = "msg1", Content = "First" },
+ new AGUIAssistantMessage { Id = "msg2", Content = "Second" },
+ new AGUIUserMessage { Id = "msg3", Content = "Third" }
];
// Act
- List chatMessages = aguiMessages.AsChatMessages().ToList();
+ List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(3, chatMessages.Count);
@@ -77,14 +101,14 @@ public sealed class AGUIChatMessageExtensionsTests
// Arrange
List 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" }
+ new AGUISystemMessage { Id = "msg1", Content = "System message" },
+ new AGUIUserMessage { Id = "msg2", Content = "User message" },
+ new AGUIAssistantMessage { Id = "msg3", Content = "Assistant message" },
+ new AGUIDeveloperMessage { Id = "msg4", Content = "Developer message" }
];
// Act
- List chatMessages = aguiMessages.AsChatMessages().ToList();
+ List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(4, chatMessages.Count);
@@ -101,7 +125,7 @@ public sealed class AGUIChatMessageExtensionsTests
List chatMessages = [];
// Act
- IEnumerable aguiMessages = chatMessages.AsAGUIMessages();
+ IEnumerable aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options);
// Assert
Assert.NotNull(aguiMessages);
@@ -118,13 +142,13 @@ public sealed class AGUIChatMessageExtensionsTests
];
// Act
- IEnumerable aguiMessages = chatMessages.AsAGUIMessages();
+ IEnumerable aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options);
// Assert
AGUIMessage message = Assert.Single(aguiMessages);
Assert.Equal("msg1", message.Id);
Assert.Equal(AGUIRoles.User, message.Role);
- Assert.Equal("Hello", message.Content);
+ Assert.Equal("Hello", ((AGUIUserMessage)message).Content);
}
[Fact]
@@ -139,13 +163,13 @@ public sealed class AGUIChatMessageExtensionsTests
];
// Act
- List aguiMessages = chatMessages.AsAGUIMessages().ToList();
+ List aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).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);
+ Assert.Equal("First", ((AGUIUserMessage)aguiMessages[0]).Content);
+ Assert.Equal("Second", ((AGUIAssistantMessage)aguiMessages[1]).Content);
+ Assert.Equal("Third", ((AGUIUserMessage)aguiMessages[2]).Content);
}
[Fact]
@@ -158,7 +182,7 @@ public sealed class AGUIChatMessageExtensionsTests
];
// Act
- IEnumerable aguiMessages = chatMessages.AsAGUIMessages();
+ IEnumerable aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options);
// Assert
AGUIMessage message = Assert.Single(aguiMessages);
@@ -185,4 +209,438 @@ public sealed class AGUIChatMessageExtensionsTests
// Arrange & Act & Assert
Assert.Throws(() => AGUIChatMessageExtensions.MapChatRole("unknown"));
}
+
+ [Fact]
+ public void AsAGUIMessages_WithToolResultMessage_SerializesResultCorrectly()
+ {
+ // Arrange
+ var result = new Dictionary { ["temperature"] = 72, ["condition"] = "Sunny" };
+ FunctionResultContent toolResult = new("call_123", result);
+ ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]);
+ List messages = [toolMessage];
+
+ // Act
+ List aguiMessages = messages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
+
+ // Assert
+ AGUIMessage aguiMessage = Assert.Single(aguiMessages);
+ Assert.Equal(AGUIRoles.Tool, aguiMessage.Role);
+ Assert.Equal("call_123", ((AGUIToolMessage)aguiMessage).ToolCallId);
+ Assert.NotEmpty(((AGUIToolMessage)aguiMessage).Content);
+ // Content should be serialized JSON
+ Assert.Contains("temperature", ((AGUIToolMessage)aguiMessage).Content);
+ Assert.Contains("72", ((AGUIToolMessage)aguiMessage).Content);
+ }
+
+ [Fact]
+ public void AsAGUIMessages_WithNullToolResult_HandlesGracefully()
+ {
+ // Arrange
+ FunctionResultContent toolResult = new("call_456", null);
+ ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]);
+ List messages = [toolMessage];
+
+ // Act
+ List aguiMessages = messages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
+
+ // Assert
+ AGUIMessage aguiMessage = Assert.Single(aguiMessages);
+ Assert.Equal(AGUIRoles.Tool, aguiMessage.Role);
+ Assert.Equal("call_456", ((AGUIToolMessage)aguiMessage).ToolCallId);
+ Assert.Equal(string.Empty, ((AGUIToolMessage)aguiMessage).Content);
+ }
+
+ [Fact]
+ public void AsAGUIMessages_WithoutTypeInfoResolver_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ FunctionResultContent toolResult = new("call_789", "Result");
+ ChatMessage toolMessage = new(ChatRole.Tool, [toolResult]);
+ List messages = [toolMessage];
+ System.Text.Json.JsonSerializerOptions optionsWithoutResolver = new();
+
+ // Act & Assert
+ NotSupportedException ex = Assert.Throws(() => messages.AsAGUIMessages(optionsWithoutResolver).ToList());
+ Assert.Contains("JsonTypeInfo", ex.Message);
+ }
+
+ [Fact]
+ public void AsChatMessages_WithToolMessage_DeserializesResultCorrectly()
+ {
+ // Arrange
+ const string JsonContent = "{\"status\":\"success\",\"value\":42}";
+ List aguiMessages =
+ [
+ new AGUIToolMessage
+ {
+ Id = "msg1",
+ Content = JsonContent,
+ ToolCallId = "call_abc"
+ }
+ ];
+
+ // Act
+ List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
+
+ // Assert
+ ChatMessage message = Assert.Single(chatMessages);
+ Assert.Equal(ChatRole.Tool, message.Role);
+ FunctionResultContent result = Assert.IsType(message.Contents[0]);
+ Assert.Equal("call_abc", result.CallId);
+ Assert.NotNull(result.Result);
+ }
+
+ [Fact]
+ public void AsChatMessages_WithEmptyToolContent_CreatesNullResult()
+ {
+ // Arrange
+ List aguiMessages =
+ [
+ new AGUIToolMessage
+ {
+ Id = "msg1",
+ Content = string.Empty,
+ ToolCallId = "call_def"
+ }
+ ];
+
+ // Act
+ List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
+
+ // Assert
+ ChatMessage message = Assert.Single(chatMessages);
+ FunctionResultContent result = Assert.IsType(message.Contents[0]);
+ Assert.Equal("call_def", result.CallId);
+ Assert.Equal(string.Empty, result.Result);
+ }
+
+ [Fact]
+ public void AsChatMessages_WithToolMessageWithoutCallId_TreatsAsRegularMessage()
+ {
+ // Arrange - use valid JSON for Content
+ List aguiMessages =
+ [
+ new AGUIToolMessage
+ {
+ Id = "msg1",
+ Content = "{\"result\":\"Some content\"}",
+ ToolCallId = string.Empty
+ }
+ ];
+
+ // Act
+ List chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
+
+ // Assert
+ ChatMessage message = Assert.Single(chatMessages);
+ Assert.Equal(ChatRole.Tool, message.Role);
+ var resultContent = Assert.IsType(message.Contents.First());
+ Assert.Equal(string.Empty, resultContent.CallId);
+ }
+
+ [Fact]
+ public void RoundTrip_ToolResultMessage_PreservesData()
+ {
+ // Arrange
+ var resultData = new Dictionary { ["location"] = "Seattle", ["temperature"] = 68, ["forecast"] = "Partly cloudy" };
+ FunctionResultContent originalResult = new("call_roundtrip", resultData);
+ ChatMessage originalMessage = new(ChatRole.Tool, [originalResult]);
+
+ // Act - Convert to AGUI and back
+ List originalList = [originalMessage];
+ AGUIMessage aguiMessage = originalList.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).Single();
+ List aguiList = [aguiMessage];
+ ChatMessage reconstructedMessage = aguiList.AsChatMessages(AGUIJsonSerializerContext.Default.Options).Single();
+
+ // Assert
+ Assert.Equal(ChatRole.Tool, reconstructedMessage.Role);
+ FunctionResultContent reconstructedResult = Assert.IsType(reconstructedMessage.Contents[0]);
+ Assert.Equal("call_roundtrip", reconstructedResult.CallId);
+ Assert.NotNull(reconstructedResult.Result);
+ }
+
+ [Fact]
+ public void MapChatRole_WithToolRole_ReturnsToolChatRole()
+ {
+ // Arrange & Act
+ ChatRole role = AGUIChatMessageExtensions.MapChatRole(AGUIRoles.Tool);
+
+ // Assert
+ Assert.Equal(ChatRole.Tool, role);
+ }
+
+ #region Custom Type Serialization Tests
+
+ [Fact]
+ public void AsChatMessages_WithFunctionCallContainingCustomType_SerializesCorrectly()
+ {
+ // Arrange
+ var customRequest = new WeatherRequest { Location = "Seattle", Units = "fahrenheit", IncludeForecast = true };
+ var parameters = new Dictionary
+ {
+ ["location"] = customRequest.Location,
+ ["units"] = customRequest.Units,
+ ["includeForecast"] = customRequest.IncludeForecast
+ };
+
+ List aguiMessages =
+ [
+ new AGUIAssistantMessage
+ {
+ Id = "msg1",
+ ToolCalls =
+ [
+ new AGUIToolCall
+ {
+ Id = "call_1",
+ Function = new AGUIFunctionCall
+ {
+ Name = "GetWeather",
+ Arguments = System.Text.Json.JsonSerializer.Serialize(parameters, AGUIJsonSerializerContext.Default.Options)
+ }
+ }
+ ]
+ }
+ ];
+
+ // Combine contexts for serialization
+ var combinedOptions = new System.Text.Json.JsonSerializerOptions
+ {
+ TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
+ AGUIJsonSerializerContext.Default,
+ CustomTypesContext.Default)
+ };
+
+ // Act
+ IEnumerable chatMessages = aguiMessages.AsChatMessages(combinedOptions);
+
+ // Assert
+ ChatMessage message = Assert.Single(chatMessages);
+ Assert.Equal(ChatRole.Assistant, message.Role);
+ var toolCallContent = Assert.IsType(message.Contents.First());
+ Assert.Equal("call_1", toolCallContent.CallId);
+ Assert.Equal("GetWeather", toolCallContent.Name);
+ Assert.NotNull(toolCallContent.Arguments);
+ // Compare as strings since deserialization produces JsonElement objects
+ Assert.Equal("Seattle", ((System.Text.Json.JsonElement)toolCallContent.Arguments["location"]!).GetString());
+ Assert.Equal("fahrenheit", ((System.Text.Json.JsonElement)toolCallContent.Arguments["units"]!).GetString());
+ Assert.True(toolCallContent.Arguments["includeForecast"] is System.Text.Json.JsonElement j && j.GetBoolean());
+ }
+
+ [Fact]
+ public void AsAGUIMessages_WithFunctionResultContainingCustomType_SerializesCorrectly()
+ {
+ // Arrange
+ var customResponse = new WeatherResponse { Temperature = 72.5, Conditions = "Sunny", Timestamp = DateTime.UtcNow };
+ var resultObject = new Dictionary
+ {
+ ["temperature"] = customResponse.Temperature,
+ ["conditions"] = customResponse.Conditions,
+ ["timestamp"] = customResponse.Timestamp.ToString("O")
+ };
+
+ var resultJson = System.Text.Json.JsonSerializer.Serialize(resultObject, AGUIJsonSerializerContext.Default.Options);
+ var functionResult = new FunctionResultContent("call_1", System.Text.Json.JsonSerializer.Deserialize(resultJson, AGUIJsonSerializerContext.Default.Options));
+ List chatMessages =
+ [
+ new ChatMessage(ChatRole.Tool, [functionResult])
+ ];
+
+ // Combine contexts for serialization
+ var combinedOptions = new System.Text.Json.JsonSerializerOptions
+ {
+ TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
+ AGUIJsonSerializerContext.Default,
+ CustomTypesContext.Default)
+ };
+
+ // Act
+ IEnumerable aguiMessages = chatMessages.AsAGUIMessages(combinedOptions);
+
+ // Assert
+ AGUIMessage message = Assert.Single(aguiMessages);
+ var toolMessage = Assert.IsType(message);
+ Assert.Equal("call_1", toolMessage.ToolCallId);
+ Assert.NotNull(toolMessage.Content);
+
+ // Verify the content can be deserialized back
+ var deserializedResult = System.Text.Json.JsonSerializer.Deserialize>(
+ toolMessage.Content,
+ combinedOptions);
+ Assert.NotNull(deserializedResult);
+ Assert.Equal(72.5, deserializedResult["temperature"].GetDouble());
+ Assert.Equal("Sunny", deserializedResult["conditions"].GetString());
+ }
+
+ [Fact]
+ public void RoundTrip_WithCustomTypesInFunctionCallAndResult_PreservesData()
+ {
+ // Arrange
+ var customRequest = new WeatherRequest { Location = "New York", Units = "celsius", IncludeForecast = false };
+ var parameters = new Dictionary
+ {
+ ["location"] = customRequest.Location,
+ ["units"] = customRequest.Units,
+ ["includeForecast"] = customRequest.IncludeForecast
+ };
+
+ var customResponse = new WeatherResponse { Temperature = 22.3, Conditions = "Cloudy", Timestamp = DateTime.UtcNow };
+ var resultObject = new Dictionary
+ {
+ ["temperature"] = customResponse.Temperature,
+ ["conditions"] = customResponse.Conditions,
+ ["timestamp"] = customResponse.Timestamp.ToString("O")
+ };
+
+ var resultJson = System.Text.Json.JsonSerializer.Serialize(resultObject, AGUIJsonSerializerContext.Default.Options);
+ var resultElement = System.Text.Json.JsonSerializer.Deserialize(resultJson, AGUIJsonSerializerContext.Default.Options);
+
+ List originalChatMessages =
+ [
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_1", "GetWeather", parameters)]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call_1", resultElement)])
+ ];
+
+ // Combine contexts for serialization
+ var combinedOptions = new System.Text.Json.JsonSerializerOptions
+ {
+ TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
+ AGUIJsonSerializerContext.Default,
+ CustomTypesContext.Default)
+ };
+
+ // Act - Convert to AGUI messages and back
+ IEnumerable aguiMessages = originalChatMessages.AsAGUIMessages(combinedOptions);
+ List roundTrippedChatMessages = aguiMessages.AsChatMessages(combinedOptions).ToList();
+
+ // Assert
+ Assert.Equal(2, roundTrippedChatMessages.Count);
+
+ // Verify function call
+ ChatMessage callMessage = roundTrippedChatMessages[0];
+ Assert.Equal(ChatRole.Assistant, callMessage.Role);
+ var functionCall = Assert.IsType(callMessage.Contents.First());
+ Assert.Equal("call_1", functionCall.CallId);
+ Assert.Equal("GetWeather", functionCall.Name);
+ Assert.NotNull(functionCall.Arguments);
+ // Compare string values from JsonElement
+ Assert.Equal(customRequest.Location, functionCall.Arguments["location"]?.ToString());
+ Assert.Equal(customRequest.Units, functionCall.Arguments["units"]?.ToString());
+
+ // Verify function result
+ ChatMessage resultMessage = roundTrippedChatMessages[1];
+ Assert.Equal(ChatRole.Tool, resultMessage.Role);
+ var functionResultContent = Assert.IsType(resultMessage.Contents.First());
+ Assert.Equal("call_1", functionResultContent.CallId);
+ Assert.NotNull(functionResultContent.Result);
+ }
+
+ [Fact]
+ public void AsAGUIMessages_WithNestedCustomObjects_HandlesComplexSerialization()
+ {
+ // Arrange - nested custom types
+ var nestedParameters = new Dictionary
+ {
+ ["request"] = new Dictionary
+ {
+ ["location"] = "Boston",
+ ["options"] = new Dictionary
+ {
+ ["units"] = "fahrenheit",
+ ["includeHumidity"] = true,
+ ["daysAhead"] = 5
+ }
+ }
+ };
+
+ var functionCall = new FunctionCallContent("call_nested", "GetDetailedWeather", nestedParameters);
+ List chatMessages =
+ [
+ new ChatMessage(ChatRole.Assistant, [functionCall])
+ ];
+
+ // Combine contexts for serialization
+ var combinedOptions = new System.Text.Json.JsonSerializerOptions
+ {
+ TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
+ AGUIJsonSerializerContext.Default,
+ CustomTypesContext.Default)
+ };
+
+ // Act
+ IEnumerable aguiMessages = chatMessages.AsAGUIMessages(combinedOptions);
+
+ // Assert
+ AGUIMessage message = Assert.Single(aguiMessages);
+ var assistantMessage = Assert.IsType(message);
+ Assert.NotNull(assistantMessage.ToolCalls);
+ var toolCall = Assert.Single(assistantMessage.ToolCalls);
+ Assert.Equal("call_nested", toolCall.Id);
+ Assert.Equal("GetDetailedWeather", toolCall.Function?.Name);
+
+ // Verify nested structure is preserved
+ var deserializedArgs = System.Text.Json.JsonSerializer.Deserialize>(
+ toolCall.Function?.Arguments ?? "{}",
+ combinedOptions);
+ Assert.NotNull(deserializedArgs);
+ Assert.True(deserializedArgs.ContainsKey("request"));
+ }
+
+ [Fact]
+ public void AsAGUIMessages_WithDictionaryContainingCustomTypes_SerializesDirectly()
+ {
+ // Arrange - Create a dictionary with custom type values (not flattened)
+ var customRequest = new WeatherRequest { Location = "Tokyo", Units = "celsius", IncludeForecast = true };
+ var parameters = new Dictionary
+ {
+ ["customRequest"] = customRequest, // Custom type as value
+ ["simpleString"] = "test",
+ ["simpleNumber"] = 42
+ };
+
+ List chatMessages =
+ [
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_custom", "ProcessWeather", parameters)])
+ ];
+
+ // Combine contexts for serialization
+ var combinedOptions = new System.Text.Json.JsonSerializerOptions
+ {
+ TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(
+ AGUIJsonSerializerContext.Default,
+ CustomTypesContext.Default)
+ };
+
+ // Act
+ IEnumerable aguiMessages = chatMessages.AsAGUIMessages(combinedOptions);
+
+ // Assert
+ AGUIMessage message = Assert.Single(aguiMessages);
+ var assistantMessage = Assert.IsType(message);
+ Assert.NotNull(assistantMessage.ToolCalls);
+ var toolCall = Assert.Single(assistantMessage.ToolCalls);
+ Assert.Equal("call_custom", toolCall.Id);
+ Assert.Equal("ProcessWeather", toolCall.Function?.Name);
+
+ // Verify custom type was serialized correctly without flattening
+ var deserializedArgs = System.Text.Json.JsonSerializer.Deserialize>(
+ toolCall.Function?.Arguments ?? "{}",
+ combinedOptions);
+ Assert.NotNull(deserializedArgs);
+ Assert.True(deserializedArgs.ContainsKey("customRequest"));
+ Assert.True(deserializedArgs.ContainsKey("simpleString"));
+ Assert.True(deserializedArgs.ContainsKey("simpleNumber"));
+
+ // Verify the custom type properties are accessible
+ var customRequestElement = deserializedArgs["customRequest"];
+ Assert.Equal("Tokyo", customRequestElement.GetProperty("Location").GetString());
+ Assert.Equal("celsius", customRequestElement.GetProperty("Units").GetString());
+ Assert.True(customRequestElement.GetProperty("IncludeForecast").GetBoolean());
+
+ // Verify simple types
+ Assert.Equal("test", deserializedArgs["simpleString"].GetString());
+ Assert.Equal(42, deserializedArgs["simpleNumber"].GetInt32());
+ }
+
+ #endregion
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs
index fb40dc622e..ec4f34db14 100644
--- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIHttpServiceTests.cs
@@ -37,7 +37,7 @@ public sealed class AGUIHttpServiceTests
{
ThreadId = "thread1",
RunId = "run1",
- Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
+ Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act
@@ -66,7 +66,7 @@ public sealed class AGUIHttpServiceTests
{
ThreadId = "thread1",
RunId = "run1",
- Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
+ Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act & Assert
@@ -96,7 +96,7 @@ public sealed class AGUIHttpServiceTests
{
ThreadId = "thread1",
RunId = "run1",
- Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
+ Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act
@@ -126,7 +126,7 @@ public sealed class AGUIHttpServiceTests
{
ThreadId = "thread1",
RunId = "run1",
- Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
+ Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act
@@ -162,7 +162,7 @@ public sealed class AGUIHttpServiceTests
{
ThreadId = "thread1",
RunId = "run1",
- Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
+ Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act & Assert
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs
index f1b1971f20..566e69d992 100644
--- a/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.UnitTests/AGUIJsonSerializerContextTests.cs
@@ -20,7 +20,7 @@ public sealed class AGUIJsonSerializerContextTests
{
ThreadId = "thread1",
RunId = "run1",
- Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }]
+ Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
// Act
@@ -72,9 +72,9 @@ public sealed class AGUIJsonSerializerContextTests
{
ThreadId = "thread1",
RunId = "run1",
- Messages = [new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "Test" }],
+ Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }],
State = JsonSerializer.SerializeToElement(new { key = "value" }),
- Context = new Dictionary { ["ctx1"] = "value1" },
+ Context = [new AGUIContextItem { Description = "ctx1", Value = "value1" }],
ForwardedProperties = JsonSerializer.SerializeToElement(new { prop1 = "val1" })
};
@@ -119,10 +119,13 @@ public sealed class AGUIJsonSerializerContextTests
RunId = "run1",
Messages =
[
- new AGUIMessage { Id = "m1", Role = AGUIRoles.User, Content = "First" },
- new AGUIMessage { Id = "m2", Role = AGUIRoles.Assistant, Content = "Second" }
+ new AGUIUserMessage { Id = "m1", Content = "First" },
+ new AGUIAssistantMessage { Id = "m2", Content = "Second" }
],
- Context = new Dictionary { ["key1"] = "value1", ["key2"] = "value2" }
+ Context = [
+ new AGUIContextItem { Description = "key1", Value = "value1" },
+ new AGUIContextItem { Description = "key2", Value = "value2" }
+ ]
};
// Act
@@ -134,7 +137,7 @@ public sealed class AGUIJsonSerializerContextTests
Assert.Equal(original.ThreadId, deserialized.ThreadId);
Assert.Equal(original.RunId, deserialized.RunId);
Assert.Equal(2, deserialized.Messages.Count());
- Assert.Equal(2, deserialized.Context.Count);
+ Assert.Equal(2, deserialized.Context.Length);
}
[Fact]
@@ -147,7 +150,8 @@ public sealed class AGUIJsonSerializerContextTests
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunStartedEvent);
// Assert
- Assert.Contains($"\"type\":\"{AGUIEventTypes.RunStarted}\"", json);
+ var jsonElement = JsonDocument.Parse(json).RootElement;
+ Assert.Equal(AGUIEventTypes.RunStarted, jsonElement.GetProperty("type").GetString());
}
[Fact]
@@ -215,7 +219,8 @@ public sealed class AGUIJsonSerializerContextTests
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunFinishedEvent);
// Assert
- Assert.Contains($"\"type\":\"{AGUIEventTypes.RunFinished}\"", json);
+ var jsonElement = JsonDocument.Parse(json).RootElement;
+ Assert.Equal(AGUIEventTypes.RunFinished, jsonElement.GetProperty("type").GetString());
}
[Fact]
@@ -287,7 +292,8 @@ public sealed class AGUIJsonSerializerContextTests
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.RunErrorEvent);
// Assert
- Assert.Contains($"\"type\":\"{AGUIEventTypes.RunError}\"", json);
+ var jsonElement = JsonDocument.Parse(json).RootElement;
+ Assert.Equal(AGUIEventTypes.RunError, jsonElement.GetProperty("type").GetString());
}
[Fact]
@@ -354,7 +360,8 @@ public sealed class AGUIJsonSerializerContextTests
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageStartEvent);
// Assert
- Assert.Contains($"\"type\":\"{AGUIEventTypes.TextMessageStart}\"", json);
+ var jsonElement = JsonDocument.Parse(json).RootElement;
+ Assert.Equal(AGUIEventTypes.TextMessageStart, jsonElement.GetProperty("type").GetString());
}
[Fact]
@@ -421,7 +428,8 @@ public sealed class AGUIJsonSerializerContextTests
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageContentEvent);
// Assert
- Assert.Contains($"\"type\":\"{AGUIEventTypes.TextMessageContent}\"", json);
+ var jsonElement = JsonDocument.Parse(json).RootElement;
+ Assert.Equal(AGUIEventTypes.TextMessageContent, jsonElement.GetProperty("type").GetString());
}
[Fact]
@@ -488,7 +496,8 @@ public sealed class AGUIJsonSerializerContextTests
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.TextMessageEndEvent);
// Assert
- Assert.Contains($"\"type\":\"{AGUIEventTypes.TextMessageEnd}\"", json);
+ var jsonElement = JsonDocument.Parse(json).RootElement;
+ Assert.Equal(AGUIEventTypes.TextMessageEnd, jsonElement.GetProperty("type").GetString());
}
[Fact]
@@ -544,7 +553,7 @@ public sealed class AGUIJsonSerializerContextTests
public void AGUIMessage_Serializes_WithIdRoleAndContent()
{
// Arrange
- AGUIMessage message = new() { Id = "m1", Role = AGUIRoles.User, Content = "Hello" };
+ AGUIMessage message = new AGUIUserMessage() { Id = "m1", Content = "Hello" };
// Act
string json = JsonSerializer.Serialize(message, AGUIJsonSerializerContext.Default.AGUIMessage);
@@ -578,14 +587,14 @@ public sealed class AGUIJsonSerializerContextTests
Assert.NotNull(message);
Assert.Equal("m1", message.Id);
Assert.Equal(AGUIRoles.User, message.Role);
- Assert.Equal("Test message", message.Content);
+ Assert.Equal("Test message", ((AGUIUserMessage)message).Content);
}
[Fact]
public void AGUIMessage_RoundTrip_PreservesData()
{
// Arrange
- AGUIMessage original = new() { Id = "msg123", Role = AGUIRoles.Assistant, Content = "Response text" };
+ AGUIMessage original = new AGUIAssistantMessage() { Id = "msg123", Content = "Response text" };
// Act
string json = JsonSerializer.Serialize(original, AGUIJsonSerializerContext.Default.AGUIMessage);
@@ -595,7 +604,7 @@ public sealed class AGUIJsonSerializerContextTests
Assert.NotNull(deserialized);
Assert.Equal(original.Id, deserialized.Id);
Assert.Equal(original.Role, deserialized.Role);
- Assert.Equal(original.Content, deserialized.Content);
+ Assert.Equal(((AGUIAssistantMessage)original).Content, ((AGUIAssistantMessage)deserialized).Content);
}
[Fact]
@@ -617,7 +626,7 @@ public sealed class AGUIJsonSerializerContextTests
Assert.NotNull(message);
Assert.NotNull(message.Id);
Assert.NotNull(message.Role);
- Assert.NotNull(message.Content);
+ Assert.NotNull(((AGUIUserMessage)message).Content);
}
[Fact]
@@ -773,71 +782,333 @@ public sealed class AGUIJsonSerializerContextTests
Assert.IsType(events[5]);
}
+ #region Comprehensive Message Serialization Tests
+
[Fact]
- public void AGUIAgentThreadState_Serializes_WithThreadIdAndWrappedState()
+ public void AGUIUserMessage_SerializesAndDeserializes_Correctly()
{
// Arrange
- AGUIAgentThread.AGUIAgentThreadState state = new()
+ var originalMessage = new AGUIUserMessage
{
- ThreadId = "thread1",
- WrappedState = JsonSerializer.SerializeToElement(new { test = "data" })
+ Id = "user1",
+ Content = "Hello, assistant!"
};
// Act
- string json = JsonSerializer.Serialize(state, AGUIJsonSerializerContext.Default.AGUIAgentThreadState);
- JsonElement jsonElement = JsonSerializer.Deserialize(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);
+ string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIUserMessage);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIUserMessage);
// 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());
+ Assert.Equal("user1", deserialized.Id);
+ Assert.Equal("Hello, assistant!", deserialized.Content);
}
+
+ [Fact]
+ public void AGUISystemMessage_SerializesAndDeserializes_Correctly()
+ {
+ // Arrange
+ var originalMessage = new AGUISystemMessage
+ {
+ Id = "sys1",
+ Content = "You are a helpful assistant."
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUISystemMessage);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUISystemMessage);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal("sys1", deserialized.Id);
+ Assert.Equal("You are a helpful assistant.", deserialized.Content);
+ }
+
+ [Fact]
+ public void AGUIDeveloperMessage_SerializesAndDeserializes_Correctly()
+ {
+ // Arrange
+ var originalMessage = new AGUIDeveloperMessage
+ {
+ Id = "dev1",
+ Content = "Developer instructions here."
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIDeveloperMessage);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIDeveloperMessage);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal("dev1", deserialized.Id);
+ Assert.Equal("Developer instructions here.", deserialized.Content);
+ }
+
+ [Fact]
+ public void AGUIAssistantMessage_WithTextOnly_SerializesAndDeserializes_Correctly()
+ {
+ // Arrange
+ var originalMessage = new AGUIAssistantMessage
+ {
+ Id = "asst1",
+ Content = "I can help you with that."
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIAssistantMessage);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIAssistantMessage);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal("asst1", deserialized.Id);
+ Assert.Equal("I can help you with that.", deserialized.Content);
+ Assert.Null(deserialized.ToolCalls);
+ }
+
+ [Fact]
+ public void AGUIAssistantMessage_WithToolCallsAndParameters_SerializesAndDeserializes_Correctly()
+ {
+ // Arrange
+ var parameters = new Dictionary
+ {
+ ["location"] = "Seattle",
+ ["units"] = "fahrenheit",
+ ["days"] = 5
+ };
+ string argumentsJson = JsonSerializer.Serialize(parameters, AGUIJsonSerializerContext.Default.Options);
+
+ var originalMessage = new AGUIAssistantMessage
+ {
+ Id = "asst2",
+ Content = "Let me check the weather for you.",
+ ToolCalls =
+ [
+ new AGUIToolCall
+ {
+ Id = "call_123",
+ Type = "function",
+ Function = new AGUIFunctionCall
+ {
+ Name = "GetWeather",
+ Arguments = argumentsJson
+ }
+ }
+ ]
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIAssistantMessage);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIAssistantMessage);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal("asst2", deserialized.Id);
+ Assert.Equal("Let me check the weather for you.", deserialized.Content);
+ Assert.NotNull(deserialized.ToolCalls);
+ Assert.Single(deserialized.ToolCalls);
+
+ var toolCall = deserialized.ToolCalls[0];
+ Assert.Equal("call_123", toolCall.Id);
+ Assert.Equal("function", toolCall.Type);
+ Assert.NotNull(toolCall.Function);
+ Assert.Equal("GetWeather", toolCall.Function.Name);
+
+ // Verify parameters can be deserialized
+ var deserializedParams = JsonSerializer.Deserialize>(
+ toolCall.Function.Arguments,
+ AGUIJsonSerializerContext.Default.Options);
+ Assert.NotNull(deserializedParams);
+ Assert.Equal("Seattle", deserializedParams["location"].GetString());
+ Assert.Equal("fahrenheit", deserializedParams["units"].GetString());
+ Assert.Equal(5, deserializedParams["days"].GetInt32());
+ }
+
+ [Fact]
+ public void AGUIToolMessage_WithResults_SerializesAndDeserializes_Correctly()
+ {
+ // Arrange
+ var result = new Dictionary
+ {
+ ["temperature"] = 72.5,
+ ["conditions"] = "Sunny",
+ ["humidity"] = 45
+ };
+ string contentJson = JsonSerializer.Serialize(result, AGUIJsonSerializerContext.Default.Options);
+
+ var originalMessage = new AGUIToolMessage
+ {
+ Id = "tool1",
+ ToolCallId = "call_123",
+ Content = contentJson
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIToolMessage);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIToolMessage);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal("tool1", deserialized.Id);
+ Assert.Equal("call_123", deserialized.ToolCallId);
+ Assert.NotNull(deserialized.Content);
+
+ // Verify result content can be deserialized
+ var deserializedResult = JsonSerializer.Deserialize>(
+ deserialized.Content,
+ AGUIJsonSerializerContext.Default.Options);
+ Assert.NotNull(deserializedResult);
+ Assert.Equal(72.5, deserializedResult["temperature"].GetDouble());
+ Assert.Equal("Sunny", deserializedResult["conditions"].GetString());
+ Assert.Equal(45, deserializedResult["humidity"].GetInt32());
+ }
+
+ [Fact]
+ public void AllFiveMessageTypes_SerializeAsPolymorphicArray_Correctly()
+ {
+ // Arrange
+ AGUIMessage[] messages =
+ [
+ new AGUISystemMessage { Id = "1", Content = "System message" },
+ new AGUIDeveloperMessage { Id = "2", Content = "Developer message" },
+ new AGUIUserMessage { Id = "3", Content = "User message" },
+ new AGUIAssistantMessage { Id = "4", Content = "Assistant message" },
+ new AGUIToolMessage { Id = "5", ToolCallId = "call_1", Content = "{\"result\":\"success\"}" }
+ ];
+
+ // Act
+ string json = JsonSerializer.Serialize(messages, AGUIJsonSerializerContext.Default.AGUIMessageArray);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIMessageArray);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(5, deserialized.Length);
+ Assert.IsType(deserialized[0]);
+ Assert.IsType(deserialized[1]);
+ Assert.IsType(deserialized[2]);
+ Assert.IsType(deserialized[3]);
+ Assert.IsType(deserialized[4]);
+ }
+
+ #endregion
+
+ #region Tool-Related Event Type Tests
+
+ [Fact]
+ public void ToolCallStartEvent_SerializesAndDeserializes_Correctly()
+ {
+ // Arrange
+ var originalEvent = new ToolCallStartEvent
+ {
+ ParentMessageId = "msg1",
+ ToolCallId = "call_123",
+ ToolCallName = "GetWeather"
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(originalEvent, AGUIJsonSerializerContext.Default.ToolCallStartEvent);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.ToolCallStartEvent);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal("msg1", deserialized.ParentMessageId);
+ Assert.Equal("call_123", deserialized.ToolCallId);
+ Assert.Equal("GetWeather", deserialized.ToolCallName);
+ Assert.Equal(AGUIEventTypes.ToolCallStart, deserialized.Type);
+ }
+
+ [Fact]
+ public void ToolCallArgsEvent_SerializesAndDeserializes_Correctly()
+ {
+ // Arrange
+ var originalEvent = new ToolCallArgsEvent
+ {
+ ToolCallId = "call_123",
+ Delta = "{\"location\":\"Seattle\",\"units\":\"fahrenheit\"}"
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(originalEvent, AGUIJsonSerializerContext.Default.ToolCallArgsEvent);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.ToolCallArgsEvent);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal("call_123", deserialized.ToolCallId);
+ Assert.Equal("{\"location\":\"Seattle\",\"units\":\"fahrenheit\"}", deserialized.Delta);
+ Assert.Equal(AGUIEventTypes.ToolCallArgs, deserialized.Type);
+ }
+
+ [Fact]
+ public void ToolCallEndEvent_SerializesAndDeserializes_Correctly()
+ {
+ // Arrange
+ var originalEvent = new ToolCallEndEvent
+ {
+ ToolCallId = "call_123"
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(originalEvent, AGUIJsonSerializerContext.Default.ToolCallEndEvent);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.ToolCallEndEvent);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal("call_123", deserialized.ToolCallId);
+ Assert.Equal(AGUIEventTypes.ToolCallEnd, deserialized.Type);
+ }
+
+ [Fact]
+ public void ToolCallResultEvent_SerializesAndDeserializes_Correctly()
+ {
+ // Arrange
+ var originalEvent = new ToolCallResultEvent
+ {
+ MessageId = "msg1",
+ ToolCallId = "call_123",
+ Content = "{\"temperature\":72.5,\"conditions\":\"Sunny\"}",
+ Role = "tool"
+ };
+
+ // Act
+ string json = JsonSerializer.Serialize(originalEvent, AGUIJsonSerializerContext.Default.ToolCallResultEvent);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.ToolCallResultEvent);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal("msg1", deserialized.MessageId);
+ Assert.Equal("call_123", deserialized.ToolCallId);
+ Assert.Equal("{\"temperature\":72.5,\"conditions\":\"Sunny\"}", deserialized.Content);
+ Assert.Equal("tool", deserialized.Role);
+ Assert.Equal(AGUIEventTypes.ToolCallResult, deserialized.Type);
+ }
+
+ [Fact]
+ public void AllToolEventTypes_SerializeAsPolymorphicBaseEvent_Correctly()
+ {
+ // Arrange
+ BaseEvent[] events =
+ [
+ new RunStartedEvent { ThreadId = "t1", RunId = "r1" },
+ new ToolCallStartEvent { ParentMessageId = "m1", ToolCallId = "c1", ToolCallName = "Tool1" },
+ new ToolCallArgsEvent { ToolCallId = "c1", Delta = "{}" },
+ new ToolCallEndEvent { ToolCallId = "c1" },
+ new ToolCallResultEvent { MessageId = "m2", ToolCallId = "c1", Content = "{}", Role = "tool" },
+ new RunFinishedEvent { ThreadId = "t1", RunId = "r1" }
+ ];
+
+ // Act
+ string json = JsonSerializer.Serialize(events, AGUIJsonSerializerContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.Options);
+
+ // Assert
+ Assert.NotNull(deserialized);
+ Assert.Equal(6, deserialized.Length);
+ Assert.IsType