diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 5c846f0def1..d45c5f575d7 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -598,6 +598,7 @@
+
@@ -656,6 +657,7 @@
+
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UICompositionGuides.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UICompositionGuides.cs
new file mode 100644
index 00000000000..ba90ff664d9
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UICompositionGuides.cs
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace AGUIDojoServer.A2UI;
+
+///
+/// Project-specific composition rules for the A2UI subagent — tells it how to use the
+/// pre-made domain components shipped in the dojo's dynamic catalog. Mirrors the
+/// LangGraph dojo examples so all integrations exercise the same demos.
+///
+internal static class A2UICompositionGuides
+{
+ /// The catalog id of the dojo's dynamic component catalog.
+ public const string DynamicCatalogId = "https://a2ui.org/demos/dojo/dynamic_catalog.json";
+
+ /// The planner system prompt for the dynamic-schema and recovery demos.
+ public const string PlannerInstructions = """
+ You are a helpful assistant that creates rich visual UI on the fly.
+
+ When the user asks for visual content (product comparisons, dashboards, lists, cards, etc.),
+ use the generate_a2ui tool to create a dynamic A2UI surface.
+ IMPORTANT: After calling the tool, do NOT repeat the data in your text response. The tool renders UI automatically. Just confirm what was rendered.
+ """;
+
+ /// The composition guide for the dynamic-schema demo.
+ public const string DynamicSchema = """
+ ## Available Pre-made Components
+
+ You have 4 components. Use Row as the root with structural children to repeat a card per item.
+
+ ### Row
+ Layout container. Use structural children to repeat a card template:
+ {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}}
+
+ ### HotelCard
+ Props: name, location, rating (number 0-5), pricePerNight, amenities (optional), action
+ Example:
+ {"id":"card","component":"HotelCard","name":{"path":"name"},"location":{"path":"location"},
+ "rating":{"path":"rating"},"pricePerNight":{"path":"pricePerNight"},
+ "action":{"event":{"name":"book","context":{"name":{"path":"name"}}}}}
+
+ ### ProductCard
+ Props: name, price, rating (number 0-5), description (optional), badge (optional), action
+ Example:
+ {"id":"card","component":"ProductCard","name":{"path":"name"},"price":{"path":"price"},
+ "rating":{"path":"rating"},"description":{"path":"description"},
+ "action":{"event":{"name":"select","context":{"name":{"path":"name"}}}}}
+
+ ### TeamMemberCard
+ Props: name, role, department (optional), email (optional), avatarUrl (optional), action
+ Example:
+ {"id":"card","component":"TeamMemberCard","name":{"path":"name"},"role":{"path":"role"},
+ "department":{"path":"department"},"email":{"path":"email"},
+ "action":{"event":{"name":"contact","context":{"name":{"path":"name"}}}}}
+
+ ## RULES
+ - Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"}
+ - Inside templates, use RELATIVE paths (no leading slash): {"path":"name"} not {"path":"/name"}
+ - Always provide data in the "data" argument as {"items":[...]}
+ - Pick the card type that best matches the user's request
+ - Generate 3-4 realistic items with diverse data
+ """;
+
+ /// The composition guide for the recovery demo (structural validation showcase).
+ public const string Recovery = """
+ ## Available Pre-made Components
+
+ Use Row as the root with structural children to repeat a card per item.
+
+ ### Row
+ Layout container. Repeat a card template via structural children:
+ {"id":"root","component":"Row","children":{"componentId":"card","path":"/items"}}
+
+ ### HotelCard / ProductCard / TeamMemberCard
+ Card components bound to per-item data (relative paths inside the template).
+
+ ## RULES
+ - Root is ALWAYS a Row with structural children: {"componentId":"","path":"/items"}
+ - ALWAYS include the referenced card component in the components array.
+ - Inside templates, use RELATIVE paths (no leading slash): {"path":"name"} not {"path":"/name"}
+ - Always provide data in the "data" argument as {"items":[...]}
+ - Generate 3-4 realistic items with diverse data.
+ """;
+}
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs
new file mode 100644
index 00000000000..4bcfbfd1839
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/A2UI/A2UIFixedSchemaTools.cs
@@ -0,0 +1,135 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ComponentModel;
+using System.Text.Json.Nodes;
+using Microsoft.Agents.AI.AGUI.A2UI;
+using Microsoft.Extensions.AI;
+
+namespace AGUIDojoServer.A2UI;
+
+///
+/// Fixed-schema A2UI tools: pre-built component layouts for flight and hotel cards.
+/// The agent only supplies the data; layout/styling is fixed in code. Demonstrates the
+/// "controlled gen-UI" pattern — the author owns the UI shape, the agent owns the data.
+///
+internal static class A2UIFixedSchemaTools
+{
+ private const string CatalogId = "https://a2ui.org/demos/dojo/fixed_catalog.json";
+ private const string FlightSurfaceId = "flight-search-results";
+ private const string HotelSurfaceId = "hotel-search-results";
+
+ /// Creates the search_flights tool.
+ public static AIFunction CreateSearchFlightsTool() => AIFunctionFactory.Create(
+ SearchFlights,
+ "search_flights",
+ "Search for flights and display the results as rich cards. Each flight " +
+ "must have: id, airline (e.g. 'United Airlines'), airlineLogo (use Google " +
+ "favicon API like 'https://www.google.com/s2/favicons?domain=united.com&sz=128'), " +
+ "flightNumber, origin, destination, date (e.g. 'Tue, Mar 18'), departureTime, " +
+ "arrivalTime, duration (e.g. '4h 25m'), status ('On Time' or 'Delayed'), " +
+ "and price (e.g. '$289').",
+ AGUIDojoServerSerializerContext.Default.Options);
+
+ /// Creates the search_hotels tool.
+ public static AIFunction CreateSearchHotelsTool() => AIFunctionFactory.Create(
+ SearchHotels,
+ "search_hotels",
+ "Search for hotels and display the results as rich cards with star ratings. " +
+ "Each hotel must have: id, name (e.g. 'The Plaza'), location " +
+ "(e.g. 'Midtown Manhattan, NYC'), rating (float 0-5, e.g. 4.5), and " +
+ "price (per night, e.g. '$350'). Generate 3-4 realistic results.",
+ AGUIDojoServerSerializerContext.Default.Options);
+
+ private static string SearchFlights(
+ [Description("Array of flight result objects.")] JsonArray flights)
+ => RenderOperations(FlightSurfaceId, FlightSchema(), "flights", flights);
+
+ private static string SearchHotels(
+ [Description("Array of hotel result objects.")] JsonArray hotels)
+ => RenderOperations(HotelSurfaceId, HotelSchema(), "hotels", hotels);
+
+ ///
+ /// Wraps the fixed layout + agent-supplied data as the A2UI operations envelope the
+ /// AG-UI A2UI middleware detects in tool results.
+ ///
+ private static string RenderOperations(string surfaceId, JsonArray schema, string dataKey, JsonArray items)
+ => A2UIToolkit.WrapAsOperationsEnvelope(A2UIToolkit.AssembleOps(
+ "create",
+ surfaceId,
+ CatalogId,
+ schema,
+ new JsonObject { [dataKey] = items.DeepClone() }));
+
+ // Flight search layout — the agent supplies the `flights` array; rendering is fixed.
+ private static JsonArray FlightSchema() => new(
+ new JsonObject
+ {
+ ["id"] = "root",
+ ["component"] = "Row",
+ ["children"] = new JsonObject { ["componentId"] = "flight-card", ["path"] = "/flights" },
+ ["gap"] = 16,
+ },
+ new JsonObject
+ {
+ ["id"] = "flight-card",
+ ["component"] = "FlightCard",
+ ["airline"] = new JsonObject { ["path"] = "airline" },
+ ["airlineLogo"] = new JsonObject { ["path"] = "airlineLogo" },
+ ["flightNumber"] = new JsonObject { ["path"] = "flightNumber" },
+ ["origin"] = new JsonObject { ["path"] = "origin" },
+ ["destination"] = new JsonObject { ["path"] = "destination" },
+ ["date"] = new JsonObject { ["path"] = "date" },
+ ["departureTime"] = new JsonObject { ["path"] = "departureTime" },
+ ["arrivalTime"] = new JsonObject { ["path"] = "arrivalTime" },
+ ["duration"] = new JsonObject { ["path"] = "duration" },
+ ["status"] = new JsonObject { ["path"] = "status" },
+ ["price"] = new JsonObject { ["path"] = "price" },
+ ["action"] = new JsonObject
+ {
+ ["event"] = new JsonObject
+ {
+ ["name"] = "book_flight",
+ ["context"] = new JsonObject
+ {
+ ["flightNumber"] = new JsonObject { ["path"] = "flightNumber" },
+ ["origin"] = new JsonObject { ["path"] = "origin" },
+ ["destination"] = new JsonObject { ["path"] = "destination" },
+ ["price"] = new JsonObject { ["path"] = "price" },
+ },
+ },
+ },
+ });
+
+ // Hotel search layout — the agent supplies the `hotels` array; rendering is fixed.
+ private static JsonArray HotelSchema() => new(
+ new JsonObject
+ {
+ ["id"] = "root",
+ ["component"] = "Row",
+ ["children"] = new JsonObject { ["componentId"] = "hotel-card", ["path"] = "/hotels" },
+ ["gap"] = 16,
+ },
+ new JsonObject
+ {
+ ["id"] = "hotel-card",
+ ["component"] = "HotelCard",
+ ["name"] = new JsonObject { ["path"] = "name" },
+ ["location"] = new JsonObject { ["path"] = "location" },
+ ["rating"] = new JsonObject { ["path"] = "rating" },
+ // Deliberate cross-name: the HotelCard prop is "pricePerNight" but the
+ // agent-supplied data field (per the search_hotels description) is "price".
+ ["pricePerNight"] = new JsonObject { ["path"] = "price" },
+ ["action"] = new JsonObject
+ {
+ ["event"] = new JsonObject
+ {
+ ["name"] = "book_hotel",
+ ["context"] = new JsonObject
+ {
+ ["hotelName"] = new JsonObject { ["path"] = "name" },
+ ["price"] = new JsonObject { ["path"] = "price" },
+ },
+ },
+ },
+ });
+}
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj
index 03e2493623d..0de7806a1bd 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj
@@ -1,11 +1,11 @@
-
+
Exe
net10.0
enable
enable
- b9c3f1e1-2fb4-5g29-0e52-53e2b7g9gf21
+ 1d558f5d-c5c3-4178-bc08-edc445249828
@@ -17,6 +17,7 @@
+
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs
index c60db0efd05..36e6c11ce3d 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServerSerializerContext.cs
@@ -8,6 +8,8 @@
namespace AGUIDojoServer;
+[JsonSerializable(typeof(System.Text.Json.Nodes.JsonArray))]
+[JsonSerializable(typeof(System.Text.Json.Nodes.JsonObject))]
[JsonSerializable(typeof(WeatherInfo))]
[JsonSerializable(typeof(Recipe))]
[JsonSerializable(typeof(Ingredient))]
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs
index 1cdd00731bb..39ec13d2a15 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/ChatClientAgentFactory.cs
@@ -2,6 +2,7 @@
using System.ComponentModel;
using System.Text.Json;
+using AGUIDojoServer.A2UI;
using AGUIDojoServer.AgenticUI;
using AGUIDojoServer.BackendToolRendering;
using AGUIDojoServer.PredictiveStateUpdates;
@@ -9,45 +10,75 @@
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.AGUI.A2UI;
using Microsoft.Extensions.AI;
+using OpenAI;
using OpenAI.Chat;
namespace AGUIDojoServer;
internal static class ChatClientAgentFactory
{
- private static AzureOpenAIClient? s_azureOpenAIClient;
+ private static OpenAIClient? s_openAIClient;
private static string? s_deploymentName;
public static void Initialize(IConfiguration configuration)
{
- string endpoint = configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
- s_deploymentName = configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
-
- // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
- // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
- // latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
- s_azureOpenAIClient = new AzureOpenAIClient(
- new Uri(endpoint),
- new DefaultAzureCredential());
+ string? azureEndpoint = configuration["AZURE_OPENAI_ENDPOINT"];
+ if (!string.IsNullOrEmpty(azureEndpoint))
+ {
+ s_deploymentName = configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
+
+ // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+ // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
+ // latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
+ if (!Uri.TryCreate(azureEndpoint, UriKind.Absolute, out Uri? azureUri))
+ {
+ throw new InvalidOperationException($"AZURE_OPENAI_ENDPOINT is not a valid absolute URI: '{azureEndpoint}'.");
+ }
+
+ s_openAIClient = new AzureOpenAIClient(
+ azureUri,
+ new DefaultAzureCredential());
+ return;
+ }
+
+ // OpenAI-compatible mode: OPENAI_API_KEY with an optional OPENAI_BASE_URL override
+ // (e.g. a local mock server for deterministic end-to-end tests).
+ string apiKey = configuration["OPENAI_API_KEY"] ?? throw new InvalidOperationException("Either AZURE_OPENAI_ENDPOINT or OPENAI_API_KEY must be set.");
+ s_deploymentName = configuration["OPENAI_CHAT_MODEL_ID"] ?? "gpt-4o";
+
+ var options = new OpenAIClientOptions();
+ string? baseUrl = configuration["OPENAI_BASE_URL"];
+ if (!string.IsNullOrEmpty(baseUrl))
+ {
+ if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? baseUri))
+ {
+ throw new InvalidOperationException($"OPENAI_BASE_URL is not a valid absolute URI: '{baseUrl}'. Include the scheme, e.g. 'http://localhost:8000/v1'.");
+ }
+
+ options.Endpoint = baseUri;
+ }
+
+ s_openAIClient = new OpenAIClient(new System.ClientModel.ApiKeyCredential(apiKey), options);
}
public static ChatClientAgent CreateAgenticChat()
{
- ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsAIAgent(
name: "AgenticChat",
- description: "A simple chat agent using Azure OpenAI");
+ description: "A simple chat agent");
}
public static ChatClientAgent CreateBackendToolRendering()
{
- ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsAIAgent(
name: "BackendToolRenderer",
- description: "An agent that can render backend tools using Azure OpenAI",
+ description: "An agent that can render backend tools",
tools: [AIFunctionFactory.Create(
GetWeather,
name: "get_weather",
@@ -57,29 +88,29 @@ public static ChatClientAgent CreateBackendToolRendering()
public static ChatClientAgent CreateHumanInTheLoop()
{
- ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsAIAgent(
name: "HumanInTheLoopAgent",
- description: "An agent that involves human feedback in its decision-making process using Azure OpenAI");
+ description: "An agent that involves human feedback in its decision-making process");
}
public static ChatClientAgent CreateToolBasedGenerativeUI()
{
- ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsAIAgent(
name: "ToolBasedGenerativeUIAgent",
- description: "An agent that uses tools to generate user interfaces using Azure OpenAI");
+ description: "An agent that uses tools to generate user interfaces");
}
public static AIAgent CreateAgenticUI(JsonSerializerOptions options)
{
- ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "AgenticUIAgent",
- Description = "An agent that generates agentic user interfaces using Azure OpenAI",
+ Description = "An agent that generates agentic user interfaces",
ChatOptions = new ChatOptions
{
Instructions = """
@@ -117,23 +148,23 @@ again until all the steps in current plan are completed.
public static AIAgent CreateSharedState(JsonSerializerOptions options)
{
- ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsAIAgent(
name: "SharedStateAgent",
- description: "An agent that demonstrates shared state patterns using Azure OpenAI");
+ description: "An agent that demonstrates shared state patterns");
return new SharedStateAgent(baseAgent, options);
}
public static AIAgent CreatePredictiveStateUpdates(JsonSerializerOptions options)
{
- ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "PredictiveStateUpdatesAgent",
- Description = "An agent that demonstrates predictive state updates using Azure OpenAI",
+ Description = "An agent that demonstrates predictive state updates",
ChatOptions = new ChatOptions
{
Instructions = """
@@ -180,4 +211,75 @@ private static string WriteDocument([Description("The document content to write.
// Simply return success - the document is tracked via state updates
return "Document written successfully";
}
+
+ public static ChatClientAgent CreateA2UIFixedSchema()
+ {
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
+
+ return chatClient.AsAIAgent(
+ instructions: """
+ You are a helpful travel assistant that can search for flights and hotels.
+
+ When the user asks about flights, use the search_flights tool.
+ When the user asks about hotels, use the search_hotels tool.
+ IMPORTANT: After calling a tool, do NOT repeat or summarize the data in your text response. The tool renders a rich UI automatically. Just say something brief like "Here are your results" or ask if they'd like to book.
+ """,
+ name: "A2UIFixedSchema",
+ description: "Fixed-schema A2UI demo: author-owned card layouts, agent-supplied data",
+ tools: [A2UIFixedSchemaTools.CreateSearchFlightsTool(), A2UIFixedSchemaTools.CreateSearchHotelsTool()]);
+ }
+
+ public static AIAgent CreateA2UIDynamicSchema()
+ {
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
+
+ AIAgent planner = chatClient.AsAIAgent(
+ instructions: A2UICompositionGuides.PlannerInstructions,
+ name: "A2UIDynamicSchema",
+ description: "Dynamic-schema A2UI demo: a subagent designs the UI via generate_a2ui");
+
+ return new A2UIAgent(planner, chatClient.AsIChatClient(), new A2UIToolParams
+ {
+ DefaultCatalogId = A2UICompositionGuides.DynamicCatalogId,
+ Guidelines = new A2UIGuidelines { CompositionGuide = A2UICompositionGuides.DynamicSchema },
+ });
+ }
+
+ public static AIAgent CreateA2UIChat()
+ {
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
+
+ // Zero-configuration A2UI: the AG-UI A2UI middleware injects the render_a2ui
+ // tool (auto-bound from the incoming tool list) plus its usage guidelines and
+ // component schema as context entries. AGUIContextAgent surfaces those entries
+ // to the model, so a plain chat agent renders A2UI surfaces with streamed,
+ // progressively painted tool arguments — no A2UI-specific agent code.
+ AIAgent inner = chatClient.AsAIAgent(
+ instructions: "You are a helpful assistant. When the user asks for visual content (cards, lists, comparisons, dashboards), render it with the available UI rendering tool.",
+ name: "A2UIChat",
+ description: "Zero-configuration A2UI chat demo: client-injected render tool with streamed arguments");
+
+ return new AGUIContextAgent(inner);
+ }
+
+ public static AIAgent CreateA2UIRecovery()
+ {
+ ChatClient chatClient = s_openAIClient!.GetChatClient(s_deploymentName!);
+
+ AIAgent planner = chatClient.AsAIAgent(
+ instructions: A2UICompositionGuides.PlannerInstructions,
+ name: "A2UIRecovery",
+ description: "A2UI error-recovery demo: invalid surfaces are validated and regenerated");
+
+ return new A2UIAgent(planner, chatClient.AsIChatClient(), new A2UIToolParams
+ {
+ DefaultCatalogId = A2UICompositionGuides.DynamicCatalogId,
+ Guidelines = new A2UIGuidelines { CompositionGuide = A2UICompositionGuides.Recovery },
+ // No Catalog is supplied, so the loop exercises structural validation (missing
+ // root, dangling child references, duplicate ids) like the sibling demos. The
+ // recovery loop runs by default; the cap is set explicitly to show where the
+ // knob lives, using the default value.
+ Recovery = new A2UIRecoveryConfig { MaxAttempts = A2UIConstants.MaxA2UIAttempts },
+ });
+ }
}
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs
index 3f0032d4da9..8cb1c01a771 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs
@@ -47,6 +47,14 @@
app.MapAGUI("/predictive_state_updates", ChatClientAgentFactory.CreatePredictiveStateUpdates(jsonOptions.Value.SerializerOptions));
+app.MapAGUI("/a2ui_fixed_schema", ChatClientAgentFactory.CreateA2UIFixedSchema());
+
+app.MapAGUI("/a2ui_dynamic_schema", ChatClientAgentFactory.CreateA2UIDynamicSchema());
+
+app.MapAGUI("/a2ui_recovery", ChatClientAgentFactory.CreateA2UIRecovery());
+
+app.MapAGUI("/a2ui_chat", ChatClientAgentFactory.CreateA2UIChat());
+
await app.RunAsync();
public partial class Program;
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/README.md b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/README.md
new file mode 100644
index 00000000000..f1bbfcdcf16
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/README.md
@@ -0,0 +1,75 @@
+# AG-UI Dojo Server
+
+An ASP.NET Core server that hosts a suite of AG-UI agents, one per protocol feature, behind
+the [AG-UI Dojo](https://github.com/ag-ui-protocol/ag-ui) demo viewer. Each feature is exposed
+as its own AG-UI endpoint via `MapAGUI`, so the Dojo front end can exercise every capability
+against a real .NET agent.
+
+> **Warning**
+> The AG-UI protocol is still under development and changing. We will try to keep these
+> samples updated as the protocol evolves.
+
+## Endpoints
+
+Each endpoint is a self-contained agent mapped with `MapAGUI("/", agent)`:
+
+| Endpoint | Feature |
+| --- | --- |
+| `/agentic_chat` | Plain streaming chat |
+| `/backend_tool_rendering` | Tool results rendered by the client |
+| `/human_in_the_loop` | Client-approved tool calls |
+| `/tool_based_generative_ui` | Tool-driven generative UI |
+| `/agentic_generative_ui` | Plan/state-driven generative UI |
+| `/shared_state` | Shared state snapshots and deltas |
+| `/predictive_state_updates` | Streamed document edits |
+| `/a2ui_fixed_schema` | **A2UI** — author-owned card layouts, agent supplies only the data |
+| `/a2ui_dynamic_schema` | **A2UI** — a subagent designs the surface via the `generate_a2ui` tool |
+| `/a2ui_recovery` | **A2UI** — the validate-and-retry recovery loop for invalid surfaces |
+| `/a2ui_chat` | **A2UI** — zero-configuration: the client middleware injects the render tool |
+
+The four `a2ui_*` endpoints demonstrate the
+[`Microsoft.Agents.AI.AGUI.A2UI`](../../../../src/Microsoft.Agents.AI.AGUI.A2UI) toolkit. See
+[A2UI/A2UICompositionGuides.cs](./A2UI/A2UICompositionGuides.cs) and
+[ChatClientAgentFactory.cs](./ChatClientAgentFactory.cs) for how each agent is built.
+
+## Configuring Environment Variables
+
+The server runs in one of two modes, chosen by which variables are set.
+
+**Azure OpenAI** (uses `DefaultAzureCredential` — authenticate via `az login`, Visual Studio,
+or environment variables):
+
+```powershell
+$env:AZURE_OPENAI_ENDPOINT="<>"
+$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o"
+```
+
+**OpenAI / OpenAI-compatible** (API key, with an optional base-URL override for a local or
+proxy endpoint — useful for deterministic end-to-end tests against a mock server):
+
+```powershell
+$env:OPENAI_API_KEY="<>"
+$env:OPENAI_CHAT_MODEL_ID="gpt-4o" # optional, defaults to gpt-4o
+$env:OPENAI_BASE_URL="http://localhost:8000/v1" # optional
+```
+
+If `AZURE_OPENAI_ENDPOINT` is set the server uses Azure mode; otherwise it falls back to
+`OPENAI_API_KEY`.
+
+## Running the Sample
+
+```bash
+cd AGUIDojoServer
+dotnet run --urls "http://localhost:8016"
+```
+
+Point the AG-UI Dojo's `microsoft-agent-framework-dotnet` integration at the server's URL
+(`http://localhost:8016` by default), then open any feature page to interact with the
+matching endpoint. The endpoints are plain AG-UI servers, so they can also be driven directly
+over HTTP POST with a `RunAgentInput` body (see the
+[AG-UI Client and Server sample](../README.md) for the request shape).
+
+> **Note**
+> When deploying multi-user, register a session-isolation key provider (see the commented
+> `UseClaimsBasedSessionIsolation` call in [Program.cs](./Program.cs)); otherwise sessions are
+> shared across callers.
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md
index 788ae93d7d2..27ae5a58fb2 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md
@@ -9,6 +9,8 @@ The demonstration has two components:
1. **AGUIServer** - An ASP.NET Core web server that hosts an AI agent and exposes it via the AG-UI protocol
2. **AGUIClient** - A console application that connects to the AG-UI server and displays streaming updates
+A third project, **[AGUIDojoServer](./AGUIDojoServer/README.md)**, hosts one AG-UI endpoint per protocol feature (agentic chat, generative UI, shared state, A2UI, and more) for use with the AG-UI Dojo demo viewer. See its [README](./AGUIDojoServer/README.md) for the endpoint list and configuration.
+
> **Warning**
> The AG-UI protocol is still under development and changing.
> We will try to keep these samples updated as the protocol evolves.
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs
new file mode 100644
index 00000000000..a2a09579439
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIAgent.cs
@@ -0,0 +1,592 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI;
+
+///
+/// Wraps an agent with A2UI surface-generation support: every run gets a
+/// generate_a2ui tool that delegates UI generation to a subagent chat client
+/// and returns a validated A2UI operations envelope as its tool result.
+///
+///
+///
+/// This is the Agent Framework adapter over the framework-agnostic toolkit
+/// (, ), mirroring the
+/// LangGraph adapters' getA2UITools/get_a2ui_tools factories. The tool
+/// must be constructed per run because it reads the run's conversation history (for
+/// prior-surface lookup) and the AG-UI context forwarded by the hosting layer (for the
+/// component catalog) — hence the agent wrapper rather than a static tool.
+///
+///
+/// The component catalog is read from the ag_ui_context additional property that
+/// MapAGUI stamps onto , using the
+/// schema context entry injected by the AG-UI A2UI middleware.
+///
+///
+public sealed class A2UIAgent : DelegatingAIAgent
+{
+ // Bare acknowledgement returned as the inner render_a2ui tool result; the painted
+ // surface rides the streamed arguments, so the result only has to balance the call.
+ private const string RenderAcknowledgement = "{\"status\":\"rendered\"}";
+
+ private readonly IChatClient _subagentChatClient;
+ private readonly A2UIResolvedToolParams _parameters;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The agent to wrap.
+ ///
+ /// The chat client used to run the UI-generation subagent. Must be a raw client
+ /// (no automatic function invocation) — the adapter reads the forced
+ /// render_a2ui call's arguments directly.
+ ///
+ /// Behavior knobs; defaults are filled per the shared toolkit rules.
+ public A2UIAgent(AIAgent innerAgent, IChatClient subagentChatClient, A2UIToolParams? parameters = null)
+ : base(innerAgent)
+ {
+ this._subagentChatClient = Throw.IfNull(subagentChatClient);
+ this._parameters = A2UIToolDefinitions.ResolveA2UIToolParams(parameters);
+ }
+
+ ///
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ (List messageList, AgentRunOptions runOptions) = this.PrepareRun(messages, options);
+ return this.InnerAgent.RunAsync(messageList, session, runOptions, cancellationToken);
+ }
+
+ ///
+ /// The cap on planner rounds (model turn → generation → result fed back) per run,
+ /// guarding against a planner that keeps requesting surfaces without terminating.
+ ///
+ internal const int MaxPlannerRounds = 8;
+
+ ///
+ ///
+ /// The streaming path runs the generate_a2ui invocation loop at the agent level
+ /// instead of through automatic function invocation: the tool is advertised as a
+ /// schema-only declaration so the planner's call surfaces on the update stream, the
+ /// render subagent is then run with a streaming chat call, and its raw updates are
+ /// forwarded so hosting layers can emit the tool-call argument fragments incrementally
+ /// (progressive surface rendering). The envelope is fed back to the planner and the
+ /// conversation continues — the same wire shape the LangGraph adapters produce.
+ ///
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ List history = messages.ToList();
+
+ ChatClientAgentRunOptions runOptions = CloneRunOptions(options);
+ ChatOptions chatOptions = runOptions.ChatOptions ?? new ChatOptions();
+ runOptions.ChatOptions = chatOptions;
+
+ A2UIAgentState state = ReadAgentState(chatOptions.AdditionalProperties);
+
+ var generateTool = new GenerateA2UIToolDeclaration(this._parameters.ToolName, this._parameters.ToolDescription);
+ chatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty())
+ .Where(t => !string.Equals(t.Name, generateTool.Name, StringComparison.Ordinal))
+ .Append(generateTool)
+ .ToList();
+
+ // Round 1 carries the caller's session so inner-agent bookkeeping still happens;
+ // later rounds resend the manually grown history without the session to avoid
+ // double-recording the same messages in session-aware inner agents.
+ List pending = history;
+ AgentSession? pendingSession = session;
+ for (int round = 1; round <= MaxPlannerRounds; round++)
+ {
+ List generateCalls = [];
+ List assistantContents = [];
+ await foreach (AgentResponseUpdate update in this.InnerAgent
+ .RunStreamingAsync(pending, pendingSession, runOptions, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ foreach (AIContent content in update.Contents)
+ {
+ // Preserve the planner's own narration alongside its generate_a2ui
+ // calls so the message fed back to it next round is not lossy.
+ if (content is TextContent text)
+ {
+ assistantContents.Add(text);
+ }
+ else if (content is FunctionCallContent call &&
+ string.Equals(call.Name, generateTool.Name, StringComparison.Ordinal))
+ {
+ assistantContents.Add(call);
+ generateCalls.Add(call);
+ }
+ }
+
+ yield return update;
+ }
+
+ if (generateCalls.Count == 0)
+ {
+ yield break;
+ }
+
+ List results = [];
+ foreach (FunctionCallContent call in generateCalls)
+ {
+ var envelopeBox = new StrongBox();
+ await foreach (AgentResponseUpdate update in this
+ .RunGenerateStreamingAsync(call, history, state, envelopeBox, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return update;
+ }
+
+ results.Add(new FunctionResultContent(call.CallId, envelopeBox.Value));
+ }
+
+ // Surface the tool results on the wire and feed them back to the planner.
+ var toolMessage = new ChatMessage(ChatRole.Tool, results);
+ yield return new AgentResponseUpdate(ChatRole.Tool, results);
+
+ history.Add(new ChatMessage(ChatRole.Assistant, assistantContents));
+ history.Add(toolMessage);
+ pending = history;
+ pendingSession = null;
+ }
+
+ // The planner kept requesting generations through the round cap. Give it one final
+ // turn to consume the last tool result and narrate, with the generate tool withheld
+ // so it cannot request another surface, otherwise the run would end on an unanswered
+ // tool result with no closing assistant message. Clone the run options so the base
+ // AgentRunOptions members (continuation token, background-response opt-in, additional
+ // properties, response format) and the chat client factory all survive, then strip
+ // only the generate tool from the cloned chat options.
+ ChatClientAgentRunOptions closingOptions = CloneRunOptions(runOptions);
+ ChatOptions closingChatOptions = closingOptions.ChatOptions ?? new ChatOptions();
+ closingOptions.ChatOptions = closingChatOptions;
+ closingChatOptions.Tools = (closingChatOptions.Tools ?? Enumerable.Empty())
+ .Where(t => !string.Equals(t.Name, generateTool.Name, StringComparison.Ordinal))
+ .ToList();
+ await foreach (AgentResponseUpdate update in this.InnerAgent
+ .RunStreamingAsync(history, pendingSession, closingOptions, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ ///
+ /// Runs one generate_a2ui invocation with the validate-and-retry loop, streaming
+ /// the render subagent's updates (each retry is a fresh, visible subagent call) and
+ /// depositing the final envelope — operations, request error, or recovery-exhausted —
+ /// into .
+ ///
+ private async IAsyncEnumerable RunGenerateStreamingAsync(
+ FunctionCallContent call,
+ IReadOnlyList conversation,
+ A2UIAgentState state,
+ StrongBox envelopeBox,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ string? intent = GetStringArgument(call.Arguments, "intent");
+ string? targetSurfaceId = GetStringArgument(call.Arguments, "target_surface_id");
+ string? changes = GetStringArgument(call.Arguments, "changes");
+
+ List history = conversation.Select(ToHistoryMessage).ToList();
+ A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest(
+ intent, targetSurfaceId, changes, history, state, this._parameters.Guidelines);
+ if (prep.Error is not null)
+ {
+ envelopeBox.Value = ParseEnvelope(A2UIToolkit.WrapErrorEnvelope(prep.Error));
+ yield break;
+ }
+
+ // The streaming twin of A2UIGenerationRecovery.RunAsync: same attempt semantics,
+ // but each subagent call streams so its updates can be forwarded between attempts.
+ int maxAttempts = A2UIGenerationRecovery.ResolveMaxAttempts(this._parameters.Recovery);
+ List attempts = [];
+ IReadOnlyList lastErrors = [];
+ for (int attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string prompt = A2UIGenerationRecovery.AugmentPromptWithValidationErrors(prep.Prompt, lastErrors);
+
+ // Forward every update so the hosting layer can paint the render_a2ui argument
+ // fragments progressively, while accumulating them to coalesce the complete
+ // tool call afterward. Reading arguments off a single update is unsafe: a chat
+ // client may stream a tool call's arguments as fragments across updates, and
+ // only the coalesced response carries the full arguments (this mirrors the
+ // non-streaming path, which reads the already-coalesced ChatResponse).
+ List attemptUpdates = [];
+ await foreach (ChatResponseUpdate update in this._subagentChatClient
+ .GetStreamingResponseAsync(BuildSubagentMessages(prompt, conversation), CreateSubagentOptions(), cancellationToken)
+ .ConfigureAwait(false))
+ {
+ attemptUpdates.Add(update);
+ yield return new AgentResponseUpdate(update);
+ }
+
+ FunctionCallContent? renderCall = attemptUpdates.ToChatResponse().Messages
+ .SelectMany(m => m.Contents)
+ .OfType()
+ .FirstOrDefault(c => string.Equals(c.Name, A2UIConstants.RenderA2UIToolName, StringComparison.Ordinal));
+ JsonObject? renderArgs = renderCall?.Arguments is { } renderArguments ? ToJsonObject(renderArguments) : null;
+
+ // The subagent's render_a2ui call is forwarded onto the wire so the hosting
+ // layer can paint its argument fragments progressively — but that means it
+ // becomes part of the persisted conversation. Emit a matching tool result so
+ // the assistant tool call is balanced; an unanswered tool call would make the
+ // next turn's history invalid (e.g. OpenAI rejects it). The painted surface
+ // comes from the streamed arguments, so this result is a bare acknowledgement.
+ if (renderCall is not null)
+ {
+ yield return new AgentResponseUpdate(
+ ChatRole.Tool,
+ [new FunctionResultContent(renderCall.CallId, ParseEnvelope(RenderAcknowledgement))]);
+ }
+
+ // Validation and attempt accounting are shared with the non-streaming recovery
+ // loop so the two paths cannot drift on attempt semantics.
+ A2UIAttemptRecord record = A2UIGenerationRecovery.ValidateAttempt(attempt, renderArgs, this._parameters.Catalog);
+ attempts.Add(record);
+ this._parameters.OnAttempt?.Invoke(record);
+
+ if (record.Ok)
+ {
+ envelopeBox.Value = ParseEnvelope(A2UIToolkit.BuildA2UIEnvelope(
+ renderArgs!,
+ prep.IsUpdate,
+ targetSurfaceId,
+ prep.Prior,
+ this._parameters.DefaultSurfaceId,
+ this._parameters.DefaultCatalogId));
+ yield break;
+ }
+
+ lastErrors = record.Errors;
+ }
+
+ envelopeBox.Value = ParseEnvelope(A2UIGenerationRecovery.WrapRecoveryExhaustedEnvelope(maxAttempts, attempts));
+ }
+
+ /// Reads a string argument from a function call's argument dictionary.
+ private static string? GetStringArgument(IDictionary? arguments, string name) =>
+ arguments is not null && arguments.TryGetValue(name, out object? value)
+ ? value switch
+ {
+ string text => text,
+ JsonElement element when element.ValueKind == JsonValueKind.String => element.GetString(),
+ JsonValue jsonValue when jsonValue.TryGetValue(out string? text) => text,
+ _ => null,
+ }
+ : null;
+
+ ///
+ /// Builds the per-run options: clones the incoming chat options and appends a
+ /// freshly constructed generate_a2ui tool that captures this run's
+ /// conversation history and AG-UI state.
+ ///
+ private (List Messages, AgentRunOptions Options) PrepareRun(
+ IEnumerable messages,
+ AgentRunOptions? options)
+ {
+ List messageList = messages.ToList();
+
+ ChatClientAgentRunOptions runOptions = CloneRunOptions(options);
+ ChatOptions chatOptions = runOptions.ChatOptions ?? new ChatOptions();
+ runOptions.ChatOptions = chatOptions;
+
+ AIFunction generateTool = this.CreateGenerateA2UIFunction(messageList, ReadAgentState(chatOptions.AdditionalProperties));
+ chatOptions.Tools = (chatOptions.Tools ?? Enumerable.Empty())
+ .Where(t => !string.Equals(t.Name, generateTool.Name, StringComparison.Ordinal))
+ .Append(generateTool)
+ .ToList();
+
+ return (messageList, runOptions);
+ }
+
+ ///
+ /// Clones the caller's run options into a so the
+ /// base members (continuation token, background-response
+ /// opt-in, additional properties, response format) survive the per-run
+ /// augmentation rather than being dropped.
+ ///
+ private static ChatClientAgentRunOptions CloneRunOptions(AgentRunOptions? options) =>
+ options switch
+ {
+ ChatClientAgentRunOptions chatRunOptions => (ChatClientAgentRunOptions)chatRunOptions.Clone(),
+ not null => new ChatClientAgentRunOptions
+ {
+ ContinuationToken = options.ContinuationToken,
+ AllowBackgroundResponses = options.AllowBackgroundResponses,
+ AdditionalProperties = options.AdditionalProperties?.Clone(),
+ ResponseFormat = options.ResponseFormat,
+ },
+ null => new ChatClientAgentRunOptions(),
+ };
+
+ ///
+ /// Builds the per-run generate_a2ui tool with the run's conversation history and
+ /// AG-UI state captured in its closure.
+ ///
+ private AIFunction CreateGenerateA2UIFunction(IReadOnlyList messages, A2UIAgentState state)
+ {
+ List history = messages.Select(ToHistoryMessage).ToList();
+
+ // The tool returns the parsed envelope (not the JSON string): a string result
+ // would be JSON-serialized a second time on its way into the AG-UI tool-result
+ // event, and the A2UI middleware would have to undo the double encoding.
+ async Task GenerateA2UIAsync(
+ [Description(A2UIToolDefinitions.IntentArgumentDescription)] string? intent = null,
+ [Description(A2UIToolDefinitions.TargetSurfaceIdArgumentDescription)] string? target_surface_id = null,
+ [Description(A2UIToolDefinitions.ChangesArgumentDescription)] string? changes = null,
+ CancellationToken cancellationToken = default)
+ {
+ A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest(
+ intent, target_surface_id, changes, history, state, this._parameters.Guidelines);
+ if (prep.Error is not null)
+ {
+ return ParseEnvelope(A2UIToolkit.WrapErrorEnvelope(prep.Error));
+ }
+
+ A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync(
+ prep.Prompt,
+ (prompt, attempt, ct) => this.InvokeRenderSubagentAsync(prompt, messages, ct),
+ args => A2UIToolkit.BuildA2UIEnvelope(
+ args,
+ prep.IsUpdate,
+ target_surface_id,
+ prep.Prior,
+ this._parameters.DefaultSurfaceId,
+ this._parameters.DefaultCatalogId),
+ this._parameters.Catalog,
+ this._parameters.Recovery,
+ this._parameters.OnAttempt,
+ cancellationToken).ConfigureAwait(false);
+
+ return ParseEnvelope(result.Envelope);
+ }
+
+ return AIFunctionFactory.Create(
+ GenerateA2UIAsync,
+ this._parameters.ToolName,
+ this._parameters.ToolDescription);
+ }
+
+ ///
+ /// Runs the UI-generation subagent with a forced render_a2ui tool call and
+ /// returns the call's structured arguments, or when the model
+ /// did not call the tool (a retryable failure in the recovery loop).
+ ///
+ private async ValueTask InvokeRenderSubagentAsync(
+ string prompt,
+ IReadOnlyList messages,
+ CancellationToken cancellationToken)
+ {
+ ChatResponse response = await this._subagentChatClient
+ .GetResponseAsync(BuildSubagentMessages(prompt, messages), CreateSubagentOptions(), cancellationToken)
+ .ConfigureAwait(false);
+
+ FunctionCallContent? call = response.Messages
+ .SelectMany(m => m.Contents)
+ .OfType()
+ .FirstOrDefault(c => string.Equals(c.Name, A2UIConstants.RenderA2UIToolName, StringComparison.Ordinal));
+
+ return call?.Arguments is { } arguments ? ToJsonObject(arguments) : null;
+ }
+
+ /// Builds the render subagent's message list: the generation prompt plus the conversation.
+ private static List BuildSubagentMessages(string prompt, IReadOnlyList messages) =>
+ [new ChatMessage(ChatRole.System, prompt), .. messages];
+
+ /// Builds the render subagent's chat options: a forced render_a2ui structured call.
+ private static ChatOptions CreateSubagentOptions() => new()
+ {
+ Tools = [new RenderA2UIToolDeclaration()],
+ ToolMode = ChatToolMode.RequireSpecific(A2UIConstants.RenderA2UIToolName),
+ };
+
+ ///
+ /// Reads the AG-UI state slice from the additional properties stamped by the AG-UI
+ /// hosting layer: forwarded context entries plus the A2UI component catalog entry
+ /// injected by the A2UI middleware.
+ ///
+ internal static A2UIAgentState ReadAgentState(AdditionalPropertiesDictionary? properties)
+ {
+ if (properties is null ||
+ !properties.TryGetValue("ag_ui_context", out object? contextValue) ||
+ contextValue is not IEnumerable> entries)
+ {
+ return new A2UIAgentState();
+ }
+
+ List context = [];
+ string? schema = null;
+ foreach (KeyValuePair entry in entries)
+ {
+ if (string.Equals(entry.Key, A2UIConstants.A2UISchemaContextDescription, StringComparison.Ordinal))
+ {
+ schema = entry.Value;
+ }
+ else
+ {
+ context.Add(new A2UIContextEntry(entry.Key, entry.Value));
+ }
+ }
+
+ return new A2UIAgentState { Context = context, A2UISchema = schema };
+ }
+
+ ///
+ /// Maps a chat message onto the toolkit's history shape: the role name plus the
+ /// message's textual content (for tool results, the function result payload).
+ ///
+ private static A2UIHistoryMessage ToHistoryMessage(ChatMessage message)
+ {
+ string? content = message.Text;
+ if (string.IsNullOrEmpty(content) && message.Role == ChatRole.Tool)
+ {
+ // A message can carry multiple tool results (parallel calls); use the first
+ // one with usable textual content rather than only the first item.
+ foreach (FunctionResultContent result in message.Contents.OfType())
+ {
+ content = result.Result switch
+ {
+ string text => text,
+ JsonElement { ValueKind: JsonValueKind.String } element => element.GetString(),
+ JsonElement element => element.GetRawText(),
+ JsonValue value when value.TryGetValue(out string? text) => text,
+ JsonNode node => node.ToJsonString(),
+ _ => null,
+ };
+
+ if (!string.IsNullOrEmpty(content))
+ {
+ break;
+ }
+ }
+ }
+
+ return new A2UIHistoryMessage(message.Role.Value, content);
+ }
+
+ private static JsonElement ParseEnvelope(string envelope)
+ {
+ using var document = JsonDocument.Parse(envelope);
+ return document.RootElement.Clone();
+ }
+
+ private static JsonObject ToJsonObject(IDictionary arguments)
+ {
+ var result = new JsonObject();
+ foreach (KeyValuePair argument in arguments)
+ {
+ result[argument.Key] = argument.Value switch
+ {
+ null => null,
+ JsonNode node => node.DeepClone(),
+ JsonElement element => JsonNode.Parse(element.GetRawText()),
+ string text => JsonValue.Create(text),
+ bool flag => JsonValue.Create(flag),
+ int number => JsonValue.Create(number),
+ long number => JsonValue.Create(number),
+ double number => JsonValue.Create(number),
+ _ => JsonNode.Parse(JsonSerializer.Serialize(argument.Value, AIJsonUtilities.DefaultOptions.GetTypeInfo(argument.Value.GetType()))),
+ };
+ }
+
+ return result;
+ }
+
+ ///
+ /// The schema-only declaration of the planner-facing generate_a2ui tool, used on
+ /// the streaming path so the planner's call surfaces on the update stream instead of
+ /// being invoked by the automatic function-invocation layer.
+ ///
+ private sealed class GenerateA2UIToolDeclaration : AIFunctionDeclaration
+ {
+ private static readonly JsonElement s_schema = ParseSchema();
+
+ private readonly string _name;
+ private readonly string _description;
+
+ public GenerateA2UIToolDeclaration(string name, string description)
+ {
+ this._name = name;
+ this._description = description;
+ }
+
+ private static JsonElement ParseSchema()
+ {
+ var schema = new JsonObject
+ {
+ ["type"] = "object",
+ ["properties"] = new JsonObject
+ {
+ ["intent"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["description"] = A2UIToolDefinitions.IntentArgumentDescription,
+ },
+ ["target_surface_id"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["description"] = A2UIToolDefinitions.TargetSurfaceIdArgumentDescription,
+ },
+ ["changes"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["description"] = A2UIToolDefinitions.ChangesArgumentDescription,
+ },
+ },
+ };
+ using var document = JsonDocument.Parse(schema.ToJsonString());
+ return document.RootElement.Clone();
+ }
+
+ public override string Name => this._name;
+
+ public override string Description => this._description;
+
+ public override JsonElement JsonSchema => s_schema;
+ }
+
+ ///
+ /// The schema-only declaration of the inner render_a2ui structured-output tool.
+ /// The subagent is forced to call it; the adapter reads the arguments instead of invoking it.
+ ///
+ private sealed class RenderA2UIToolDeclaration : AIFunctionDeclaration
+ {
+ // Name, description, and schema all derive from the canonical tool definition so
+ // the declaration cannot drift from what other A2UI hosts advertise.
+ private static readonly (string Description, JsonElement Schema) s_definition = ParseDefinition();
+
+ private static (string Description, JsonElement Schema) ParseDefinition()
+ {
+ JsonNode function = A2UIToolDefinitions.CreateRenderA2UIToolDefinition()["function"]!;
+ using var document = JsonDocument.Parse(function["parameters"]!.ToJsonString());
+ return (function["description"]!.GetValue(), document.RootElement.Clone());
+ }
+
+ public override string Name => A2UIConstants.RenderA2UIToolName;
+
+ public override string Description => s_definition.Description;
+
+ public override JsonElement JsonSchema => s_definition.Schema;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs
new file mode 100644
index 00000000000..cb147e94b6b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIConstants.cs
@@ -0,0 +1,67 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.AGUI.A2UI;
+
+///
+/// Shared constants for the A2UI toolkit, mirroring the canonical values used by the
+/// TypeScript (@ag-ui/a2ui-toolkit) and Python (ag-ui-a2ui-toolkit) implementations.
+///
+///
+/// These values are part of the cross-language A2UI wire contract and must not diverge
+/// from the sibling implementations.
+///
+public static class A2UIConstants
+{
+ ///
+ /// The JSON key that wraps an array of A2UI operations in a tool result envelope.
+ /// AG-UI middlewares scan tool results for this key to detect renderable surfaces.
+ ///
+ public const string A2UIOperationsKey = "a2ui_operations";
+
+ ///
+ /// The catalog identifier of the A2UI v0.9 basic component catalog.
+ /// Used as the default catalog when the host does not configure one.
+ ///
+ public const string BasicCatalogId = "https://a2ui.org/specification/v0_9/basic_catalog.json";
+
+ ///
+ /// The fallback surface identifier used when the model output does not carry a usable one.
+ ///
+ public const string DefaultSurfaceId = "dynamic-surface";
+
+ ///
+ /// The protocol version stamped on every emitted A2UI operation.
+ ///
+ public const string ProtocolVersion = "v0.9";
+
+ ///
+ /// The default name of the planner-facing tool that delegates surface generation to a subagent.
+ ///
+ public const string GenerateA2UIToolName = "generate_a2ui";
+
+ ///
+ /// The name of the inner structured-output tool the subagent is forced to call.
+ ///
+ public const string RenderA2UIToolName = "render_a2ui";
+
+ ///
+ /// The default maximum number of generation attempts in the validate-and-retry recovery loop.
+ ///
+ public const int MaxA2UIAttempts = 3;
+
+ ///
+ /// The activity type identifier reserved for the A2UI recovery status channel. Part of
+ /// the cross-language wire contract (it mirrors the TypeScript toolkit's
+ /// A2UI_RECOVERY_ACTIVITY_TYPE); pinned here so adapters and tests can reference it.
+ ///
+ public const string A2UIRecoveryActivityType = "a2ui_recovery";
+
+ ///
+ /// The description the AG-UI A2UI middleware uses for the context entry that carries
+ /// the component catalog schema. Adapters match this description to route the catalog
+ /// into the subagent prompt's "Available Components" section.
+ ///
+ public const string A2UISchemaContextDescription =
+ "A2UI Component Schema — available components for generating UI surfaces. " +
+ "Use these component names and properties when creating A2UI operations.";
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs
new file mode 100644
index 00000000000..a388938c56f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIGenerationRecovery.cs
@@ -0,0 +1,209 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI;
+
+///
+/// One attempt of the A2UI validate-and-retry generation loop.
+///
+/// The 1-based attempt number.
+/// Whether the attempt produced a valid component tree.
+/// The validation errors when is .
+public sealed record A2UIAttemptRecord(int Attempt, bool Ok, IReadOnlyList Errors);
+
+///
+/// Configuration for the A2UI generation recovery loop.
+///
+public sealed class A2UIRecoveryConfig
+{
+ ///
+ /// Gets the maximum number of generation attempts. Defaults to
+ /// when unset.
+ ///
+ public int? MaxAttempts { get; init; }
+}
+
+///
+/// The outcome of the A2UI generation recovery loop.
+///
+///
+/// The operations envelope on success, or a structured hard-failure envelope
+/// (code: "a2ui_recovery_exhausted") when all attempts failed.
+///
+/// The per-attempt records, in order.
+/// Whether a valid surface was produced.
+public sealed record A2UIRecoveryResult(string Envelope, IReadOnlyList Attempts, bool Ok);
+
+///
+/// The A2UI validate-and-retry generation loop, mirroring
+/// runA2UIGenerationWithRecovery / run_a2ui_generation_with_recovery in the sibling toolkits.
+///
+///
+/// Each attempt invokes the subagent, validates the structured tool output, and either
+/// returns the built envelope (first valid attempt wins) or retries with the prior
+/// attempt's errors appended to the prompt. A missing tool call counts as a failed,
+/// retryable attempt. After the attempt cap is reached, a structured hard-failure
+/// envelope is returned instead of throwing, so the conversation stays usable.
+///
+public static class A2UIGenerationRecovery
+{
+ internal static readonly A2UIValidationError NoToolCallError = new(
+ A2UIValidationErrorCodes.EmptyComponents,
+ "components",
+ "Sub-agent did not call render_a2ui");
+
+ ///
+ /// Formats validation errors as a compact, model-readable list
+ /// (- [code] path: message per line).
+ ///
+ /// The errors to format.
+ /// The formatted block.
+ public static string FormatValidationErrors(IEnumerable errors)
+ {
+ Throw.IfNull(errors);
+ return string.Join("\n", errors.Select(e => $"- [{e.Code}] {e.Path}: {e.Message}"));
+ }
+
+ ///
+ /// Appends a fix-it block carrying to .
+ /// Returns the prompt unchanged when there are no errors.
+ ///
+ /// The base subagent prompt.
+ /// The prior attempt's validation errors.
+ /// The augmented prompt.
+ public static string AugmentPromptWithValidationErrors(string prompt, IReadOnlyList errors)
+ {
+ Throw.IfNull(errors);
+ return errors.Count == 0
+ ? prompt
+ : $"{prompt}\n\n## Previous attempt was invalid — fix these and regenerate:\n{FormatValidationErrors(errors)}\n";
+ }
+
+ ///
+ /// Runs the validate-and-retry loop until a valid surface is produced or the attempt cap is reached.
+ ///
+ /// The subagent system prompt produced by request preparation.
+ ///
+ /// Invokes the subagent with the (possibly error-augmented) prompt and the 1-based attempt
+ /// number, returning the structured render_a2ui tool arguments, or
+ /// when the model did not call the tool.
+ ///
+ /// Builds the final operations envelope from validated tool arguments.
+ /// The catalog used for semantic validation, when available.
+ /// Loop configuration overrides.
+ /// Observability callback invoked after each attempt is validated.
+ /// A token to cancel the loop between attempts.
+ /// The loop outcome.
+ public static async Task RunAsync(
+ string basePrompt,
+ Func> invokeSubagentAsync,
+ Func buildEnvelope,
+ A2UIValidationCatalog? catalog = null,
+ A2UIRecoveryConfig? config = null,
+ Action? onAttempt = null,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(basePrompt);
+ Throw.IfNull(invokeSubagentAsync);
+ Throw.IfNull(buildEnvelope);
+
+ int maxAttempts = ResolveMaxAttempts(config);
+ List attempts = [];
+ IReadOnlyList lastErrors = [];
+
+ for (int attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string prompt = AugmentPromptWithValidationErrors(basePrompt, lastErrors);
+ JsonObject? args = await invokeSubagentAsync(prompt, attempt, cancellationToken).ConfigureAwait(false);
+
+ A2UIAttemptRecord record = ValidateAttempt(attempt, args, catalog);
+ attempts.Add(record);
+ onAttempt?.Invoke(record);
+
+ if (record.Ok)
+ {
+ return new A2UIRecoveryResult(buildEnvelope(args!), attempts, Ok: true);
+ }
+
+ lastErrors = record.Errors;
+ }
+
+ return new A2UIRecoveryResult(WrapRecoveryExhaustedEnvelope(maxAttempts, attempts), attempts, Ok: false);
+ }
+
+ ///
+ /// Resolves the attempt cap, falling back to
+ /// when the configured value is unset or non-positive. A zero/negative cap would skip
+ /// the loop entirely and emit a confusing "0 attempt(s)" envelope, so it is treated as
+ /// unset rather than honored. Shared by both generation paths.
+ ///
+ /// The recovery configuration, if any.
+ /// The effective maximum number of attempts (at least 1).
+ internal static int ResolveMaxAttempts(A2UIRecoveryConfig? config)
+ => config?.MaxAttempts is int max && max > 0 ? max : A2UIConstants.MaxA2UIAttempts;
+
+ ///
+ /// Validates one attempt's structured render_a2ui arguments, narrowing the
+ /// untrusted model output to the expected component/data shapes. A
+ /// (the subagent did not call the tool) is a failed, retryable
+ /// attempt. Shared by the non-streaming loop and the streaming twin in A2UIAgent
+ /// so the two cannot drift on attempt semantics.
+ ///
+ /// The 1-based attempt number.
+ /// The structured tool arguments, or when absent.
+ /// The catalog used for semantic validation, when available.
+ /// The attempt record.
+ internal static A2UIAttemptRecord ValidateAttempt(int attempt, JsonObject? args, A2UIValidationCatalog? catalog)
+ {
+ if (args is null)
+ {
+ return new A2UIAttemptRecord(attempt, Ok: false, [NoToolCallError]);
+ }
+
+ JsonArray? components = args["components"] as JsonArray;
+ JsonObject? data = args["data"] as JsonObject;
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components, data, catalog);
+ return new A2UIAttemptRecord(attempt, result.Valid, result.Errors);
+ }
+
+ internal static string WrapRecoveryExhaustedEnvelope(int maxAttempts, IReadOnlyList attempts)
+ {
+ var attemptsArray = new JsonArray();
+ foreach (A2UIAttemptRecord attempt in attempts)
+ {
+ var errorsArray = new JsonArray();
+ foreach (A2UIValidationError error in attempt.Errors)
+ {
+ errorsArray.Add((JsonNode)new JsonObject
+ {
+ ["code"] = error.Code,
+ ["path"] = error.Path,
+ ["message"] = error.Message,
+ });
+ }
+
+ attemptsArray.Add((JsonNode)new JsonObject
+ {
+ ["attempt"] = attempt.Attempt,
+ ["ok"] = attempt.Ok,
+ ["errors"] = errorsArray,
+ });
+ }
+
+ return new JsonObject
+ {
+ ["error"] = $"Failed to generate valid A2UI after {maxAttempts} attempt(s)",
+ ["code"] = "a2ui_recovery_exhausted",
+ ["attempts"] = attemptsArray,
+ }.ToJsonString();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs
new file mode 100644
index 00000000000..0fda2deb430
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIOperationBuilder.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI;
+
+///
+/// Builders for the A2UI v0.9 operations that make up a surface lifecycle:
+/// createSurface, updateComponents, and updateDataModel.
+///
+///
+/// Each builder returns a single operation object of the shape
+/// { "version": "v0.9", "<operation>": { ... } }, matching the A2UI v0.9
+/// envelope specification and the sibling TypeScript/Python toolkit implementations.
+/// Node inputs are deep-cloned, so callers may pass nodes that are already attached
+/// to another document.
+///
+public static class A2UIOperationBuilder
+{
+ ///
+ /// Builds a createSurface operation.
+ ///
+ /// The identifier of the surface to create.
+ /// The identifier of the component catalog the surface renders against.
+ /// The operation as a .
+ public static JsonObject CreateSurface(string surfaceId, string catalogId) => new()
+ {
+ ["version"] = A2UIConstants.ProtocolVersion,
+ ["createSurface"] = new JsonObject
+ {
+ ["surfaceId"] = surfaceId,
+ ["catalogId"] = catalogId,
+ },
+ };
+
+ ///
+ /// Builds an updateComponents operation carrying a flat component array.
+ ///
+ /// The identifier of the target surface.
+ /// The flat A2UI component array.
+ /// The operation as a .
+ public static JsonObject UpdateComponents(string surfaceId, IEnumerable components) => new()
+ {
+ ["version"] = A2UIConstants.ProtocolVersion,
+ ["updateComponents"] = new JsonObject
+ {
+ ["surfaceId"] = surfaceId,
+ ["components"] = new JsonArray(components.Select(c => c?.DeepClone()).ToArray()),
+ },
+ };
+
+ ///
+ /// Builds an updateDataModel operation that writes at .
+ ///
+ /// The identifier of the target surface.
+ /// The value to write into the surface data model.
+ /// The JSON-pointer-style path to write at. Defaults to the root path "/".
+ /// The operation as a .
+ public static JsonObject UpdateDataModel(string surfaceId, JsonNode? value, string path = "/") => new()
+ {
+ ["version"] = A2UIConstants.ProtocolVersion,
+ ["updateDataModel"] = new JsonObject
+ {
+ ["surfaceId"] = surfaceId,
+ ["path"] = path,
+ ["value"] = value?.DeepClone(),
+ },
+ };
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs
new file mode 100644
index 00000000000..e5322ba2ce5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolParams.cs
@@ -0,0 +1,294 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI;
+
+///
+/// Shared behavior knobs for A2UI tool factories. Every framework adapter accepts this
+/// exact shape, so a new knob reaches all adapters without signature changes.
+///
+///
+/// Mirrors A2UIToolParams in the sibling toolkits, minus the model field:
+/// in .NET the subagent chat client is a framework concern owned by the adapter's own
+/// factory signature, not by this parameter object.
+///
+public sealed class A2UIToolParams
+{
+ /// Gets the prompt-section overrides.
+ public A2UIGuidelines? Guidelines { get; init; }
+
+ /// Gets the fallback surface id. Empty or unset falls back to .
+ public string? DefaultSurfaceId { get; init; }
+
+ /// Gets the catalog id for created surfaces. Empty or unset falls back to .
+ public string? DefaultCatalogId { get; init; }
+
+ /// Gets the planner-facing tool name. Empty or unset falls back to .
+ public string? ToolName { get; init; }
+
+ /// Gets the planner-facing tool description. Empty or unset falls back to the canonical description.
+ public string? ToolDescription { get; init; }
+
+ /// Gets the catalog used for semantic validation in the recovery loop.
+ public A2UIValidationCatalog? Catalog { get; init; }
+
+ /// Gets the recovery-loop configuration.
+ public A2UIRecoveryConfig? Recovery { get; init; }
+
+ /// Gets the per-attempt observability callback.
+ public Action? OnAttempt { get; init; }
+}
+
+///
+/// with every defaultable field resolved to its effective value.
+///
+/// The prompt-section overrides, passed through.
+/// The effective fallback surface id.
+/// The effective default catalog id.
+/// The effective planner-facing tool name.
+/// The effective planner-facing tool description.
+/// The validation catalog, passed through.
+/// The recovery configuration, passed through.
+/// The per-attempt callback, passed through.
+public sealed record A2UIResolvedToolParams(
+ A2UIGuidelines? Guidelines,
+ string DefaultSurfaceId,
+ string DefaultCatalogId,
+ string ToolName,
+ string ToolDescription,
+ A2UIValidationCatalog? Catalog,
+ A2UIRecoveryConfig? Recovery,
+ Action? OnAttempt);
+
+///
+/// Canonical tool definitions and descriptions shared by all A2UI adapters.
+///
+public static class A2UIToolDefinitions
+{
+ ///
+ /// Gets the planner-facing description of the generate_a2ui tool.
+ ///
+ public const string GenerateA2UIToolDescription =
+ "Generate or update a dynamic A2UI surface based on the conversation. " +
+ "A secondary LLM designs the UI components and data. " +
+ "Use intent='create' (default) when the user requests new visual content " +
+ "(cards, forms, lists, dashboards, comparisons, etc.). " +
+ "Use intent='update' with target_surface_id to modify a surface you " +
+ "previously rendered (e.g. 'change the second card's price', " +
+ "'add a Buy button', 'use red instead of blue').";
+
+ ///
+ /// Gets the planner-facing description of the generate_a2ui tool's intent argument.
+ ///
+ public const string IntentArgumentDescription =
+ "'create' to render a new surface; 'update' to modify a surface " +
+ "previously rendered in this conversation. Defaults to 'create'.";
+
+ ///
+ /// Gets the planner-facing description of the generate_a2ui tool's
+ /// target_surface_id argument.
+ ///
+ public const string TargetSurfaceIdArgumentDescription =
+ "Required when intent='update'. The surface id of the prior render to modify.";
+
+ ///
+ /// Gets the planner-facing description of the generate_a2ui tool's changes argument.
+ ///
+ public const string ChangesArgumentDescription =
+ "Optional natural-language description of the changes to apply when intent='update'.";
+
+ ///
+ /// Creates the OpenAI-style function definition of the inner render_a2ui
+ /// structured-output tool (surfaceId, components, data;
+ /// surfaceId and components required).
+ ///
+ /// A fresh, caller-owned with the tool definition.
+ public static JsonObject CreateRenderA2UIToolDefinition() => new()
+ {
+ ["type"] = "function",
+ ["function"] = new JsonObject
+ {
+ ["name"] = A2UIConstants.RenderA2UIToolName,
+ ["description"] =
+ "Render a dynamic A2UI v0.9 surface. The root component must have " +
+ "id 'root'. Use components from the available catalog only.",
+ ["parameters"] = new JsonObject
+ {
+ ["type"] = "object",
+ ["properties"] = new JsonObject
+ {
+ ["surfaceId"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["description"] = "Unique surface identifier.",
+ },
+ ["components"] = new JsonObject
+ {
+ ["type"] = "array",
+ ["description"] =
+ "A2UI v0.9 component array (flat format). The root " +
+ "component must have id 'root'.",
+ ["items"] = new JsonObject { ["type"] = "object" },
+ },
+ ["data"] = new JsonObject
+ {
+ ["type"] = "object",
+ ["description"] =
+ "Optional initial data model for the surface (form " +
+ "values, list items for data-bound components, etc.).",
+ },
+ },
+ ["required"] = new JsonArray("surfaceId", "components"),
+ },
+ },
+ };
+
+ ///
+ /// Fills canonical defaults for every unset or empty-string field of
+ /// . Empty strings fall back to defaults rather than
+ /// propagating into tool advertisements or emitted operations.
+ ///
+ /// The raw parameters, or for all defaults.
+ /// The resolved parameters.
+ public static A2UIResolvedToolParams ResolveA2UIToolParams(A2UIToolParams? parameters) => new(
+ Guidelines: parameters?.Guidelines,
+ DefaultSurfaceId: DefaultOr(parameters?.DefaultSurfaceId, A2UIConstants.DefaultSurfaceId),
+ DefaultCatalogId: DefaultOr(parameters?.DefaultCatalogId, A2UIConstants.BasicCatalogId),
+ ToolName: DefaultOr(parameters?.ToolName, A2UIConstants.GenerateA2UIToolName),
+ ToolDescription: DefaultOr(parameters?.ToolDescription, GenerateA2UIToolDescription),
+ Catalog: parameters?.Catalog,
+ Recovery: parameters?.Recovery,
+ OnAttempt: parameters?.OnAttempt);
+
+ private static string DefaultOr(string? value, string fallback)
+ => string.IsNullOrEmpty(value) ? fallback : value!;
+}
+
+///
+/// The built-in default prompt blocks shared by all adapters. The exact text is part of
+/// the cross-language contract and is ported verbatim from the sibling toolkits.
+///
+public static class A2UIPromptDefaults
+{
+ ///
+ /// Gets the default generation-guidelines block (A2UI protocol rules: ids, paths,
+ /// bindings, data model). Ported verbatim from the sibling toolkits.
+ ///
+ public static string GenerationGuidelines => """
+ Generate A2UI v0.9 JSON.
+
+ ## A2UI Protocol Instructions
+
+ A2UI (Agent to UI) is a protocol for rendering rich UI surfaces from agent responses.
+
+ CRITICAL: You MUST call the render_a2ui tool with ALL of these arguments:
+ - surfaceId: A unique ID for the surface (e.g. "product-comparison")
+ - components: REQUIRED — the A2UI component array. NEVER omit this. Use a List with
+ children: { componentId: "card-id", path: "/items" } for repeating cards.
+ - data: OPTIONAL — a JSON object written to the root of the surface data model.
+ Use for pre-filling form values or providing data for path-bound components.
+ - every component must have the "component" field specifying the component type (e.g. "Text", "Image", "Row", "Column", "List", "Button", etc.)
+
+ COMPONENT ID RULES:
+ - Every component ID must be unique within the surface.
+ - A component MUST NOT reference itself as child/children. This causes a
+ circular dependency error. For example, if a component has id="avatar",
+ its child must be a DIFFERENT id (e.g. "avatar-img"), never "avatar".
+ - The child/children tree must be a DAG — no cycles allowed.
+
+ PATH RULES FOR TEMPLATES:
+ Components inside a repeating List use RELATIVE paths (no leading slash).
+ The path is resolved relative to each array item automatically.
+ If List has children: { componentId: "card", path: "/items" } and item has key "name",
+ use { "path": "name" } (NO leading slash — relative to item).
+ CRITICAL: Do NOT use "/name" (absolute) inside templates — use "name" (relative).
+ The List's own path ("/items") uses a leading slash (absolute), but all
+ components INSIDE the template card use paths WITHOUT leading slash.
+ Do NOT use "/items/0/name" or "/items/{@key}/name" — just "name".
+
+ DATA MODEL:
+ The "data" key in the tool args is a plain JSON object that initializes the surface
+ data model. Components bound to paths (e.g. "value": { "path": "/form/name" })
+ read from and write to this data model. Examples:
+ For forms: "data": { "form": { "name": "Alice", "email": "" } }
+ For lists: "data": { "items": [{"name": "Product A"}, {"name": "Product B"}] }
+ For mixed: "data": { "form": { "query": "" }, "results": [...] }
+
+ FORMS AND TWO-WAY DATA BINDING:
+ To create editable forms, bind input components to data model paths using { "path": "..." }.
+ The client automatically writes user input back to the data model at the bound path.
+ CRITICAL: Using a literal value (e.g. "value": "") makes the field READ-ONLY.
+ You MUST use { "path": "..." } to make inputs editable.
+
+ All input components use "value" as the binding property:
+ - TextField: "value": { "path": "/form/fieldName" }
+ - CheckBox: "value": { "path": "/form/isChecked" }
+ - Slider: "value": { "path": "/form/sliderVal" }
+ - DateTimeInput: "value": { "path": "/form/date" }
+ - ChoicePicker: "value": { "path": "/form/choices" }
+
+ To retrieve form values when a button is clicked, include "context" with path references
+ in the button's action. Paths are resolved to their current values at click time:
+ "action": { "event": { "name": "submit", "context": { "userName": { "path": "/form/name" } } } }
+
+ To pre-fill form values, pass initial data via the "data" tool argument:
+ "data": { "form": { "name": "Markus" } }
+
+ FORM EXAMPLE (editable text field with pre-filled value + submit button):
+ "components": [
+ { "id": "root", "component": "Card", "child": "form-col" },
+ { "id": "form-col", "component": "Column", "children": ["name-field", "submit-row"] },
+ { "id": "name-field", "component": "TextField", "label": "Name", "value": { "path": "/form/name" } },
+ { "id": "submit-row", "component": "Row", "justify": "end", "children": ["submit-btn"] },
+ { "id": "submit-btn", "component": "Button", "child": "btn-text", "variant": "primary",
+ "action": { "event": { "name": "submit", "context": { "userName": { "path": "/form/name" } } } } },
+ { "id": "btn-text", "component": "Text", "text": "Submit" }
+ ],
+ "data": { "form": { "name": "Markus" } }
+ """;
+
+ ///
+ /// Gets the default design-guidelines block (visual hierarchy, layout patterns).
+ /// Ported verbatim from the sibling toolkits.
+ ///
+ public static string DesignGuidelines => """
+ Create polished, visually appealing interfaces:
+ - Always include a title heading (h2) for the surface, outside the List.
+ Wrap in a Column: [title, list] as root.
+ - For card templates, create clear visual hierarchy:
+ - h3 for primary text (names, titles)
+ - h2 for featured numbers (prices, scores) — makes them stand out
+ - caption for secondary info (ratings, categories, metadata)
+ - body for descriptions
+ - Use Divider between logical sections within cards.
+ - Use Row with justify="spaceBetween" for label-value pairs
+ (e.g. "Rating" on left, "4.5/5" on right).
+ - Include images when relevant (logos, icons, product photos):
+ - Use Image component with variant="smallFeature" or "avatar"
+ - Prefer company logos for branded products — Google favicons are reliable:
+ https://www.google.com/s2/favicons?domain=sony.com&sz=128
+ https://www.google.com/s2/favicons?domain=bose.com&sz=128
+ - For generic icons: https://placehold.co/128x128/EEE/999?text=🎧
+ - Do NOT invent Unsplash photo-IDs — they will 404. Only use real, known URLs.
+ - Use horizontal List direction for side-by-side comparison cards.
+ - Keep cards clean — avoid clutter. Whitespace is good.
+ - Use consistent surfaceIds (lowercase, hyphenated).
+ - NEVER use the same ID for a component and its child — this creates a
+ circular dependency. E.g. if id="avatar", child must NOT be "avatar".
+ - Both Row and Column support "justify" and "align".
+ - Add Button for interactivity. Button needs child (Text ID) + action.
+ Action MUST use this exact nested format:
+ "action": { "event": { "name": "myAction", "context": { "key": "value" } } }
+ The "event" key holds an OBJECT with "name" (required) and "context" (optional).
+ Do NOT use a flat format like {"event": "name"} — "event" must be an object.
+ Use variant="primary" for main action buttons, variant="borderless" for links.
+ - For forms: wrap fields in a Card with a Column. Place the submit button in a
+ Row with justify="end". Every input MUST use path binding on the "value" property
+ (e.g. "value": { "path": "/form/name" }) to be editable. The submit button's action
+ context MUST reference the same paths to capture the user's input.
+
+ Use the SAME surfaceId as the main surface. Match action names to Button action event names.
+ """;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs
new file mode 100644
index 00000000000..bdf9d8da2ff
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIToolkit.cs
@@ -0,0 +1,445 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI;
+
+///
+/// Pure helpers for building A2UI subagent tools: prompt assembly, conversation-history
+/// surface reconstruction, request preparation, and operations-envelope assembly.
+///
+///
+/// This is the .NET port of @ag-ui/a2ui-toolkit (TypeScript) and
+/// ag-ui-a2ui-toolkit (Python). All behavior — section ordering, fallback rules,
+/// surface-walk semantics, untrusted-output narrowing — is contract-tested for parity
+/// with the sibling implementations.
+///
+public static class A2UIToolkit
+{
+ private static readonly JsonSerializerOptions s_indentedOptions = new() { WriteIndented = true };
+
+ ///
+ /// Builds the context section of the subagent prompt from the AG-UI agent state:
+ /// one markdown section per described context entry, followed by the component
+ /// catalog under an ## Available Components heading when present.
+ ///
+ /// The AG-UI agent state slice, or .
+ /// The context prompt, possibly empty.
+ public static string BuildContextPrompt(A2UIAgentState? state)
+ {
+ List parts = [];
+
+ foreach (A2UIContextEntry entry in state?.Context ?? [])
+ {
+ // A null value with a description must not leak a literal "null"
+ // into the subagent prompt — coerce to the empty string first.
+ string value = entry.Value ?? string.Empty;
+ if (!string.IsNullOrEmpty(entry.Description))
+ {
+ parts.Add($"## {entry.Description}\n{value}\n");
+ }
+ else if (value.Length > 0)
+ {
+ parts.Add($"{value}\n");
+ }
+ }
+
+ if (!string.IsNullOrEmpty(state?.A2UISchema))
+ {
+ parts.Add($"## Available Components\n{state!.A2UISchema}\n");
+ }
+
+ return string.Join("\n", parts);
+ }
+
+ ///
+ /// Walks the conversation history backwards to reconstruct the latest known state of
+ /// from prior a2ui_operations tool results.
+ ///
+ ///
+ /// Within a message, operations apply in order (the last operation per field wins, and
+ /// deleteSurface resets the accumulator). Across messages, the newest mention is
+ /// authoritative and older messages only fill fields the newer ones did not set. When the
+ /// newest mention ends with the surface deleted, the surface is gone — older state is not
+ /// resurrected and is returned.
+ ///
+ /// The conversation history, oldest first.
+ /// The surface to look for.
+ /// The reconstructed surface state, or when absent or deleted.
+ public static A2UIPriorSurface? FindPriorSurface(IEnumerable messages, string surfaceId)
+ {
+ Throw.IfNull(messages);
+ Throw.IfNull(surfaceId);
+
+ JsonArray? components = null;
+ JsonNode? data = null;
+ bool dataSeen = false;
+ string? catalogId = null;
+ bool matched = false;
+
+ foreach (A2UIHistoryMessage message in messages.Reverse())
+ {
+ if (message.Role is not ("tool" or "ToolMessage") || message.Content is null)
+ {
+ continue;
+ }
+
+ JsonNode? parsed;
+ try
+ {
+ parsed = JsonNode.Parse(message.Content);
+ }
+ catch (JsonException)
+ {
+ // Conversation history is untrusted input — skip malformed content.
+ continue;
+ }
+
+ if (parsed is not JsonObject parsedObject ||
+ parsedObject[A2UIConstants.A2UIOperationsKey] is not JsonArray operations)
+ {
+ continue;
+ }
+
+ // Compute this message's end state for the surface by walking ops forward.
+ // deleteSurface resets the per-message accumulator; subsequent create/update
+ // ops in the same message restore it.
+ bool messageMentions = false;
+ bool messageDeleted = false;
+ string? messageCatalogId = null;
+ JsonArray? messageComponents = null;
+ JsonNode? messageData = null;
+ bool messageDataSeen = false;
+
+ foreach (JsonNode? operationNode in operations)
+ {
+ if (operationNode is not JsonObject operation)
+ {
+ continue;
+ }
+
+ if (operation["deleteSurface"] is JsonObject deleteSurface &&
+ TryGetString(deleteSurface["surfaceId"]) == surfaceId)
+ {
+ messageMentions = true;
+ messageDeleted = true;
+ messageCatalogId = null;
+ messageComponents = null;
+ messageData = null;
+ messageDataSeen = false;
+ continue;
+ }
+
+ if (operation["createSurface"] is JsonObject createSurface &&
+ TryGetString(createSurface["surfaceId"]) == surfaceId)
+ {
+ messageMentions = true;
+ messageDeleted = false;
+ if (TryGetString(createSurface["catalogId"]) is string opCatalogId)
+ {
+ messageCatalogId = opCatalogId;
+ }
+ }
+
+ if (operation["updateComponents"] is JsonObject updateComponents &&
+ TryGetString(updateComponents["surfaceId"]) == surfaceId)
+ {
+ messageMentions = true;
+ messageDeleted = false;
+ if (updateComponents["components"] is JsonArray opComponents)
+ {
+ // Clone: the captured nodes outlive this method inside the public
+ // A2UIPriorSurface, and a parent-attached JsonNode throws when a
+ // caller re-attaches it elsewhere.
+ messageComponents = (JsonArray)opComponents.DeepClone();
+ }
+ }
+
+ if (operation["updateDataModel"] is JsonObject updateDataModel &&
+ TryGetString(updateDataModel["surfaceId"]) == surfaceId)
+ {
+ messageMentions = true;
+ messageDeleted = false;
+ messageData = updateDataModel["value"]?.DeepClone();
+ messageDataSeen = true;
+ }
+ }
+
+ if (!messageMentions)
+ {
+ continue;
+ }
+
+ if (!matched)
+ {
+ // Newest message that mentions the surface — its end state is authoritative.
+ if (messageDeleted)
+ {
+ return null;
+ }
+
+ matched = true;
+ catalogId = messageCatalogId;
+ components = messageComponents;
+ data = messageData;
+ dataSeen = messageDataSeen;
+ }
+ else if (!messageDeleted)
+ {
+ // Older message: fill in only the fields not yet set. A delete here is
+ // overridden by the newer state already recorded.
+ catalogId ??= messageCatalogId;
+ components ??= messageComponents;
+ if (!dataSeen && messageDataSeen)
+ {
+ data = messageData;
+ dataSeen = true;
+ }
+ }
+
+ // Early-exit once every field is populated — nothing older can override.
+ if (matched && components is not null && catalogId is not null && dataSeen)
+ {
+ break;
+ }
+ }
+
+ return matched
+ ? new A2UIPriorSurface(components ?? [], data, catalogId)
+ : null;
+ }
+
+ ///
+ /// Assembles the full subagent system prompt in the canonical section order:
+ /// generation guidelines, design guidelines, context, composition guide, and —
+ /// when editing — the prior-surface edit block.
+ ///
+ /// The context section produced by .
+ /// Per-section overrides; see for the fallback rules.
+ /// The prior-surface context when editing an existing surface.
+ /// The assembled prompt, possibly empty.
+ public static string BuildSubagentPrompt(
+ string contextPrompt,
+ A2UIGuidelines? guidelines = null,
+ A2UIEditContext? editContext = null)
+ {
+ Throw.IfNull(contextPrompt);
+
+ // Per-field fallback: null → built-in default; "" → the host explicitly
+ // suppressed the block.
+ string generation = guidelines?.GenerationGuidelines ?? A2UIPromptDefaults.GenerationGuidelines;
+ string design = guidelines?.DesignGuidelines ?? A2UIPromptDefaults.DesignGuidelines;
+ string? compositionGuide = guidelines?.CompositionGuide;
+
+ List parts = [];
+ if (generation.Length > 0)
+ {
+ parts.Add(generation);
+ }
+
+ if (design.Length > 0)
+ {
+ parts.Add($"## Design Guidelines\n{design}");
+ }
+
+ if (contextPrompt.Length > 0)
+ {
+ parts.Add(contextPrompt);
+ }
+
+ if (!string.IsNullOrEmpty(compositionGuide))
+ {
+ parts.Add(compositionGuide!);
+ }
+
+ if (editContext is not null)
+ {
+ string componentsJson = (editContext.Prior.Components ?? []).ToJsonString(s_indentedOptions);
+ string dataJson = editContext.Prior.Data?.ToJsonString(s_indentedOptions) ?? "null";
+
+ var editBlock = new StringBuilder()
+ .Append("## Editing an existing surface\n")
+ .Append("You are editing surface '").Append(editContext.SurfaceId).Append("'. Produce the FULL ")
+ .Append("updated components array and data model — not just a diff. ")
+ .Append("Preserve component ids that the user has not asked to change so ")
+ .Append("the renderer can reconcile them. Reuse the same catalogId.\n\n")
+ .Append("### Previous components\n").Append(componentsJson).Append("\n\n")
+ .Append("### Previous data\n").Append(dataJson).Append('\n');
+
+ if (!string.IsNullOrEmpty(editContext.Changes))
+ {
+ editBlock.Append("\n### Requested changes\n").Append(editContext.Changes).Append('\n');
+ }
+
+ parts.Add(editBlock.ToString());
+ }
+
+ return string.Join("\n", parts.Where(p => p.Length > 0));
+ }
+
+ ///
+ /// Resolves the create/update intent, locates the prior surface for updates, and builds
+ /// the subagent prompt.
+ ///
+ /// "create" (default) or "update".
+ /// The surface to edit; required for the update intent.
+ /// An optional natural-language description of the requested changes.
+ /// The conversation history, oldest first.
+ /// The AG-UI agent state slice.
+ /// Prompt-section overrides.
+ ///
+ /// The prepared request. On the update path, a missing prior surface yields a result with
+ /// set and an empty prompt instead of throwing, so the
+ /// hosting tool can return a structured error envelope to the planner.
+ ///
+ public static A2UIPreparedRequest PrepareA2UIRequest(
+ string? intent,
+ string? targetSurfaceId,
+ string? changes,
+ IEnumerable messages,
+ A2UIAgentState? state,
+ A2UIGuidelines? guidelines = null)
+ {
+ Throw.IfNull(messages);
+
+ bool isUpdate = (intent ?? "create") == "update" && !string.IsNullOrEmpty(targetSurfaceId);
+ A2UIPriorSurface? prior = isUpdate ? FindPriorSurface(messages, targetSurfaceId!) : null;
+
+ if (isUpdate && prior is null)
+ {
+ return new A2UIPreparedRequest(
+ Prompt: string.Empty,
+ IsUpdate: isUpdate,
+ Prior: null,
+ Error: $"intent='update' requested target_surface_id='{targetSurfaceId}' " +
+ "but no prior render of that surface was found in conversation history");
+ }
+
+ string prompt = BuildSubagentPrompt(
+ BuildContextPrompt(state),
+ guidelines,
+ prior is not null ? new A2UIEditContext(targetSurfaceId!, prior, changes) : null);
+
+ return new A2UIPreparedRequest(prompt, isUpdate, prior, Error: null);
+ }
+
+ ///
+ /// Builds the final operations envelope from the subagent's structured tool output,
+ /// narrowing untrusted values to safe defaults.
+ ///
+ ///
+ /// The model output is untrusted: a missing, empty, or non-string surfaceId falls back
+ /// to the default; a non-array components becomes empty; a non-object data is
+ /// dropped. The catalog id is never taken from the model — it comes from the prior surface on
+ /// updates and from on creates. Empty-string defaults fall
+ /// back to the canonical constants.
+ ///
+ /// The structured render_a2ui tool arguments from the model.
+ /// Whether this is the update path.
+ /// The surface being updated; ignored on the create path.
+ /// The prior surface state on the update path.
+ /// The fallback surface id.
+ /// The catalog id used on the create path.
+ /// The serialized operations envelope.
+ public static string BuildA2UIEnvelope(
+ JsonObject args,
+ bool isUpdate,
+ string? targetSurfaceId,
+ A2UIPriorSurface? prior,
+ string defaultSurfaceId = A2UIConstants.DefaultSurfaceId,
+ string defaultCatalogId = A2UIConstants.BasicCatalogId)
+ {
+ Throw.IfNull(args);
+
+ // Treat empty-string defaults as unset. Without this, a misconfigured host
+ // passing "" would propagate the empty string into the emitted ops and surface
+ // as "Catalog not found: " / blank surface ids at render time, hiding the cause.
+ string safeDefaultSurfaceId = string.IsNullOrEmpty(defaultSurfaceId) ? A2UIConstants.DefaultSurfaceId : defaultSurfaceId;
+ string safeDefaultCatalogId = string.IsNullOrEmpty(defaultCatalogId) ? A2UIConstants.BasicCatalogId : defaultCatalogId;
+
+ string argSurfaceId = TryGetString(args["surfaceId"]) is string raw && raw.Length > 0 ? raw : string.Empty;
+ string surfaceId = isUpdate
+ ? (string.IsNullOrEmpty(targetSurfaceId) ? safeDefaultSurfaceId : targetSurfaceId!)
+ : (argSurfaceId.Length > 0 ? argSurfaceId : safeDefaultSurfaceId);
+ string catalogId = string.IsNullOrEmpty(prior?.CatalogId) ? safeDefaultCatalogId : prior!.CatalogId!;
+
+ JsonArray components = args["components"] as JsonArray ?? [];
+ JsonObject? data = args["data"] as JsonObject;
+
+ IReadOnlyList ops = AssembleOps(
+ isUpdate ? "update" : "create",
+ surfaceId,
+ catalogId,
+ components,
+ data);
+
+ return WrapAsOperationsEnvelope(ops);
+ }
+
+ ///
+ /// Assembles the ordered operation list for a surface render: the create intent emits
+ /// createSurface + updateComponents (+ updateDataModel when data is
+ /// non-empty); the update intent omits createSurface so the renderer reconciles in place.
+ ///
+ /// "create" or "update".
+ /// The target surface id.
+ /// The catalog id stamped on createSurface.
+ /// The flat component array.
+ /// The initial data model; omitted from the envelope when null or empty.
+ /// The ordered operations.
+ public static IReadOnlyList AssembleOps(
+ string intent,
+ string surfaceId,
+ string catalogId,
+ JsonArray components,
+ JsonObject? data = null)
+ {
+ Throw.IfNull(components);
+
+ List ops = [];
+ if (!string.Equals(intent, "update", StringComparison.Ordinal))
+ {
+ ops.Add(A2UIOperationBuilder.CreateSurface(surfaceId, catalogId));
+ }
+
+ ops.Add(A2UIOperationBuilder.UpdateComponents(surfaceId, components));
+ if (data is { Count: > 0 })
+ {
+ ops.Add(A2UIOperationBuilder.UpdateDataModel(surfaceId, data));
+ }
+
+ return ops;
+ }
+
+ ///
+ /// Serializes operations under the envelope key.
+ ///
+ /// The operations to wrap.
+ /// The serialized envelope.
+ public static string WrapAsOperationsEnvelope(IEnumerable operations)
+ {
+ Throw.IfNull(operations);
+ return new JsonObject
+ {
+ [A2UIConstants.A2UIOperationsKey] = new JsonArray(operations.Select(JsonNode? (op) => op.DeepClone()).ToArray()),
+ }.ToJsonString();
+ }
+
+ ///
+ /// Serializes a host-facing error as {"error": message} so the planner receives a
+ /// structured failure instead of an exception.
+ ///
+ /// The error message.
+ /// The serialized error envelope.
+ public static string WrapErrorEnvelope(string message)
+ => new JsonObject { ["error"] = message }.ToJsonString();
+
+ private static string? TryGetString(JsonNode? node)
+ => node is JsonValue value && value.TryGetValue(out string? text) ? text : null;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UITypes.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UITypes.cs
new file mode 100644
index 00000000000..9d38f85b0b7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UITypes.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI;
+
+///
+/// One AG-UI context entry as forwarded to the agent (description/value pair).
+///
+/// The optional section heading for the entry.
+/// The entry content.
+public sealed record A2UIContextEntry(string? Description, string? Value);
+
+///
+/// The AG-UI slice of agent state the toolkit reads: forwarded context entries and
+/// the component catalog schema, when the host supplied one.
+///
+///
+/// Mirrors the state["ag-ui"] contract of the sibling toolkits
+/// (context + a2ui_schema). Adapters populate this from the transport,
+/// e.g. the AG-UI hosting layer's ag_ui_context additional property.
+///
+public sealed class A2UIAgentState
+{
+ ///
+ /// Gets the forwarded AG-UI context entries, when present.
+ ///
+ public IReadOnlyList? Context { get; init; }
+
+ ///
+ /// Gets the A2UI component catalog schema (serialized JSON), when present.
+ ///
+ public string? A2UISchema { get; init; }
+}
+
+///
+/// A conversation-history message as seen by the surface walker. Adapters map their
+/// framework's message type onto this shape; only tool-result messages with string
+/// content participate in surface reconstruction.
+///
+/// The message role; tool results carry "tool".
+/// The raw message content.
+public sealed record A2UIHistoryMessage(string? Role, string? Content);
+
+///
+/// The reconstructed end state of a previously rendered surface, used to seed
+/// update-intent prompts and envelopes.
+///
+/// The last known component array, when seen.
+/// The last known data model, when seen. May be .
+/// The catalog the surface was created against, when seen.
+public sealed record A2UIPriorSurface(JsonArray? Components, JsonNode? Data, string? CatalogId);
+
+///
+/// Prompt-section overrides for the subagent system prompt.
+///
+///
+/// Per-field semantics, identical across the sibling toolkits: applies
+/// the built-in default block, the empty string suppresses the block entirely, and any other
+/// value replaces the default.
+///
+public sealed class A2UIGuidelines
+{
+ /// Gets the protocol/generation rules block override.
+ public string? GenerationGuidelines { get; init; }
+
+ /// Gets the visual design rules block override.
+ public string? DesignGuidelines { get; init; }
+
+ /// Gets the host-specific composition guide appended after the context. No built-in default.
+ public string? CompositionGuide { get; init; }
+}
+
+///
+/// The prior-surface context injected into the prompt when editing an existing surface.
+///
+/// The id of the surface being edited.
+/// The reconstructed prior surface state.
+/// An optional natural-language description of the requested changes.
+public sealed record A2UIEditContext(string SurfaceId, A2UIPriorSurface Prior, string? Changes = null);
+
+///
+/// The outcome of preparing an A2UI generation request: the assembled subagent prompt
+/// plus the resolved create/update intent.
+///
+/// The subagent system prompt; empty when is set.
+/// Whether the request edits an existing surface.
+/// The prior surface state on the update path.
+/// A host-facing error when preparation failed (e.g. update target not found).
+public sealed record A2UIPreparedRequest(string Prompt, bool IsUpdate, A2UIPriorSurface? Prior, string? Error);
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs
new file mode 100644
index 00000000000..a793c8f637d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/A2UIValidation.cs
@@ -0,0 +1,486 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI;
+
+///
+/// Error codes emitted by .
+///
+///
+/// The string values are part of the cross-language A2UI contract (shared with the
+/// TypeScript and Python toolkits) and feed back into subagent retry prompts; they
+/// must not diverge from the sibling implementations.
+///
+public static class A2UIValidationErrorCodes
+{
+ /// The component set is missing or empty.
+ public const string EmptyComponents = "empty_components";
+
+ /// A component has no usable string id.
+ public const string MissingId = "missing_id";
+
+ /// A component has no usable string component type.
+ public const string MissingComponentType = "missing_component_type";
+
+ /// Two or more components share the same id.
+ public const string DuplicateId = "duplicate_id";
+
+ /// No component carries the mandatory id of "root".
+ public const string NoRoot = "no_root";
+
+ /// A component type is not present in the supplied catalog.
+ public const string UnknownComponent = "unknown_component";
+
+ /// A component lacks a property the catalog marks as required.
+ public const string MissingRequiredProp = "missing_required_prop";
+
+ /// A child reference points at a component id that does not exist.
+ public const string UnresolvedChild = "unresolved_child";
+
+ /// A component participates in a child-reference cycle; the child/children tree must be a DAG.
+ public const string ChildCycle = "child_cycle";
+
+ /// An absolute data binding path does not resolve in the data model.
+ public const string UnresolvedBinding = "unresolved_binding";
+}
+
+///
+/// A single semantic validation finding for an A2UI component tree.
+///
+/// One of the values.
+/// A JSON-pointer-style location, e.g. components[1].rating.
+/// A human/model-readable description used in retry prompts.
+public sealed record A2UIValidationError(string Code, string Path, string Message);
+
+///
+/// The outcome of validating an A2UI component tree.
+///
+/// when no errors were found.
+/// The findings, empty when is .
+public sealed record A2UIValidationResult(bool Valid, IReadOnlyList Errors);
+
+///
+/// An inline component catalog used for semantic validation: component schemas
+/// (standard JSON Schema fragments with an optional required array) keyed by component name.
+///
+public sealed class A2UIValidationCatalog
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Component schemas keyed by component name.
+ public A2UIValidationCatalog(JsonObject components)
+ {
+ this.Components = components ?? throw new ArgumentNullException(nameof(components));
+ }
+
+ ///
+ /// Gets the component schemas keyed by component name.
+ ///
+ public JsonObject Components { get; }
+}
+
+///
+/// Semantic validator for A2UI v0.9 component trees, mirroring
+/// validateA2UIComponents / validate_a2ui_components in the sibling toolkits.
+///
+///
+/// Structural checks (ids, component types, root presence, child references) always run.
+/// Catalog checks (component existence, required props) run only when a catalog is supplied.
+/// Absolute data-binding checks run unless validateBindings is ;
+/// relative binding paths are never validated globally because they resolve per item inside
+/// repeated templates.
+///
+public static class A2UIComponentValidator
+{
+ /// The component fields that carry child references: singular child and plural children.
+ private static readonly string[] s_childReferenceFields = ["child", "children"];
+
+ ///
+ /// Validates a flat A2UI component array against structural rules and, optionally,
+ /// a component catalog and a data model.
+ ///
+ /// The flat component array. or empty fails validation.
+ /// The surface data model used to resolve absolute binding paths.
+ /// The component catalog enabling semantic checks.
+ ///
+ /// When , absolute binding checks are deferred (used while the data
+ /// model has not finished streaming).
+ ///
+ /// The validation outcome.
+ public static A2UIValidationResult Validate(
+ JsonArray? components,
+ JsonObject? data = null,
+ A2UIValidationCatalog? catalog = null,
+ bool validateBindings = true)
+ {
+ // Fail loud on a missing/empty payload.
+ if (components is null || components.Count == 0)
+ {
+ return new A2UIValidationResult(false,
+ [
+ new A2UIValidationError(
+ A2UIValidationErrorCodes.EmptyComponents,
+ "components",
+ "A2UI components must be a non-empty array"),
+ ]);
+ }
+
+ List errors = [];
+
+ // First pass: collect ids and flag duplicates. Empty ids are skipped here so they
+ // are reported once as a missing id in the next pass rather than as spurious
+ // duplicates of each other.
+ var ids = new HashSet(StringComparer.Ordinal);
+ foreach (JsonNode? node in components)
+ {
+ if (node is JsonObject component && component["id"] is JsonValue idValue &&
+ idValue.TryGetValue(out string? id) && !string.IsNullOrEmpty(id) && !ids.Add(id))
+ {
+ errors.Add(new A2UIValidationError(
+ A2UIValidationErrorCodes.DuplicateId,
+ $"components[id={id}]",
+ $"Duplicate component id '{id}'"));
+ }
+ }
+
+ for (int i = 0; i < components.Count; i++)
+ {
+ JsonObject? component = components[i] as JsonObject;
+ string? id = TryGetString(component?["id"]);
+ string? componentType = TryGetString(component?["component"]);
+
+ if (string.IsNullOrEmpty(id))
+ {
+ errors.Add(new A2UIValidationError(
+ A2UIValidationErrorCodes.MissingId,
+ $"components[{i}].id",
+ $"Component at index {i} is missing a string 'id'"));
+ }
+
+ if (string.IsNullOrEmpty(componentType))
+ {
+ errors.Add(new A2UIValidationError(
+ A2UIValidationErrorCodes.MissingComponentType,
+ $"components[{i}].component",
+ $"Component at index {i} is missing a string 'component' type"));
+ }
+
+ if (catalog is not null && componentType is not null)
+ {
+ if (catalog.Components[componentType] is not JsonObject schema)
+ {
+ errors.Add(new A2UIValidationError(
+ A2UIValidationErrorCodes.UnknownComponent,
+ $"components[{i}].component",
+ $"Component type '{componentType}' is not in the catalog"));
+ }
+ else if (schema["required"] is JsonArray required)
+ {
+ foreach (JsonNode? requiredNode in required)
+ {
+ if (TryGetString(requiredNode) is string requiredProp &&
+ component?.ContainsKey(requiredProp) != true)
+ {
+ errors.Add(new A2UIValidationError(
+ A2UIValidationErrorCodes.MissingRequiredProp,
+ $"components[{i}].{requiredProp}",
+ $"Component '{componentType}' (index {i}) is missing required prop '{requiredProp}'"));
+ }
+ }
+ }
+ }
+
+ if (component is not null)
+ {
+ // Validate both the singular `child` (one-child containers such as Card and
+ // Button, which the default prompt uses) and the plural `children` so a
+ // dangling reference in either is caught and fed back to the recovery loop.
+ foreach (string field in s_childReferenceFields)
+ {
+ foreach (string reference in CollectChildReferences(component[field]))
+ {
+ if (!ids.Contains(reference))
+ {
+ errors.Add(new A2UIValidationError(
+ A2UIValidationErrorCodes.UnresolvedChild,
+ $"components[{i}].{field}",
+ $"Child reference '{reference}' does not match any component id"));
+ }
+ }
+ }
+
+ if (validateBindings)
+ {
+ List bindingPaths = [];
+ CollectAbsoluteBindingPaths(component, bindingPaths);
+ foreach (string path in bindingPaths)
+ {
+ if (!AbsolutePathResolves(path, data))
+ {
+ errors.Add(new A2UIValidationError(
+ A2UIValidationErrorCodes.UnresolvedBinding,
+ $"components[{i}]",
+ $"Binding path '{path}' does not resolve in the data model"));
+ }
+ }
+ }
+ }
+ }
+
+ // The child/children tree must be a DAG — a component that (transitively)
+ // references itself never terminates at render time. Report each cycle once.
+ foreach (List cycle in FindChildCycles(components))
+ {
+ errors.Add(new A2UIValidationError(
+ A2UIValidationErrorCodes.ChildCycle,
+ $"components[id={cycle[0]}]",
+ $"Child reference cycle detected: {string.Join(" -> ", cycle)} -> {cycle[0]}"));
+ }
+
+ bool hasRoot = false;
+ foreach (JsonNode? node in components)
+ {
+ if (node is JsonObject component && TryGetString(component["id"]) == "root")
+ {
+ hasRoot = true;
+ break;
+ }
+ }
+
+ if (!hasRoot)
+ {
+ errors.Add(new A2UIValidationError(
+ A2UIValidationErrorCodes.NoRoot,
+ "components",
+ "No component has id 'root'"));
+ }
+
+ return new A2UIValidationResult(errors.Count == 0, errors);
+ }
+
+ private static string? TryGetString(JsonNode? node)
+ => node is JsonValue value && value.TryGetValue(out string? text) ? text : null;
+
+ private static bool AbsolutePathResolves(string path, JsonNode? data)
+ {
+ JsonNode? cursor = data;
+ foreach (string segment in path.Split('/'))
+ {
+ if (segment.Length == 0)
+ {
+ continue;
+ }
+
+ switch (cursor)
+ {
+ case JsonArray array:
+ if (!int.TryParse(segment, out int index) || index < 0 || index >= array.Count)
+ {
+ return false;
+ }
+
+ cursor = array[index];
+ break;
+
+ case JsonObject obj:
+ if (!obj.TryGetPropertyValue(segment, out JsonNode? next))
+ {
+ return false;
+ }
+
+ cursor = next;
+ break;
+
+ default:
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static List CollectChildReferences(JsonNode? children)
+ {
+ List references = [];
+
+ void Push(JsonNode? node)
+ {
+ if (TryGetString(node) is string id)
+ {
+ references.Add(id);
+ }
+ else if (node is JsonObject obj && TryGetString(obj["componentId"]) is string componentId)
+ {
+ references.Add(componentId);
+ }
+ }
+
+ if (children is JsonArray array)
+ {
+ foreach (JsonNode? child in array)
+ {
+ Push(child);
+ }
+ }
+ else if (children is JsonObject or JsonValue)
+ {
+ // A JsonObject template ({ componentId, ... }) or a bare string id (the singular
+ // `child` shape).
+ Push(children);
+ }
+
+ return references;
+ }
+
+ /// id → ordered child-id references, gathered from singular child + plural children.
+ private static Dictionary> BuildChildAdjacency(JsonArray components)
+ {
+ var adjacency = new Dictionary>(StringComparer.Ordinal);
+ foreach (JsonNode? node in components)
+ {
+ if (node is JsonObject component && TryGetString(component["id"]) is string id)
+ {
+ List references = [];
+ foreach (string field in s_childReferenceFields)
+ {
+ references.AddRange(CollectChildReferences(component[field]));
+ }
+
+ adjacency[id] = references;
+ }
+ }
+
+ return adjacency;
+ }
+
+ ///
+ /// Finds unique child-reference cycles (self-references and longer loops) over the child graph
+ /// via an iterative depth-first search. The traversal is explicit-stack rather than recursive
+ /// so a pathologically deep child chain in untrusted model output cannot overflow the call
+ /// stack (an uncatchable failure on .NET). Each cycle is canonicalised — rotated so the
+ /// lexicographically smallest id leads — so the same loop reached from different entry points
+ /// collapses to one finding, and the reported chain stays byte-identical across the sibling
+ /// toolkits.
+ ///
+ private static List> FindChildCycles(JsonArray components)
+ {
+ Dictionary> adjacency = BuildChildAdjacency(components);
+ var color = new Dictionary(StringComparer.Ordinal); // absent/0 = unvisited, 1 = on path, 2 = done
+ var seenKeys = new HashSet(StringComparer.Ordinal);
+ var cycles = new List>();
+
+ // path = the ids currently on the DFS path (the "on path"/grey set, in order).
+ // frames = a resumable DFS frame per path node: which neighbor index to visit next.
+ var path = new List();
+ var frames = new Stack<(string Node, int NextNeighbor)>();
+
+ foreach (string id in adjacency.Keys)
+ {
+ if (color.TryGetValue(id, out int rootState) && rootState != 0)
+ {
+ continue;
+ }
+
+ color[id] = 1;
+ path.Add(id);
+ frames.Push((id, 0));
+
+ while (frames.Count > 0)
+ {
+ (string u, int next) = frames.Pop();
+ List neighbors = adjacency.TryGetValue(u, out List? n) ? n : [];
+
+ bool descended = false;
+ for (int i = next; i < neighbors.Count; i++)
+ {
+ string v = neighbors[i];
+ int state = color.TryGetValue(v, out int c) ? c : 0;
+ if (state == 1)
+ {
+ // Back-edge to a node still on the path: extract the loop.
+ int start = path.IndexOf(v);
+ List cycle = Canonicalize(path.GetRange(start, path.Count - start));
+ if (seenKeys.Add(string.Join(" ", cycle)))
+ {
+ cycles.Add(cycle);
+ }
+ }
+ else if (state == 0)
+ {
+ // Descend into v, resuming u after this neighbor on the way back up.
+ frames.Push((u, i + 1));
+ color[v] = 1;
+ path.Add(v);
+ frames.Push((v, 0));
+ descended = true;
+ break;
+ }
+ }
+
+ if (!descended)
+ {
+ // u is fully explored; it is the top of the path.
+ color[u] = 2;
+ path.RemoveAt(path.Count - 1);
+ }
+ }
+ }
+
+ return cycles;
+ }
+
+ /// Rotates a cycle's node list so the lexicographically smallest (ordinal) id leads.
+ private static List Canonicalize(List nodes)
+ {
+ int m = 0;
+ for (int i = 1; i < nodes.Count; i++)
+ {
+ if (string.CompareOrdinal(nodes[i], nodes[m]) < 0)
+ {
+ m = i;
+ }
+ }
+
+ var rotated = new List(nodes.Count);
+ rotated.AddRange(nodes.GetRange(m, nodes.Count - m));
+ rotated.AddRange(nodes.GetRange(0, m));
+ return rotated;
+ }
+
+ private static void CollectAbsoluteBindingPaths(JsonNode? node, List accumulator)
+ {
+ switch (node)
+ {
+ case JsonArray array:
+ foreach (JsonNode? item in array)
+ {
+ CollectAbsoluteBindingPaths(item, accumulator);
+ }
+
+ break;
+
+ case JsonObject obj:
+ if (TryGetString(obj["path"]) is string path && path.Length > 0 && path[0] == '/')
+ {
+ accumulator.Add(path);
+ }
+
+ foreach (KeyValuePair property in obj)
+ {
+ if (!string.Equals(property.Key, "path", StringComparison.Ordinal))
+ {
+ CollectAbsoluteBindingPaths(property.Value, accumulator);
+ }
+ }
+
+ break;
+
+ default:
+ break;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs
new file mode 100644
index 00000000000..c8f58b6465b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/AGUIContextAgent.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI;
+
+///
+/// Surfaces forwarded AG-UI context entries to the model by prepending them as a system
+/// message. The AG-UI hosting layer stores incoming context entries in
+/// (ag_ui_context), where the model
+/// never sees them; this wrapper renders each entry as a markdown section so
+/// client-driven guidance — e.g. the A2UI middleware's injected component schema and
+/// render-tool usage guide — reaches the prompt without any agent-specific code.
+///
+///
+/// This is the missing piece of the zero-configuration A2UI path: the AG-UI client
+/// middleware injects the render_a2ui tool (bound automatically from the incoming
+/// tool list) plus its usage guidelines and catalog schema as context entries; with this
+/// wrapper the model receives both, and a plain chat agent can render A2UI surfaces with
+/// no further setup.
+///
+public sealed class AGUIContextAgent : DelegatingAIAgent
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The agent to wrap.
+ public AGUIContextAgent(AIAgent innerAgent)
+ : base(innerAgent)
+ {
+ }
+
+ ///
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => this.InnerAgent.RunAsync(WithContextPrompt(messages, options), session, options, cancellationToken);
+
+ ///
+ protected override IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => this.InnerAgent.RunStreamingAsync(WithContextPrompt(messages, options), session, options, cancellationToken);
+
+ private static IEnumerable WithContextPrompt(IEnumerable messages, AgentRunOptions? options)
+ {
+ if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties })
+ {
+ return messages;
+ }
+
+ // Shared routing with A2UIAgent: the catalog schema entry lands in the
+ // canonical "## Available Components" section, other entries become
+ // plain context sections — both agents render the same prompt for the
+ // same forwarded context.
+ string prompt = A2UIToolkit.BuildContextPrompt(A2UIAgent.ReadAgentState(properties));
+
+ return prompt.Length == 0
+ ? messages
+ : messages.Prepend(new ChatMessage(ChatRole.System, prompt));
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj
new file mode 100644
index 00000000000..4054b1714fe
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI.A2UI/Microsoft.Agents.AI.AGUI.A2UI.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+ Microsoft Agent Framework AG-UI A2UI Toolkit
+ Framework-agnostic helpers for building A2UI (Agent-to-UI) generation tools on top of the AG-UI protocol.
+ true
+ true
+
+ $(NoWarn);MEAI001
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs
index d5451a9ff5c..b50e04fd90d 100644
--- a/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Shared/ChatResponseUpdateAGUIExtensions.cs
@@ -453,6 +453,21 @@ public static async IAsyncEnumerable AsAGUIEventStreamAsync(
string? currentReasoningBaseId = null;
string? currentReasoningId = null;
string? currentReasoningMessageId = null;
+#if ASPNETCORE
+ // Progressive tool-call argument streaming. MEAI chat clients yield one update
+ // per provider chunk but attach the typed FunctionCallContent only once a call's
+ // arguments are complete, so the AGUI wire would otherwise carry a single atomic
+ // TOOL_CALL_ARGS event per call — starving streaming consumers (e.g. generative-UI
+ // middlewares that paint arguments incrementally). For OpenAI-family providers the
+ // argument fragments are still observable on the update's RawRepresentation:
+ // surface them as incremental TOOL_CALL_ARGS events and suppress the duplicate
+ // atomic emission when the coalesced FunctionCallContent arrives.
+ // Two views of the same open raw-streamed calls: by fragment index (for matching
+ // later fragments and the deterministic end-of-stream sweep) and by call id (for an
+ // O(1) close when the coalesced FunctionCallContent arrives).
+ Dictionary rawToolCallIdsByIndex = [];
+ Dictionary rawToolCallIndexById = new(StringComparer.Ordinal);
+#endif
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// The text-event surface (TextMessageStart/Content/End) requires a non-empty
@@ -528,6 +543,71 @@ chatResponse.Contents[0] is TextContent &&
};
}
+#if ASPNETCORE
+ // Surface OpenAI streamed tool-call argument fragments incrementally.
+ object? rawUpdate = chatResponse.RawRepresentation;
+ if (rawUpdate is ChatResponseUpdate innerUpdate)
+ {
+ // Agent pipelines (e.g. ChatClientAgent) wrap the provider update once.
+ rawUpdate = innerUpdate.RawRepresentation;
+ }
+
+ if (rawUpdate is OpenAI.Chat.StreamingChatCompletionUpdate streamingChatUpdate)
+ {
+ foreach (OpenAI.Chat.StreamingChatToolCallUpdate toolCallUpdate in streamingChatUpdate.ToolCallUpdates ?? [])
+ {
+ if (!rawToolCallIdsByIndex.TryGetValue(toolCallUpdate.Index, out string? rawToolCallId))
+ {
+ // The first fragment of a call carries its id and function name;
+ // later fragments only carry the index plus an arguments delta.
+ if (string.IsNullOrEmpty(toolCallUpdate.ToolCallId) || string.IsNullOrEmpty(toolCallUpdate.FunctionName))
+ {
+ continue;
+ }
+
+ rawToolCallId = toolCallUpdate.ToolCallId;
+ rawToolCallIdsByIndex[toolCallUpdate.Index] = rawToolCallId;
+ rawToolCallIndexById[rawToolCallId] = toolCallUpdate.Index;
+
+ // Close any open reasoning block before emitting tool events.
+ if (currentReasoningMessageId is not null)
+ {
+ yield return new ReasoningMessageEndEvent
+ {
+ MessageId = currentReasoningMessageId
+ };
+ yield return new ReasoningEndEvent
+ {
+ MessageId = currentReasoningId!
+ };
+ currentReasoningBaseId = null;
+ currentReasoningId = null;
+ currentReasoningMessageId = null;
+ }
+
+ yield return new ToolCallStartEvent
+ {
+ ToolCallId = rawToolCallId,
+ ToolCallName = toolCallUpdate.FunctionName,
+ // Fragment updates typically carry no MessageId, so this is
+ // often null — schema-legal, and the inbound builder ignores it.
+ ParentMessageId = chatResponse.MessageId
+ };
+ }
+
+ string argumentsDelta = toolCallUpdate.FunctionArgumentsUpdate?.ToString() ?? string.Empty;
+ if (argumentsDelta.Length > 0)
+ {
+ yield return new ToolCallArgsEvent
+ {
+ ToolCallId = rawToolCallId,
+ Delta = argumentsDelta
+ };
+ }
+ }
+ }
+#endif
+
// Emit tool call events and tool result events
if (chatResponse is { Contents.Count: > 0 })
{
@@ -535,6 +615,40 @@ chatResponse.Contents[0] is TextContent &&
{
if (content is FunctionCallContent functionCallContent)
{
+#if ASPNETCORE
+ // This call's arguments already streamed incrementally from the raw
+ // provider fragments above — only the closing event remains. The
+ // per-call state is then released: OpenAI restarts tool-call indexes
+ // at 0 for each assistant turn, so a stale index entry would silently
+ // absorb a later round's fragments into this finished call.
+ if (rawToolCallIndexById.Remove(functionCallContent.CallId, out int closedIndex))
+ {
+ rawToolCallIdsByIndex.Remove(closedIndex);
+
+ // Close any open reasoning block before emitting tool events.
+ if (currentReasoningMessageId is not null)
+ {
+ yield return new ReasoningMessageEndEvent
+ {
+ MessageId = currentReasoningMessageId
+ };
+ yield return new ReasoningEndEvent
+ {
+ MessageId = currentReasoningId!
+ };
+ currentReasoningBaseId = null;
+ currentReasoningId = null;
+ currentReasoningMessageId = null;
+ }
+
+ yield return new ToolCallEndEvent
+ {
+ ToolCallId = functionCallContent.CallId
+ };
+ continue;
+ }
+#endif
+
// Close any open reasoning block before emitting tool events.
if (currentReasoningMessageId is not null)
{
@@ -727,6 +841,26 @@ chatResponse.Contents[0] is TextContent &&
};
}
+#if ASPNETCORE
+ // Close any raw-streamed tool call whose coalesced FunctionCallContent never
+ // arrived (stream cut short, or a pipeline that does not re-emit the typed
+ // content). Without this sweep the wire carries Start/Args with no End and
+ // streaming consumers stay in a perpetual "in progress" state. Sweep in
+ // tool-call index order so the close events are deterministic.
+ List openToolCallIndexes = new(rawToolCallIdsByIndex.Keys);
+ openToolCallIndexes.Sort();
+ foreach (int openToolCallIndex in openToolCallIndexes)
+ {
+ yield return new ToolCallEndEvent
+ {
+ ToolCallId = rawToolCallIdsByIndex[openToolCallIndex]
+ };
+ }
+
+ rawToolCallIdsByIndex.Clear();
+ rawToolCallIndexById.Clear();
+#endif
+
yield return new RunFinishedEvent
{
ThreadId = threadId,
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj
index 1565977149b..dd345d30ce1 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
@@ -26,6 +26,11 @@
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs
new file mode 100644
index 00000000000..46d703080bc
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIAgentTests.cs
@@ -0,0 +1,749 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests;
+
+///
+/// Unit tests for : per-run generate_a2ui tool injection and
+/// the tool's end-to-end behavior over a scripted subagent chat client.
+///
+public sealed class A2UIAgentTests
+{
+ private static readonly JsonObject s_validRenderArgs = new()
+ {
+ ["surfaceId"] = "s1",
+ ["components"] = new JsonArray(
+ new JsonObject
+ {
+ ["id"] = "root",
+ ["component"] = "Row",
+ ["children"] = new JsonObject { ["componentId"] = "card", ["path"] = "/items" },
+ },
+ new JsonObject
+ {
+ ["id"] = "card",
+ ["component"] = "HotelCard",
+ ["name"] = new JsonObject { ["path"] = "name" },
+ }),
+ ["data"] = new JsonObject
+ {
+ ["items"] = new JsonArray(new JsonObject { ["name"] = "Ritz" }),
+ },
+ };
+
+ [Fact]
+ public async Task RunAsync_InjectsGenerateA2UIToolIntoRunOptionsAsync()
+ {
+ // Arrange
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs));
+
+ // Act
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")]);
+
+ // Assert
+ ChatClientAgentRunOptions options = Assert.IsType(inner.LastOptions);
+ AITool tool = Assert.Single(options.ChatOptions?.Tools ?? []);
+ Assert.Equal(A2UIConstants.GenerateA2UIToolName, tool.Name);
+ Assert.Equal(A2UIToolDefinitions.GenerateA2UIToolDescription, tool.Description);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_InjectsGenerateA2UIDeclarationIntoRunOptionsAsync()
+ {
+ // Arrange
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs));
+
+ // Act
+ await foreach (AgentResponseUpdate _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "hi")]))
+ {
+ }
+
+ // Assert: the streaming path advertises a schema-only declaration so the planner's
+ // call surfaces on the update stream instead of being auto-invoked.
+ ChatClientAgentRunOptions options = Assert.IsType(inner.LastOptions);
+ AITool tool = Assert.Single(options.ChatOptions?.Tools ?? []);
+ Assert.Equal(A2UIConstants.GenerateA2UIToolName, tool.Name);
+ Assert.IsNotType(tool, exactMatch: false);
+ Assert.IsType(tool, exactMatch: false);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_GenerateCall_StreamsSubagentAndFeedsResultBackAsync()
+ {
+ // Arrange: round 1 the planner calls generate_a2ui; round 2 it narrates. The
+ // subagent streams its forced render_a2ui call across several updates.
+ var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" });
+ var subagent = new ScriptedChatClient(_ => s_validRenderArgs) { StreamingChunks = 3 };
+ var agent = new A2UIAgent(inner, subagent);
+
+ // Act
+ List updates = [];
+ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")]))
+ {
+ updates.Add(update);
+ }
+
+ // Assert: the subagent's streamed updates were forwarded on the agent stream.
+ Assert.Equal(3, updates.Count(u => u.Contents.Any(c => c is TextContent text && text.Text == "chunk")));
+ FunctionCallContent renderCall = Assert.Single(
+ updates.SelectMany(u => u.Contents).OfType(),
+ c => c.Name == A2UIConstants.RenderA2UIToolName);
+
+ // The forwarded render_a2ui call is balanced with a tool result so the persisted
+ // conversation stays valid for the next turn.
+ FunctionResultContent renderResult = Assert.Single(
+ updates.SelectMany(u => u.Contents).OfType(),
+ r => r.CallId == renderCall.CallId);
+ Assert.Equal("rendered", Assert.IsType(renderResult.Result).GetProperty("status").GetString());
+
+ // The generate call's result rides the stream as a valid operations envelope.
+ FunctionResultContent result = Assert.Single(
+ updates.SelectMany(u => u.Contents).OfType(),
+ r => r.CallId == "call-g1");
+ JsonElement envelope = Assert.IsType(result.Result);
+ Assert.True(envelope.TryGetProperty(A2UIConstants.A2UIOperationsKey, out JsonElement ops));
+ Assert.Equal(3, ops.GetArrayLength());
+
+ // The planner's second round received the tool-call/result pair and narrated.
+ Assert.Equal(2, inner.Runs.Count);
+ IReadOnlyList secondRoundMessages = inner.Runs[1];
+ Assert.Contains(secondRoundMessages, m =>
+ m.Role == ChatRole.Assistant && m.Contents.OfType().Any(c => c.CallId == "call-g1"));
+ Assert.Contains(secondRoundMessages, m =>
+ m.Role == ChatRole.Tool && m.Contents.OfType().Any(c => c.CallId == "call-g1"));
+ Assert.Contains(updates, u => u.Text == "done");
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_InvalidFirstAttempt_RetriesWithVisibleSecondSubagentCallAsync()
+ {
+ // Arrange: attempt 1 returns a dangling child reference, attempt 2 is valid.
+ int calls = 0;
+ var invalidArgs = new JsonObject
+ {
+ ["surfaceId"] = "s1",
+ ["components"] = new JsonArray(
+ new JsonObject
+ {
+ ["id"] = "root",
+ ["component"] = "Row",
+ ["children"] = new JsonObject { ["componentId"] = "ghost", ["path"] = "/items" },
+ }),
+ };
+ var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" });
+ var subagent = new ScriptedChatClient(_ => ++calls == 1 ? invalidArgs : s_validRenderArgs) { StreamingChunks = 1 };
+ var agent = new A2UIAgent(inner, subagent);
+
+ // Act
+ List updates = [];
+ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")]))
+ {
+ updates.Add(update);
+ }
+
+ // Assert: both attempts streamed (two visible render calls), and every forwarded
+ // render call is balanced with its own tool result so the next turn's history is valid.
+ List renderCalls = updates
+ .SelectMany(u => u.Contents)
+ .OfType()
+ .Where(c => c.Name == A2UIConstants.RenderA2UIToolName)
+ .ToList();
+ Assert.Equal(2, renderCalls.Count);
+ List resultContents = updates
+ .SelectMany(u => u.Contents)
+ .OfType()
+ .ToList();
+ foreach (FunctionCallContent renderCall in renderCalls)
+ {
+ Assert.Contains(resultContents, r => r.CallId == renderCall.CallId);
+ }
+
+ // The generate call's final envelope is the valid second attempt.
+ FunctionResultContent result = Assert.Single(resultContents, r => r.CallId == "call-g1");
+ JsonElement envelope = Assert.IsType(result.Result);
+ Assert.True(envelope.TryGetProperty(A2UIConstants.A2UIOperationsKey, out _));
+ Assert.False(envelope.TryGetProperty("code", out _));
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_PreservesPlannerNarrationInNextRoundHistoryAsync()
+ {
+ // Arrange: the planner narrates alongside its generate_a2ui call on round 1.
+ var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" })
+ {
+ RoundOneText = "Let me put that together.",
+ };
+ var subagent = new ScriptedChatClient(_ => s_validRenderArgs) { StreamingChunks = 1 };
+ var agent = new A2UIAgent(inner, subagent);
+
+ // Act
+ await foreach (AgentResponseUpdate _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")]))
+ {
+ }
+
+ // Assert: round 2's history carries the planner's round-1 narration, not just the
+ // generate_a2ui call — the reconstructed assistant message is not lossy.
+ Assert.Equal(2, inner.Runs.Count);
+ Assert.Contains(inner.Runs[1], m =>
+ m.Role == ChatRole.Assistant &&
+ m.Contents.OfType().Any(t => t.Text == "Let me put that together.") &&
+ m.Contents.OfType().Any(c => c.CallId == "call-g1"));
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_SubagentNeverCallsTool_ReturnsRecoveryExhaustedEnvelopeAsync()
+ {
+ // Arrange: the subagent never calls render_a2ui, so every attempt fails.
+ int attempts = 0;
+ var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" });
+ var subagent = new ScriptedChatClient(_ => null);
+ var agent = new A2UIAgent(
+ inner,
+ subagent,
+ new A2UIToolParams { OnAttempt = _ => attempts++ });
+
+ // Act
+ List updates = [];
+ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")]))
+ {
+ updates.Add(update);
+ }
+
+ // Assert: OnAttempt fired once per attempt, and the generate result is the
+ // structured hard-failure envelope — matching the non-streaming path.
+ Assert.Equal(A2UIConstants.MaxA2UIAttempts, attempts);
+ FunctionResultContent result = Assert.Single(
+ updates.SelectMany(u => u.Contents).OfType(),
+ r => r.CallId == "call-g1");
+ JsonElement envelope = Assert.IsType(result.Result);
+ Assert.Equal("a2ui_recovery_exhausted", envelope.GetProperty("code").GetString());
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_UpdateWithPriorRenderInHistory_ReturnsInPlaceUpdateAsync()
+ {
+ // Arrange: a prior render envelope rides in a tool-result message, the way the
+ // persisted conversation carries it back on a later turn. The streaming update
+ // intent must find it through ToHistoryMessage + FindPriorSurface.
+ string priorEnvelope = A2UIToolkit.WrapAsOperationsEnvelope(
+ [
+ A2UIOperationBuilder.CreateSurface("s1", "https://example.test/catalog.json"),
+ A2UIOperationBuilder.UpdateComponents("s1", s_validRenderArgs["components"]!.AsArray()),
+ ]);
+ using JsonDocument priorDocument = JsonDocument.Parse(priorEnvelope);
+ var inner = new ScriptedPlannerAgent(generateArguments: new()
+ {
+ ["intent"] = "update",
+ ["target_surface_id"] = "s1",
+ });
+ var subagent = new ScriptedChatClient(_ => s_validRenderArgs) { StreamingChunks = 1 };
+ var agent = new A2UIAgent(inner, subagent);
+
+ // Act
+ List updates = [];
+ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(
+ [
+ new ChatMessage(ChatRole.User, "show hotels"),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call-0", priorDocument.RootElement.Clone())]),
+ new ChatMessage(ChatRole.User, "make the cards bigger"),
+ ]))
+ {
+ updates.Add(update);
+ }
+
+ // Assert: an in-place update — no createSurface for the existing surface.
+ FunctionResultContent result = Assert.Single(
+ updates.SelectMany(u => u.Contents).OfType(),
+ r => r.CallId == "call-g1");
+ JsonElement envelope = Assert.IsType(result.Result);
+ JsonElement ops = envelope.GetProperty(A2UIConstants.A2UIOperationsKey);
+ Assert.Equal(2, ops.GetArrayLength());
+ Assert.DoesNotContain(
+ ops.EnumerateArray(),
+ op => op.TryGetProperty("createSurface", out _));
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_PlannerExhaustsRounds_ClosesWithGenerateToolWithheldAsync()
+ {
+ // Arrange: a planner that requests a generation every round, so the round cap is hit.
+ var inner = new ScriptedPlannerAgent(generateArguments: new() { ["intent"] = "create" })
+ {
+ AlwaysGenerate = true,
+ };
+ var subagent = new ScriptedChatClient(_ => s_validRenderArgs) { StreamingChunks = 1 };
+ var agent = new A2UIAgent(inner, subagent);
+ var callerOptions = new ChatClientAgentRunOptions
+ {
+ AllowBackgroundResponses = true,
+ ResponseFormat = ChatResponseFormat.Json,
+ AdditionalProperties = new AdditionalPropertiesDictionary { ["run-key"] = "run-value" },
+ };
+
+ // Act
+ List updates = [];
+ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "show hotels")], options: callerOptions))
+ {
+ updates.Add(update);
+ }
+
+ // Assert: one final planner turn beyond the cap, and that final turn had the
+ // generate tool withheld so the planner could only narrate (no dangling tool result).
+ Assert.Equal(A2UIAgent.MaxPlannerRounds + 1, inner.Runs.Count);
+ IReadOnlyList finalTurnTools = inner.ToolsPerRun[^1];
+ Assert.DoesNotContain(A2UIConstants.GenerateA2UIToolName, finalTurnTools);
+ Assert.Contains(updates, u => u.Text == "done");
+
+ // The closing turn preserves the caller's base run-option members rather than
+ // rebuilding a partial options instance.
+ ChatClientAgentRunOptions finalTurnOptions = Assert.IsType(inner.OptionsPerRun[^1]);
+ Assert.True(finalTurnOptions.AllowBackgroundResponses);
+ Assert.Same(ChatResponseFormat.Json, finalTurnOptions.ResponseFormat);
+ Assert.Equal("run-value", finalTurnOptions.AdditionalProperties?["run-key"]);
+ }
+
+ [Fact]
+ public async Task RunAsync_CustomToolName_IsHonoredAsync()
+ {
+ // Arrange
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(
+ inner,
+ new ScriptedChatClient(_ => s_validRenderArgs),
+ new A2UIToolParams { ToolName = "custom_a2ui" });
+
+ // Act
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")]);
+
+ // Assert
+ ChatClientAgentRunOptions options = Assert.IsType(inner.LastOptions);
+ Assert.Contains(options.ChatOptions?.Tools ?? [], t => t.Name == "custom_a2ui");
+ }
+
+ [Fact]
+ public async Task RunAsync_PreservesCallerToolsAndRunOptionsAsync()
+ {
+ // Arrange: a caller-supplied options object carrying chat tools and base
+ // AgentRunOptions members, plus a stale generate_a2ui entry that must be
+ // replaced rather than duplicated.
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs));
+ AIFunction callerTool = AIFunctionFactory.Create(() => "weather", "get_weather");
+ AIFunction staleGenerateTool = AIFunctionFactory.Create(() => "stale", A2UIConstants.GenerateA2UIToolName);
+ Func factory = client => client;
+ var callerOptions = new ChatClientAgentRunOptions
+ {
+ ChatOptions = new ChatOptions { Tools = [callerTool, staleGenerateTool] },
+ ChatClientFactory = factory,
+ AllowBackgroundResponses = true,
+ ResponseFormat = ChatResponseFormat.Json,
+ AdditionalProperties = new AdditionalPropertiesDictionary { ["run-key"] = "run-value" },
+ };
+
+ // Act
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")], options: callerOptions);
+
+ // Assert: caller tool retained, generate tool replaced (no duplicate), base
+ // run-option members and the chat client factory all survive.
+ ChatClientAgentRunOptions forwarded = Assert.IsType(inner.LastOptions);
+ IList tools = forwarded.ChatOptions?.Tools ?? [];
+ Assert.Contains(tools, t => t.Name == "get_weather");
+ AITool generateTool = Assert.Single(tools, t => t.Name == A2UIConstants.GenerateA2UIToolName);
+ Assert.NotSame(staleGenerateTool, generateTool);
+ Assert.Same(factory, forwarded.ChatClientFactory);
+ Assert.True(forwarded.AllowBackgroundResponses);
+ Assert.Same(ChatResponseFormat.Json, forwarded.ResponseFormat);
+ Assert.Equal("run-value", forwarded.AdditionalProperties?["run-key"]);
+
+ // The caller's options object is not mutated.
+ Assert.Equal(2, callerOptions.ChatOptions!.Tools!.Count);
+ Assert.Same(staleGenerateTool, callerOptions.ChatOptions.Tools[1]);
+ }
+
+ [Fact]
+ public async Task RunAsync_PlainAgentRunOptions_BaseMembersSurviveAsync()
+ {
+ // Arrange: a caller passing the base options type still gets its members forwarded.
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs));
+ var callerOptions = new AgentRunOptions
+ {
+ AllowBackgroundResponses = true,
+ ResponseFormat = ChatResponseFormat.Json,
+ AdditionalProperties = new AdditionalPropertiesDictionary { ["run-key"] = "run-value" },
+ };
+
+ // Act
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")], options: callerOptions);
+
+ // Assert
+ ChatClientAgentRunOptions forwarded = Assert.IsType(inner.LastOptions);
+ Assert.True(forwarded.AllowBackgroundResponses);
+ Assert.Same(ChatResponseFormat.Json, forwarded.ResponseFormat);
+ Assert.Equal("run-value", forwarded.AdditionalProperties?["run-key"]);
+ Assert.Contains(forwarded.ChatOptions?.Tools ?? [], t => t.Name == A2UIConstants.GenerateA2UIToolName);
+ }
+
+ [Fact]
+ public async Task GenerateA2UITool_CreateIntent_ReturnsOperationsEnvelopeAsync()
+ {
+ // Arrange
+ ChatOptions? subagentOptions = null;
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(options =>
+ {
+ subagentOptions = options;
+ return s_validRenderArgs;
+ }));
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "show hotels")]);
+
+ // Act
+ string envelope = await InvokeGenerateToolAsync(inner, new() { ["intent"] = "create" });
+
+ // Assert
+ JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope));
+ JsonArray ops = Assert.IsType(parsed[A2UIConstants.A2UIOperationsKey]);
+ Assert.Equal(3, ops.Count);
+ // The subagent is forced to call render_a2ui.
+ Assert.NotNull(subagentOptions);
+ Assert.Equal(
+ ChatToolMode.RequireSpecific(A2UIConstants.RenderA2UIToolName),
+ subagentOptions!.ToolMode);
+ AITool renderTool = Assert.Single(subagentOptions.Tools ?? []);
+ Assert.Equal(A2UIConstants.RenderA2UIToolName, renderTool.Name);
+ }
+
+ [Fact]
+ public async Task GenerateA2UITool_UpdateWithoutPrior_ReturnsErrorEnvelopeAsync()
+ {
+ // Arrange
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs));
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "update it")]);
+
+ // Act
+ string envelope = await InvokeGenerateToolAsync(
+ inner, new() { ["intent"] = "update", ["target_surface_id"] = "missing" });
+
+ // Assert
+ JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope));
+ Assert.Contains("no prior render", (string?)parsed["error"]);
+ }
+
+ [Fact]
+ public async Task GenerateA2UITool_UpdateWithPriorRenderInHistory_ReturnsUpdateOpsAsync()
+ {
+ // Arrange: a prior render envelope rides in a tool-result message, the way a real
+ // conversation history carries it. The update intent must find it through
+ // ToHistoryMessage + FindPriorSurface and emit in-place update operations.
+ string priorEnvelope = A2UIToolkit.WrapAsOperationsEnvelope(
+ [
+ A2UIOperationBuilder.CreateSurface("s1", "https://example.test/catalog.json"),
+ A2UIOperationBuilder.UpdateComponents("s1", s_validRenderArgs["components"]!.AsArray()),
+ ]);
+ using JsonDocument priorDocument = JsonDocument.Parse(priorEnvelope);
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => s_validRenderArgs));
+ await agent.RunAsync(
+ [
+ new ChatMessage(ChatRole.User, "show hotels"),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call-0", priorDocument.RootElement.Clone())]),
+ new ChatMessage(ChatRole.User, "make the cards bigger"),
+ ]);
+
+ // Act
+ string envelope = await InvokeGenerateToolAsync(
+ inner, new() { ["intent"] = "update", ["target_surface_id"] = "s1" });
+
+ // Assert: no createSurface for an in-place update; the ops target the prior surface.
+ JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope));
+ JsonArray ops = Assert.IsType(parsed[A2UIConstants.A2UIOperationsKey]);
+ Assert.Equal(2, ops.Count);
+ Assert.DoesNotContain(ops, op => op is JsonObject obj && obj.ContainsKey("createSurface"));
+ JsonObject updateComponents = Assert.IsType(ops[0]?["updateComponents"]);
+ Assert.Equal("s1", (string?)updateComponents["surfaceId"]);
+ }
+
+ [Fact]
+ public async Task GenerateA2UITool_SubagentNeverCallsTool_ReturnsRecoveryExhaustedEnvelopeAsync()
+ {
+ // Arrange
+ int calls = 0;
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(_ =>
+ {
+ calls++;
+ return null; // no render_a2ui call
+ }));
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "show hotels")]);
+
+ // Act
+ string envelope = await InvokeGenerateToolAsync(inner, []);
+
+ // Assert
+ JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope));
+ Assert.Equal("a2ui_recovery_exhausted", (string?)parsed["code"]);
+ Assert.Equal(A2UIConstants.MaxA2UIAttempts, calls);
+ }
+
+ [Fact]
+ public async Task GenerateA2UITool_JsonElementArguments_ReturnsOperationsEnvelopeAsync()
+ {
+ // Arrange: real chat clients deliver FunctionCallContent.Arguments values as
+ // JsonElement — exercise that marshalling arm rather than pre-built JsonNodes.
+ using JsonDocument argsDocument = JsonDocument.Parse(s_validRenderArgs.ToJsonString());
+ var elementArgs = new Dictionary
+ {
+ ["surfaceId"] = argsDocument.RootElement.GetProperty("surfaceId").Clone(),
+ ["components"] = argsDocument.RootElement.GetProperty("components").Clone(),
+ ["data"] = argsDocument.RootElement.GetProperty("data").Clone(),
+ };
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(_ => null) { RawArguments = elementArgs });
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "show hotels")]);
+
+ // Act
+ string envelope = await InvokeGenerateToolAsync(inner, new() { ["intent"] = "create" });
+
+ // Assert
+ JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope));
+ JsonArray ops = Assert.IsType(parsed[A2UIConstants.A2UIOperationsKey]);
+ Assert.Equal(3, ops.Count);
+ }
+
+ [Fact]
+ public async Task ReadAgentState_RoutesSchemaEntryIntoAvailableComponentsAsync()
+ {
+ // Arrange: the hosting layer forwards context entries; the catalog schema entry
+ // must land in the prompt's canonical "## Available Components" section and the
+ // plain entry under its own heading.
+ string? subagentPrompt = null;
+ var inner = new RecordingAgent();
+ var agent = new A2UIAgent(inner, new ScriptedChatClient(options => s_validRenderArgs)
+ {
+ OnMessages = messages => subagentPrompt = messages.FirstOrDefault(m => m.Role == ChatRole.System)?.Text,
+ });
+ var runOptions = new ChatClientAgentRunOptions
+ {
+ ChatOptions = new ChatOptions
+ {
+ AdditionalProperties = new AdditionalPropertiesDictionary
+ {
+ ["ag_ui_context"] = new[]
+ {
+ new KeyValuePair(A2UIConstants.A2UISchemaContextDescription, "{\"components\":{}}"),
+ new KeyValuePair("Style guide", "use cards"),
+ },
+ },
+ },
+ };
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "show hotels")], options: runOptions);
+
+ // Act
+ await InvokeGenerateToolAsync(inner, new() { ["intent"] = "create" });
+
+ // Assert
+ Assert.NotNull(subagentPrompt);
+ Assert.Contains("## Available Components", subagentPrompt);
+ Assert.Contains("{\"components\":{}}", subagentPrompt);
+ Assert.Contains("## Style guide", subagentPrompt);
+ Assert.DoesNotContain($"## {A2UIConstants.A2UISchemaContextDescription}", subagentPrompt);
+ }
+
+ ///
+ /// Pulls the injected generate_a2ui function from the recorded run options and
+ /// invokes it the way a function-invoking chat client would.
+ ///
+ private static async Task InvokeGenerateToolAsync(RecordingAgent inner, Dictionary arguments)
+ {
+ ChatClientAgentRunOptions options = Assert.IsType(inner.LastOptions);
+ AIFunction function = Assert.IsType(
+ (options.ChatOptions?.Tools ?? []).Single(t => t is AIFunction),
+ exactMatch: false);
+
+ object? result = await function.InvokeAsync(new AIFunctionArguments(arguments));
+ return result switch
+ {
+ string text => text,
+ JsonElement element when element.ValueKind == JsonValueKind.String => element.GetString()!,
+ JsonElement element => element.GetRawText(),
+ _ => result?.ToString() ?? string.Empty,
+ };
+ }
+
+ /// An inner agent that records the options it was run with and returns an empty response.
+ private sealed class RecordingAgent : AIAgent
+ {
+ public AgentRunOptions? LastOptions { get; private set; }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ this.LastOptions = options;
+ return Task.FromResult(new AgentResponse());
+ }
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ this.LastOptions = options;
+ await Task.CompletedTask.ConfigureAwait(false);
+ yield break;
+ }
+ }
+
+ ///
+ /// A chat client scripted per call: returns a render_a2ui function call with the
+ /// supplied arguments, or a plain text message when the script yields .
+ ///
+ private sealed class ScriptedChatClient : IChatClient
+ {
+ private readonly Func _script;
+
+ public ScriptedChatClient(Func script) => this._script = script;
+
+ /// When set, the function call carries these raw argument values verbatim.
+ public IDictionary? RawArguments { get; init; }
+
+ /// Observes the messages each subagent invocation receives.
+ public Action>? OnMessages { get; init; }
+
+ public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ this.OnMessages?.Invoke(messages);
+ IDictionary? arguments = this.RawArguments;
+ if (arguments is null)
+ {
+ JsonObject? args = this._script(options);
+ arguments = args?.ToDictionary(p => p.Key, object? (p) => p.Value?.DeepClone());
+ }
+
+ ChatMessage message = arguments is null
+ ? new ChatMessage(ChatRole.Assistant, "no tool call")
+ : new ChatMessage(ChatRole.Assistant,
+ [
+ new FunctionCallContent("call-1", A2UIConstants.RenderA2UIToolName, arguments),
+ ]);
+ return Task.FromResult(new ChatResponse(message));
+ }
+
+ /// Text chunks streamed before the function call on the streaming path.
+ public int StreamingChunks { get; init; }
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ this.OnMessages?.Invoke(messages);
+ for (int i = 0; i < this.StreamingChunks; i++)
+ {
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "chunk");
+ }
+
+ IDictionary? arguments = this.RawArguments;
+ if (arguments is null)
+ {
+ JsonObject? args = this._script(options);
+ arguments = args?.ToDictionary(p => p.Key, object? (p) => p.Value?.DeepClone());
+ }
+
+ // Real streaming chat clients coalesce the call's fragments and attach the
+ // typed FunctionCallContent on a trailing update.
+ yield return arguments is null
+ ? new ChatResponseUpdate(ChatRole.Assistant, "no tool call")
+ : new ChatResponseUpdate(ChatRole.Assistant,
+ [
+ new FunctionCallContent($"render-call-{Guid.NewGuid():N}", A2UIConstants.RenderA2UIToolName, arguments),
+ ]);
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null) => null;
+
+ public void Dispose()
+ {
+ }
+ }
+
+ ///
+ /// A planner agent scripted for the streaming invocation loop: the first run emits a
+ /// generate_a2ui tool call, subsequent runs emit a closing narration.
+ ///
+ private sealed class ScriptedPlannerAgent : AIAgent
+ {
+ private readonly Dictionary _generateArguments;
+
+ public ScriptedPlannerAgent(Dictionary generateArguments)
+ => this._generateArguments = generateArguments;
+
+ /// When set, emit a generate_a2ui call on every run instead of narrating after the first.
+ public bool AlwaysGenerate { get; init; }
+
+ /// When set, the first run emits this narration text alongside its generate_a2ui call.
+ public string? RoundOneText { get; init; }
+
+ public List> Runs { get; } = [];
+
+ /// The tool names advertised on each run, in order.
+ public List> ToolsPerRun { get; } = [];
+
+ /// The run options received on each run, in order.
+ public List OptionsPerRun { get; } = [];
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ this.Runs.Add(messages.ToList());
+ this.ToolsPerRun.Add((options as ChatClientAgentRunOptions)?.ChatOptions?.Tools?.Select(t => t.Name).ToList() ?? []);
+ this.OptionsPerRun.Add(options);
+
+ // A generate_a2ui call is only possible when the tool is still advertised; once
+ // the agent withholds it (the round-cap final turn), fall back to narration.
+ bool generateAdvertised = this.ToolsPerRun[^1].Contains(A2UIConstants.GenerateA2UIToolName);
+ if (generateAdvertised && (this.AlwaysGenerate || this.Runs.Count == 1))
+ {
+ List contents = [];
+ if (this.RoundOneText is not null && this.Runs.Count == 1)
+ {
+ contents.Add(new TextContent(this.RoundOneText));
+ }
+
+ contents.Add(new FunctionCallContent($"call-g{this.Runs.Count}", A2UIConstants.GenerateA2UIToolName, this._generateArguments));
+ yield return new AgentResponseUpdate(ChatRole.Assistant, contents);
+ }
+ else
+ {
+ yield return new AgentResponseUpdate(ChatRole.Assistant, "done");
+ }
+
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs
new file mode 100644
index 00000000000..ea740c330bd
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIComponentValidatorTests.cs
@@ -0,0 +1,401 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests;
+
+///
+/// Unit tests for .
+///
+///
+/// Ported from the Python toolkit's tests/test_validate.py and the TypeScript toolkit's
+/// validate.test.ts so all three languages agree on what counts as a valid A2UI surface.
+/// The Python "non-list components" case is covered by the type system here (the parameter is a
+/// ), so only the null/empty variants are ported.
+///
+public sealed class A2UIComponentValidatorTests
+{
+ private static A2UIValidationCatalog CreateCatalog() => new(new JsonObject
+ {
+ ["Row"] = new JsonObject
+ {
+ ["type"] = "object",
+ ["required"] = new JsonArray("children"),
+ },
+ ["HotelCard"] = new JsonObject
+ {
+ ["type"] = "object",
+ ["required"] = new JsonArray("name", "location", "rating", "pricePerNight"),
+ },
+ });
+
+ private static JsonArray CreateValidComponents() => new(
+ new JsonObject
+ {
+ ["id"] = "root",
+ ["component"] = "Row",
+ ["children"] = new JsonObject { ["componentId"] = "card", ["path"] = "/items" },
+ },
+ new JsonObject
+ {
+ ["id"] = "card",
+ ["component"] = "HotelCard",
+ ["name"] = new JsonObject { ["path"] = "name" },
+ ["location"] = new JsonObject { ["path"] = "location" },
+ ["rating"] = new JsonObject { ["path"] = "rating" },
+ ["pricePerNight"] = new JsonObject { ["path"] = "pricePerNight" },
+ });
+
+ private static JsonObject CreateValidData() => new()
+ {
+ ["items"] = new JsonArray(new JsonObject
+ {
+ ["name"] = "Ritz",
+ ["location"] = "NYC",
+ ["rating"] = 4.8,
+ ["pricePerNight"] = "$450",
+ }),
+ };
+
+ private static HashSet Codes(A2UIValidationResult result)
+ => result.Errors.Select(e => e.Code).ToHashSet();
+
+ [Fact]
+ public void Validate_WellFormedSurface_IsValid()
+ {
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(
+ CreateValidComponents(), CreateValidData(), CreateCatalog());
+
+ // Assert
+ Assert.True(result.Valid);
+ Assert.Empty(result.Errors);
+ }
+
+ [Fact]
+ public void Validate_MissingRoot_ReportsNoRoot()
+ {
+ // Arrange: rename the root component so no component carries id "root".
+ JsonArray components = CreateValidComponents();
+ components[0]!["id"] = "container";
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(
+ components, CreateValidData(), CreateCatalog());
+
+ // Assert
+ Assert.False(result.Valid);
+ Assert.Contains(A2UIValidationErrorCodes.NoRoot, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_MissingId_ReportsMissingId()
+ {
+ // Arrange
+ var components = new JsonArray(new JsonObject
+ {
+ ["component"] = "Row",
+ ["children"] = new JsonArray(),
+ });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.Contains(A2UIValidationErrorCodes.MissingId, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_MissingComponentType_ReportsMissingComponentType()
+ {
+ // Arrange
+ var components = new JsonArray(new JsonObject { ["id"] = "root" });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.Contains(A2UIValidationErrorCodes.MissingComponentType, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_DuplicateId_ReportsDuplicateId()
+ {
+ // Arrange
+ var components = new JsonArray(
+ new JsonObject { ["id"] = "root", ["component"] = "Row", ["children"] = new JsonArray("x") },
+ new JsonObject { ["id"] = "x", ["component"] = "Row", ["children"] = new JsonArray() },
+ new JsonObject { ["id"] = "x", ["component"] = "Row", ["children"] = new JsonArray() });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.Contains(A2UIValidationErrorCodes.DuplicateId, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_EmptyStringIds_ReportMissingIdNotDuplicate()
+ {
+ // Arrange: two components with empty-string ids must each be reported as a missing
+ // id, not as duplicates of one another.
+ var components = new JsonArray(
+ new JsonObject { ["id"] = "root", ["component"] = "Row", ["children"] = new JsonArray() },
+ new JsonObject { ["id"] = "", ["component"] = "Row", ["children"] = new JsonArray() },
+ new JsonObject { ["id"] = "", ["component"] = "Row", ["children"] = new JsonArray() });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.Contains(A2UIValidationErrorCodes.MissingId, Codes(result));
+ Assert.DoesNotContain(A2UIValidationErrorCodes.DuplicateId, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_SingularChildUnresolved_ReportsUnresolvedChild()
+ {
+ // Arrange: a one-child container (Card) whose singular `child` points at a
+ // component id that does not exist.
+ var components = new JsonArray(
+ new JsonObject { ["id"] = "root", ["component"] = "Card", ["child"] = "ghost" });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.Contains(A2UIValidationErrorCodes.UnresolvedChild, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_SingularChildResolved_IsValid()
+ {
+ // Arrange: the singular `child` points at a real component id.
+ var components = new JsonArray(
+ new JsonObject { ["id"] = "root", ["component"] = "Card", ["child"] = "label" },
+ new JsonObject { ["id"] = "label", ["component"] = "Text" });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.DoesNotContain(A2UIValidationErrorCodes.UnresolvedChild, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_SelfReferentialChild_ReportsChildCycle()
+ {
+ // Arrange: a one-child container whose singular `child` points at itself.
+ // The default prompt warns the model the child tree must be a DAG; a
+ // self-reference never terminates at render time.
+ var components = new JsonArray(
+ new JsonObject { ["id"] = "avatar", ["component"] = "Card", ["child"] = "avatar" });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.False(result.Valid);
+ Assert.Contains(result.Errors, e =>
+ e.Code == A2UIValidationErrorCodes.ChildCycle && e.Message.Contains("avatar -> avatar"));
+ }
+
+ [Fact]
+ public void Validate_MultiComponentCycle_ReportedOnce()
+ {
+ // Arrange: root -> a -> b -> a forms a cycle reachable from multiple entry points.
+ var components = new JsonArray(
+ new JsonObject { ["id"] = "root", ["component"] = "Row", ["children"] = new JsonArray("a") },
+ new JsonObject { ["id"] = "a", ["component"] = "Row", ["children"] = new JsonArray("b") },
+ new JsonObject { ["id"] = "b", ["component"] = "Row", ["children"] = new JsonArray("a") });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.Equal(1, result.Errors.Count(e => e.Code == A2UIValidationErrorCodes.ChildCycle));
+ Assert.Contains(result.Errors, e =>
+ e.Code == A2UIValidationErrorCodes.ChildCycle && e.Message.Contains("a -> b -> a"));
+ }
+
+ [Fact]
+ public void Validate_AcyclicChildGraph_NoChildCycle()
+ {
+ // Arrange
+ var components = new JsonArray(
+ new JsonObject { ["id"] = "root", ["component"] = "Row", ["children"] = new JsonArray("a", "b") },
+ new JsonObject { ["id"] = "a", ["component"] = "Text" },
+ new JsonObject { ["id"] = "b", ["component"] = "Text" });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.DoesNotContain(A2UIValidationErrorCodes.ChildCycle, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_DeepLinearChildChain_DoesNotOverflow()
+ {
+ // Arrange: a very deep linear child chain (root -> c1 -> c2 -> ... -> cN). The cycle
+ // walk must handle untrusted depth without overflowing the stack and must not report
+ // a cycle for an acyclic chain.
+ const int depth = 20_000;
+ var components = new JsonArray
+ {
+ new JsonObject { ["id"] = "root", ["component"] = "Card", ["child"] = "c1" },
+ };
+ for (int i = 1; i < depth; i++)
+ {
+ components.Add(new JsonObject { ["id"] = $"c{i}", ["component"] = "Card", ["child"] = $"c{i + 1}" });
+ }
+
+ components.Add(new JsonObject { ["id"] = $"c{depth}", ["component"] = "Text" });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.DoesNotContain(A2UIValidationErrorCodes.ChildCycle, Codes(result));
+ Assert.DoesNotContain(A2UIValidationErrorCodes.UnresolvedChild, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_EmptyOrNullComponents_FailsLoud()
+ {
+ // Act
+ A2UIValidationResult emptyResult = A2UIComponentValidator.Validate(new JsonArray());
+ A2UIValidationResult nullResult = A2UIComponentValidator.Validate(null);
+
+ // Assert
+ Assert.False(emptyResult.Valid);
+ Assert.False(nullResult.Valid);
+ Assert.Contains(A2UIValidationErrorCodes.EmptyComponents, Codes(emptyResult));
+ Assert.Contains(A2UIValidationErrorCodes.EmptyComponents, Codes(nullResult));
+ }
+
+ [Fact]
+ public void Validate_UnknownComponent_ReportsUnknownComponent()
+ {
+ // Arrange: point the card at a type the catalog does not define.
+ JsonArray components = CreateValidComponents();
+ components[1]!["component"] = "MysteryCard";
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(
+ components, CreateValidData(), CreateCatalog());
+
+ // Assert
+ Assert.Contains(A2UIValidationErrorCodes.UnknownComponent, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_MissingRequiredProp_ReportsPropNameInMessage()
+ {
+ // Arrange: drop a catalog-required property from the card.
+ JsonArray components = CreateValidComponents();
+ ((JsonObject)components[1]!).Remove("pricePerNight");
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(
+ components, CreateValidData(), CreateCatalog());
+
+ // Assert
+ Assert.Contains(result.Errors, e =>
+ e.Code == A2UIValidationErrorCodes.MissingRequiredProp && e.Message.Contains("pricePerNight"));
+ }
+
+ [Fact]
+ public void Validate_WithoutCatalog_RunsStructuralChecksOnly()
+ {
+ // Arrange: an unknown component type is acceptable when no catalog is supplied.
+ JsonArray components = CreateValidComponents();
+ components[1]!["component"] = "MysteryCard";
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components, CreateValidData());
+
+ // Assert
+ Assert.DoesNotContain(A2UIValidationErrorCodes.UnknownComponent, Codes(result));
+ Assert.True(result.Valid);
+ }
+
+ [Fact]
+ public void Validate_StructuralChildUnresolved_ReportsReferencedId()
+ {
+ // Arrange: the repeated-template child references a component that does not exist.
+ var components = new JsonArray(new JsonObject
+ {
+ ["id"] = "root",
+ ["component"] = "Row",
+ ["children"] = new JsonObject { ["componentId"] = "ghost", ["path"] = "/items" },
+ });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(
+ components, CreateValidData(), CreateCatalog());
+
+ // Assert
+ Assert.Contains(result.Errors, e =>
+ e.Code == A2UIValidationErrorCodes.UnresolvedChild && e.Message.Contains("ghost"));
+ }
+
+ [Fact]
+ public void Validate_ArrayChildUnresolved_ReportsReferencedId()
+ {
+ // Arrange
+ var components = new JsonArray(new JsonObject
+ {
+ ["id"] = "root",
+ ["component"] = "Row",
+ ["children"] = new JsonArray("missing-1"),
+ });
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(components);
+
+ // Assert
+ Assert.Contains(result.Errors, e =>
+ e.Code == A2UIValidationErrorCodes.UnresolvedChild && e.Message.Contains("missing-1"));
+ }
+
+ [Fact]
+ public void Validate_AbsoluteBindingUnresolved_ReportsPathInMessage()
+ {
+ // Arrange: empty data model cannot satisfy the absolute "/items" template path.
+ var emptyData = new JsonObject();
+
+ // Act
+ A2UIValidationResult result = A2UIComponentValidator.Validate(
+ CreateValidComponents(), emptyData, CreateCatalog());
+
+ // Assert
+ Assert.Contains(result.Errors, e =>
+ e.Code == A2UIValidationErrorCodes.UnresolvedBinding && e.Message.Contains("/items"));
+ }
+
+ [Fact]
+ public void Validate_RelativeBindings_AreNotValidated()
+ {
+ // Act: card props use relative paths (resolved per item inside the template).
+ A2UIValidationResult result = A2UIComponentValidator.Validate(
+ CreateValidComponents(), CreateValidData(), CreateCatalog());
+
+ // Assert
+ Assert.DoesNotContain(A2UIValidationErrorCodes.UnresolvedBinding, Codes(result));
+ }
+
+ [Fact]
+ public void Validate_ValidateBindingsFalse_DefersBindingChecks()
+ {
+ // Act: with binding checks deferred, an empty data model is acceptable.
+ A2UIValidationResult result = A2UIComponentValidator.Validate(
+ CreateValidComponents(), new JsonObject(), CreateCatalog(), validateBindings: false);
+
+ // Assert
+ Assert.DoesNotContain(A2UIValidationErrorCodes.UnresolvedBinding, Codes(result));
+ Assert.True(result.Valid);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs
new file mode 100644
index 00000000000..d01dfc93cad
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIConstantsTests.cs
@@ -0,0 +1,68 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests;
+
+///
+/// Unit tests for .
+///
+///
+/// These values are the cross-language A2UI wire contract shared with the TypeScript and
+/// Python toolkits; the tests pin them so a refactor cannot silently change the protocol.
+///
+public sealed class A2UIConstantsTests
+{
+ [Fact]
+ public void A2UIOperationsKey_MatchesCrossLanguageContract()
+ {
+ // Assert
+ Assert.Equal("a2ui_operations", A2UIConstants.A2UIOperationsKey);
+ }
+
+ [Fact]
+ public void BasicCatalogId_MatchesCrossLanguageContract()
+ {
+ // Assert
+ Assert.Equal("https://a2ui.org/specification/v0_9/basic_catalog.json", A2UIConstants.BasicCatalogId);
+ }
+
+ [Fact]
+ public void DefaultSurfaceId_MatchesCrossLanguageContract()
+ {
+ // Assert
+ Assert.Equal("dynamic-surface", A2UIConstants.DefaultSurfaceId);
+ }
+
+ [Fact]
+ public void RecoveryDefaults_MatchCrossLanguageContract()
+ {
+ // Assert
+ Assert.Equal(3, A2UIConstants.MaxA2UIAttempts);
+ Assert.Equal("a2ui_recovery", A2UIConstants.A2UIRecoveryActivityType);
+ }
+
+ [Fact]
+ public void ProtocolVersion_MatchesCrossLanguageContract()
+ {
+ // Assert
+ Assert.Equal("v0.9", A2UIConstants.ProtocolVersion);
+ }
+
+ [Fact]
+ public void ToolNames_MatchCrossLanguageContract()
+ {
+ // Assert
+ Assert.Equal("generate_a2ui", A2UIConstants.GenerateA2UIToolName);
+ Assert.Equal("render_a2ui", A2UIConstants.RenderA2UIToolName);
+ }
+
+ [Fact]
+ public void A2UISchemaContextDescription_MatchesMiddlewareContract()
+ {
+ // The AG-UI A2UI middleware emits the catalog schema context entry under exactly
+ // this description; adapters match it byte-for-byte to route the catalog.
+ Assert.Equal(
+ "A2UI Component Schema — available components for generating UI surfaces. " +
+ "Use these component names and properties when creating A2UI operations.",
+ A2UIConstants.A2UISchemaContextDescription);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs
new file mode 100644
index 00000000000..9f80efa4bbe
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIEnvelopeTests.cs
@@ -0,0 +1,394 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests;
+
+///
+/// Unit tests for , the envelope wrappers,
+/// , ,
+/// , and parameter resolution.
+///
+///
+/// Ported from the Python toolkit's TestAssembleOps, TestWrapAsOperationsEnvelope,
+/// TestWrapErrorEnvelope, TestPrepareA2UIRequest, TestBuildA2UIEnvelope,
+/// TestRenderToolDef, and TestResolveA2UIToolParams (mirrored in the TypeScript
+/// suite). The Python model passthrough assertions are not ported: in .NET the subagent
+/// chat client is owned by the adapter factory, not by .
+///
+public sealed class A2UIEnvelopeTests
+{
+ private static readonly string[] s_renderToolRequiredFields = ["surfaceId", "components"];
+
+ private static JsonArray RowComponents() => new(new JsonObject { ["id"] = "root", ["component"] = "Row" });
+
+ private static A2UIHistoryMessage PriorSurfaceMessage(string surfaceId) => new(
+ "tool",
+ A2UIToolkit.WrapAsOperationsEnvelope(
+ [
+ A2UIOperationBuilder.CreateSurface(surfaceId, "cat://x"),
+ A2UIOperationBuilder.UpdateComponents(surfaceId, RowComponents()),
+ A2UIOperationBuilder.UpdateDataModel(surfaceId, new JsonObject { ["items"] = new JsonArray(1, 2) }),
+ ]));
+
+ private static JsonArray ParseOperations(string envelope)
+ {
+ JsonObject parsed = Assert.IsType(JsonNode.Parse(envelope));
+ return Assert.IsType(parsed[A2UIConstants.A2UIOperationsKey]);
+ }
+
+ private static JsonObject SingleOperation(JsonArray operations, string operationName)
+ {
+ JsonObject? match = operations
+ .OfType()
+ .SingleOrDefault(op => op.ContainsKey(operationName));
+ Assert.NotNull(match);
+ return Assert.IsType(match[operationName]);
+ }
+
+ [Fact]
+ public void CreateRenderA2UIToolDefinition_HasCanonicalShape()
+ {
+ // Act
+ JsonObject definition = A2UIToolDefinitions.CreateRenderA2UIToolDefinition();
+
+ // Assert
+ Assert.Equal("function", (string?)definition["type"]);
+ JsonObject function = Assert.IsType(definition["function"]);
+ Assert.Equal(A2UIConstants.RenderA2UIToolName, (string?)function["name"]);
+ JsonObject parameters = Assert.IsType(function["parameters"]);
+ JsonArray required = Assert.IsType(parameters["required"]);
+ Assert.Equal(s_renderToolRequiredFields, required.Select(n => (string?)n).ToArray());
+ JsonObject properties = Assert.IsType(parameters["properties"]);
+ Assert.Equal(["surfaceId", "components", "data"], properties.Select(p => p.Key).ToArray());
+ }
+
+ [Fact]
+ public void AssembleOps_CreateIntent_EmitsFullEnvelope()
+ {
+ // Act
+ IReadOnlyList ops = A2UIToolkit.AssembleOps(
+ "create", "s1", "cat://x", RowComponents(), new JsonObject { ["items"] = new JsonArray("a") });
+
+ // Assert
+ Assert.Equal(3, ops.Count);
+ Assert.True(ops[0].ContainsKey("createSurface"));
+ Assert.True(ops[1].ContainsKey("updateComponents"));
+ Assert.True(ops[2].ContainsKey("updateDataModel"));
+ }
+
+ [Fact]
+ public void AssembleOps_UpdateIntent_SkipsCreateSurface()
+ {
+ // Act
+ IReadOnlyList ops = A2UIToolkit.AssembleOps(
+ "update", "s1", "cat://x", RowComponents(), new JsonObject { ["items"] = new JsonArray("a") });
+
+ // Assert
+ Assert.Equal(2, ops.Count);
+ Assert.True(ops[0].ContainsKey("updateComponents"));
+ Assert.True(ops[1].ContainsKey("updateDataModel"));
+ }
+
+ [Fact]
+ public void AssembleOps_NoData_OmitsDataModelOp()
+ {
+ // Act
+ IReadOnlyList ops = A2UIToolkit.AssembleOps("create", "s1", "cat://x", RowComponents());
+
+ // Assert
+ Assert.Equal(2, ops.Count);
+ Assert.True(ops[0].ContainsKey("createSurface"));
+ Assert.True(ops[1].ContainsKey("updateComponents"));
+ }
+
+ [Fact]
+ public void AssembleOps_EmptyData_OmitsDataModelOp()
+ {
+ // Act
+ IReadOnlyList ops = A2UIToolkit.AssembleOps(
+ "create", "s1", "cat://x", RowComponents(), new JsonObject());
+
+ // Assert
+ Assert.Equal(2, ops.Count);
+ }
+
+ [Fact]
+ public void WrapAsOperationsEnvelope_SerializesUnderOperationsKey()
+ {
+ // Act
+ string envelope = A2UIToolkit.WrapAsOperationsEnvelope([A2UIOperationBuilder.CreateSurface("s1", "c")]);
+
+ // Assert
+ JsonArray ops = ParseOperations(envelope);
+ JsonObject op = Assert.IsType(Assert.Single(ops));
+ Assert.Equal("v0.9", (string?)op["version"]);
+ Assert.Equal("s1", (string?)op["createSurface"]?["surfaceId"]);
+ }
+
+ [Fact]
+ public void WrapAsOperationsEnvelope_EmptyOps_SerializesEmptyArray()
+ {
+ // Act & Assert
+ Assert.Empty(ParseOperations(A2UIToolkit.WrapAsOperationsEnvelope([])));
+ }
+
+ [Fact]
+ public void WrapErrorEnvelope_WrapsMessage()
+ {
+ // Act
+ JsonObject parsed = Assert.IsType(JsonNode.Parse(A2UIToolkit.WrapErrorEnvelope("boom")));
+
+ // Assert
+ Assert.Equal("boom", (string?)parsed["error"]);
+ Assert.Single(parsed);
+ }
+
+ [Fact]
+ public void PrepareA2UIRequest_CreateIntent_BuildsPromptWithoutPrior()
+ {
+ // Arrange
+ var state = new A2UIAgentState { Context = [new A2UIContextEntry(null, "ctx")] };
+ var guidelines = new A2UIGuidelines { CompositionGuide = "guide" };
+
+ // Act
+ A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest(
+ "create", targetSurfaceId: null, changes: null, messages: [], state, guidelines);
+
+ // Assert
+ Assert.Null(prep.Error);
+ Assert.False(prep.IsUpdate);
+ Assert.Null(prep.Prior);
+ Assert.Contains("ctx", prep.Prompt);
+ Assert.Contains("guide", prep.Prompt);
+ }
+
+ [Fact]
+ public void PrepareA2UIRequest_MissingIntent_DefaultsToCreate()
+ {
+ // Act
+ A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest(
+ intent: null, targetSurfaceId: null, changes: null, messages: [], state: null);
+
+ // Assert
+ Assert.False(prep.IsUpdate);
+ Assert.Null(prep.Error);
+ }
+
+ [Fact]
+ public void PrepareA2UIRequest_UpdateWithMatchingPrior_BuildsEditPrompt()
+ {
+ // Act
+ A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest(
+ "update", "s1", "make it red", [PriorSurfaceMessage("s1")], state: null);
+
+ // Assert
+ Assert.Null(prep.Error);
+ Assert.True(prep.IsUpdate);
+ Assert.Equal("cat://x", prep.Prior?.CatalogId);
+ Assert.Contains("Editing an existing surface", prep.Prompt);
+ Assert.Contains("make it red", prep.Prompt);
+ }
+
+ [Fact]
+ public void PrepareA2UIRequest_UpdateWithoutPrior_ReturnsError()
+ {
+ // Act
+ A2UIPreparedRequest prep = A2UIToolkit.PrepareA2UIRequest(
+ "update", "missing", changes: null, [PriorSurfaceMessage("s1")], state: null);
+
+ // Assert
+ Assert.Equal(string.Empty, prep.Prompt);
+ Assert.NotNull(prep.Error);
+ Assert.Contains("missing", prep.Error);
+ Assert.Contains("no prior render", prep.Error);
+ }
+
+ [Fact]
+ public void BuildA2UIEnvelope_Create_UsesConfiguredCatalogNotArgs()
+ {
+ // Arrange
+ var args = new JsonObject
+ {
+ ["surfaceId"] = "from-args",
+ ["components"] = RowComponents(),
+ ["data"] = new JsonObject { ["items"] = new JsonArray(1) },
+ };
+
+ // Act
+ JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope(
+ args, isUpdate: false, targetSurfaceId: null, prior: null,
+ defaultCatalogId: "cat://configured"));
+
+ // Assert
+ JsonObject createSurface = SingleOperation(ops, "createSurface");
+ Assert.Equal("from-args", (string?)createSurface["surfaceId"]);
+ Assert.Equal("cat://configured", (string?)createSurface["catalogId"]);
+ JsonObject updateComponents = SingleOperation(ops, "updateComponents");
+ Assert.Equal(RowComponents().ToJsonString(), updateComponents["components"]?.ToJsonString());
+ JsonObject updateDataModel = SingleOperation(ops, "updateDataModel");
+ Assert.Equal("""{"items":[1]}""", updateDataModel["value"]?.ToJsonString());
+ }
+
+ [Fact]
+ public void BuildA2UIEnvelope_MissingSurfaceId_FallsBackToDefault()
+ {
+ // Arrange
+ var args = new JsonObject { ["components"] = new JsonArray() };
+
+ // Act
+ JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope(
+ args, isUpdate: false, targetSurfaceId: null, prior: null));
+
+ // Assert
+ Assert.Equal(A2UIConstants.DefaultSurfaceId, (string?)SingleOperation(ops, "createSurface")["surfaceId"]);
+ }
+
+ [Fact]
+ public void BuildA2UIEnvelope_EmptyStringDefaults_FallBackToCanonical()
+ {
+ // Arrange: a misconfigured host passes empty-string defaults. Those must NOT
+ // propagate into the emitted ops — the renderer would surface
+ // "Catalog not found: " / a blank surface id, hiding the real cause.
+ var args = new JsonObject { ["components"] = RowComponents() };
+
+ // Act
+ JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope(
+ args, isUpdate: false, targetSurfaceId: null, prior: null,
+ defaultSurfaceId: string.Empty, defaultCatalogId: string.Empty));
+
+ // Assert
+ JsonObject createSurface = SingleOperation(ops, "createSurface");
+ Assert.Equal(A2UIConstants.DefaultSurfaceId, (string?)createSurface["surfaceId"]);
+ Assert.Equal(A2UIConstants.BasicCatalogId, (string?)createSurface["catalogId"]);
+ }
+
+ [Theory]
+ [InlineData("42")]
+ [InlineData("[\"x\"]")]
+ [InlineData("null")]
+ [InlineData("{\"a\":1}")]
+ [InlineData("true")]
+ public void BuildA2UIEnvelope_NonStringSurfaceId_FallsBackToDefault(string badSurfaceIdJson)
+ {
+ // Arrange: the model is untrusted — surfaceId may come back as a number, array,
+ // null, object, or boolean. Without narrowing, a non-string value propagates into
+ // createSurface.surfaceId and the renderer crashes. Mirror of the TS/Python narrow.
+ var args = new JsonObject
+ {
+ ["surfaceId"] = JsonNode.Parse(badSurfaceIdJson),
+ ["components"] = new JsonArray(),
+ };
+
+ // Act
+ JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope(
+ args, isUpdate: false, targetSurfaceId: null, prior: null));
+
+ // Assert
+ Assert.Equal(A2UIConstants.DefaultSurfaceId, (string?)SingleOperation(ops, "createSurface")["surfaceId"]);
+ }
+
+ [Fact]
+ public void BuildA2UIEnvelope_UpdateWithEmptyTargetSurfaceId_FallsBackToDefault()
+ {
+ // Arrange: direct callers (bypassing PrepareA2UIRequest) may pass an empty target
+ // surface id on the update path. It must not propagate into updateComponents.
+ var args = new JsonObject { ["components"] = RowComponents() };
+ var prior = new A2UIPriorSurface(new JsonArray(), null, "cat://prior");
+
+ // Act
+ JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope(
+ args, isUpdate: true, targetSurfaceId: string.Empty, prior));
+
+ // Assert
+ Assert.Equal(A2UIConstants.DefaultSurfaceId, (string?)SingleOperation(ops, "updateComponents")["surfaceId"]);
+ }
+
+ [Fact]
+ public void BuildA2UIEnvelope_Update_SkipsCreateSurfaceAndKeepsTarget()
+ {
+ // Arrange
+ var args = new JsonObject
+ {
+ ["surfaceId"] = "ignored",
+ ["components"] = new JsonArray(new JsonObject { ["id"] = "root", ["component"] = "Column" }),
+ };
+ var prior = new A2UIPriorSurface(new JsonArray(), null, "cat://prior");
+
+ // Act
+ JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope(
+ args, isUpdate: true, targetSurfaceId: "s1", prior));
+
+ // Assert
+ Assert.DoesNotContain(ops.OfType(), op => op.ContainsKey("createSurface"));
+ Assert.Equal("s1", (string?)SingleOperation(ops, "updateComponents")["surfaceId"]);
+ }
+
+ [Fact]
+ public void BuildA2UIEnvelope_UpdateWithData_EmitsDataModelOp()
+ {
+ // Arrange
+ var args = new JsonObject
+ {
+ ["components"] = RowComponents(),
+ ["data"] = new JsonObject { ["items"] = new JsonArray(1) },
+ };
+ var prior = new A2UIPriorSurface(new JsonArray(), null, "cat://prior");
+
+ // Act
+ JsonArray ops = ParseOperations(A2UIToolkit.BuildA2UIEnvelope(
+ args, isUpdate: true, targetSurfaceId: "s1", prior));
+
+ // Assert: update path = updateComponents + updateDataModel, both on the target.
+ Assert.Equal(2, ops.Count);
+ Assert.Equal("s1", (string?)SingleOperation(ops, "updateComponents")["surfaceId"]);
+ JsonObject updateDataModel = SingleOperation(ops, "updateDataModel");
+ Assert.Equal("s1", (string?)updateDataModel["surfaceId"]);
+ Assert.Equal("""{"items":[1]}""", updateDataModel["value"]?.ToJsonString());
+ }
+
+ [Fact]
+ public void ResolveA2UIToolParams_FillsCanonicalDefaults()
+ {
+ // Act
+ A2UIResolvedToolParams resolved = A2UIToolDefinitions.ResolveA2UIToolParams(new A2UIToolParams());
+
+ // Assert
+ Assert.Equal(A2UIConstants.DefaultSurfaceId, resolved.DefaultSurfaceId);
+ Assert.Equal(A2UIConstants.BasicCatalogId, resolved.DefaultCatalogId);
+ Assert.Equal(A2UIConstants.GenerateA2UIToolName, resolved.ToolName);
+ Assert.Equal(A2UIToolDefinitions.GenerateA2UIToolDescription, resolved.ToolDescription);
+ Assert.Null(resolved.Guidelines);
+ }
+
+ [Fact]
+ public void ResolveA2UIToolParams_EmptyStringOverrides_FallBackToDefaults()
+ {
+ // Arrange
+ var parameters = new A2UIToolParams { ToolName = string.Empty, DefaultCatalogId = string.Empty };
+
+ // Act
+ A2UIResolvedToolParams resolved = A2UIToolDefinitions.ResolveA2UIToolParams(parameters);
+
+ // Assert
+ Assert.Equal(A2UIConstants.GenerateA2UIToolName, resolved.ToolName);
+ Assert.Equal(A2UIConstants.BasicCatalogId, resolved.DefaultCatalogId);
+ }
+
+ [Fact]
+ public void ResolveA2UIToolParams_Overrides_PassThrough()
+ {
+ // Arrange
+ var guidelines = new A2UIGuidelines { CompositionGuide = "g" };
+ var parameters = new A2UIToolParams { ToolName = "custom_tool", Guidelines = guidelines };
+
+ // Act
+ A2UIResolvedToolParams resolved = A2UIToolDefinitions.ResolveA2UIToolParams(parameters);
+
+ // Assert
+ Assert.Equal("custom_tool", resolved.ToolName);
+ Assert.Same(guidelines, resolved.Guidelines);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs
new file mode 100644
index 00000000000..cba009711c4
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIFindPriorSurfaceTests.cs
@@ -0,0 +1,268 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests;
+
+///
+/// Unit tests for .
+///
+///
+/// Ported from the Python toolkit's TestFindPriorSurface (11 cases, mirrored in the
+/// TypeScript suite). These pin the surface-walk semantics: within a message the last
+/// operation per field wins and deleteSurface resets the accumulator; across
+/// messages the newest mention is authoritative and older messages only fill gaps; a
+/// surface whose newest end state is deleted is never resurrected.
+/// The Python "accepts dict-style messages" duck-typing case is covered by the
+/// type here and is not ported.
+///
+public sealed class A2UIFindPriorSurfaceTests
+{
+ private static A2UIHistoryMessage ToolMessage(JsonObject content)
+ => new("tool", content.ToJsonString());
+
+ private static JsonObject Operations(params JsonObject[] operations)
+ => new() { [A2UIConstants.A2UIOperationsKey] = new JsonArray(operations) };
+
+ private static JsonObject DeleteSurface(string surfaceId) => new()
+ {
+ ["version"] = "v0.9",
+ ["deleteSurface"] = new JsonObject { ["surfaceId"] = surfaceId },
+ };
+
+ private static JsonArray RowComponents() => new(new JsonObject { ["id"] = "root", ["component"] = "Row" });
+
+ private static JsonArray ColumnComponents() => new(new JsonObject { ["id"] = "root", ["component"] = "Column" });
+
+ [Fact]
+ public void FindPriorSurface_SurfaceNotInHistory_ReturnsNull()
+ {
+ // Arrange
+ A2UIHistoryMessage[] messages = [ToolMessage(Operations())];
+
+ // Act & Assert
+ Assert.Null(A2UIToolkit.FindPriorSurface(messages, "missing"));
+ }
+
+ [Fact]
+ public void FindPriorSurface_SingleMessage_ReconstructsState()
+ {
+ // Arrange
+ A2UIHistoryMessage[] messages =
+ [
+ ToolMessage(Operations(
+ A2UIOperationBuilder.CreateSurface("s1", "cat://x"),
+ A2UIOperationBuilder.UpdateComponents("s1", RowComponents()),
+ A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1, 2) }))),
+ ];
+
+ // Act
+ A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1");
+
+ // Assert
+ Assert.NotNull(prior);
+ Assert.Equal("cat://x", prior.CatalogId);
+ Assert.Equal(RowComponents().ToJsonString(), prior.Components?.ToJsonString());
+ Assert.Equal("""{"items":[1,2]}""", prior.Data?.ToJsonString());
+ }
+
+ [Fact]
+ public void FindPriorSurface_MultipleMessages_PrefersLatest()
+ {
+ // Arrange
+ A2UIHistoryMessage[] messages =
+ [
+ ToolMessage(Operations(
+ A2UIOperationBuilder.CreateSurface("s1", "old-cat"),
+ A2UIOperationBuilder.UpdateComponents("s1", RowComponents()))),
+ ToolMessage(Operations(
+ A2UIOperationBuilder.UpdateComponents("s1", ColumnComponents()),
+ A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["changed"] = true }))),
+ ];
+
+ // Act
+ A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1");
+
+ // Assert
+ Assert.NotNull(prior);
+ Assert.Equal(ColumnComponents().ToJsonString(), prior.Components?.ToJsonString());
+ Assert.Equal("""{"changed":true}""", prior.Data?.ToJsonString());
+ }
+
+ [Fact]
+ public void FindPriorSurface_NonToolAndMalformedMessages_AreIgnored()
+ {
+ // Arrange
+ A2UIHistoryMessage[] messages =
+ [
+ new("assistant", "not a tool"),
+ new("tool", "not json"),
+ ToolMessage(new JsonObject { ["unrelated"] = "payload" }),
+ ];
+
+ // Act & Assert
+ Assert.Null(A2UIToolkit.FindPriorSurface(messages, "s1"));
+ }
+
+ [Fact]
+ public void FindPriorSurface_WithinMessage_LastOperationWins()
+ {
+ // Arrange: one envelope emits multiple ops for the same surface. The renderer
+ // applies them in order, so the surface ends at Column / {v:2} / cat-B.
+ A2UIHistoryMessage[] messages =
+ [
+ ToolMessage(Operations(
+ A2UIOperationBuilder.CreateSurface("s1", "cat-A"),
+ A2UIOperationBuilder.UpdateComponents("s1", RowComponents()),
+ A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["v"] = 1 }),
+ A2UIOperationBuilder.CreateSurface("s1", "cat-B"),
+ A2UIOperationBuilder.UpdateComponents("s1", ColumnComponents()),
+ A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["v"] = 2 }))),
+ ];
+
+ // Act
+ A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1");
+
+ // Assert
+ Assert.NotNull(prior);
+ Assert.Equal("cat-B", prior.CatalogId);
+ Assert.Equal(ColumnComponents().ToJsonString(), prior.Components?.ToJsonString());
+ Assert.Equal("""{"v":2}""", prior.Data?.ToJsonString());
+ }
+
+ [Fact]
+ public void FindPriorSurface_FieldsAccumulateAcrossWalk()
+ {
+ // Arrange: turn 1 sets everything; turn 2 only updates data. The walker must
+ // surface components + catalogId from turn 1 plus the newer data from turn 2 —
+ // not blank components because the most recent message happened to omit them.
+ A2UIHistoryMessage[] messages =
+ [
+ ToolMessage(Operations(
+ A2UIOperationBuilder.CreateSurface("s1", "cat://x"),
+ A2UIOperationBuilder.UpdateComponents("s1", RowComponents()),
+ A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1) }))),
+ ToolMessage(Operations(
+ A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1, 2, 3) }))),
+ ];
+
+ // Act
+ A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1");
+
+ // Assert
+ Assert.NotNull(prior);
+ Assert.Equal("cat://x", prior.CatalogId);
+ Assert.Equal(RowComponents().ToJsonString(), prior.Components?.ToJsonString());
+ Assert.Equal("""{"items":[1,2,3]}""", prior.Data?.ToJsonString());
+ }
+
+ [Fact]
+ public void FindPriorSurface_NewestDelete_ReturnsNull()
+ {
+ // Arrange: older message populated the surface; newer message deletes it. The
+ // renderer no longer shows it, so the stale state must not be resurrected.
+ A2UIHistoryMessage[] messages =
+ [
+ ToolMessage(Operations(
+ A2UIOperationBuilder.CreateSurface("s1", "cat://x"),
+ A2UIOperationBuilder.UpdateComponents("s1", RowComponents()),
+ A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1, 2) }))),
+ ToolMessage(Operations(DeleteSurface("s1"))),
+ ];
+
+ // Act & Assert
+ Assert.Null(A2UIToolkit.FindPriorSurface(messages, "s1"));
+ }
+
+ [Fact]
+ public void FindPriorSurface_OlderDelete_OverriddenByNewerCreate()
+ {
+ // Arrange: older message deleted the surface; newer message recreates it. The
+ // newer state must be returned — the older delete is dead history.
+ A2UIHistoryMessage[] messages =
+ [
+ ToolMessage(Operations(DeleteSurface("s1"))),
+ ToolMessage(Operations(
+ A2UIOperationBuilder.CreateSurface("s1", "cat://new"),
+ A2UIOperationBuilder.UpdateComponents("s1", ColumnComponents()),
+ A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(9) }))),
+ ];
+
+ // Act
+ A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1");
+
+ // Assert
+ Assert.NotNull(prior);
+ Assert.Equal("cat://new", prior.CatalogId);
+ Assert.Equal(ColumnComponents().ToJsonString(), prior.Components?.ToJsonString());
+ Assert.Equal("""{"items":[9]}""", prior.Data?.ToJsonString());
+ }
+
+ [Fact]
+ public void FindPriorSurface_IntraMessageDeleteThenCreate_ReturnsRecreatedState()
+ {
+ // Arrange: within one message, ops apply in order. Delete then create → the
+ // surface exists with the recreated content at end of message, data unset.
+ A2UIHistoryMessage[] messages =
+ [
+ ToolMessage(Operations(
+ DeleteSurface("s1"),
+ A2UIOperationBuilder.CreateSurface("s1", "cat-recreated"),
+ A2UIOperationBuilder.UpdateComponents("s1", RowComponents()))),
+ ];
+
+ // Act
+ A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1");
+
+ // Assert
+ Assert.NotNull(prior);
+ Assert.Equal("cat-recreated", prior.CatalogId);
+ Assert.Equal(RowComponents().ToJsonString(), prior.Components?.ToJsonString());
+ Assert.Null(prior.Data);
+ }
+
+ [Fact]
+ public void FindPriorSurface_ReturnedNodes_AreDetachedFromTheParsedMessage()
+ {
+ // Arrange
+ A2UIHistoryMessage[] messages =
+ [
+ ToolMessage(Operations(
+ A2UIOperationBuilder.CreateSurface("s1", "cat://x"),
+ A2UIOperationBuilder.UpdateComponents("s1", RowComponents()),
+ A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray(1) }))),
+ ];
+ A2UIPriorSurface? prior = A2UIToolkit.FindPriorSurface(messages, "s1");
+ Assert.NotNull(prior);
+
+ // Act: re-attaching the returned nodes must not throw "node already has a parent".
+ var host = new JsonObject
+ {
+ ["components"] = prior.Components,
+ ["data"] = prior.Data,
+ };
+
+ // Assert
+ Assert.NotNull(host["components"]);
+ Assert.NotNull(host["data"]);
+ }
+
+ [Fact]
+ public void FindPriorSurface_IntraMessageCreateThenDelete_ReturnsNull()
+ {
+ // Arrange: within the newest message the surface is created then deleted — its end
+ // state is deleted, regardless of older accumulated state in prior messages.
+ A2UIHistoryMessage[] messages =
+ [
+ ToolMessage(Operations(
+ A2UIOperationBuilder.CreateSurface("s1", "older-cat"),
+ A2UIOperationBuilder.UpdateComponents("s1", RowComponents()))),
+ ToolMessage(Operations(
+ A2UIOperationBuilder.CreateSurface("s1", "transient"),
+ DeleteSurface("s1"))),
+ ];
+
+ // Act & Assert
+ Assert.Null(A2UIToolkit.FindPriorSurface(messages, "s1"));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIGenerationRecoveryTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIGenerationRecoveryTests.cs
new file mode 100644
index 00000000000..7e060c54235
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIGenerationRecoveryTests.cs
@@ -0,0 +1,204 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests;
+
+///
+/// Unit tests for .
+///
+///
+/// Ported from the Python toolkit's tests/test_recovery.py and the TypeScript toolkit's
+/// recovery.test.ts. The .NET loop is asynchronous (idiomatic ), but the
+/// attempt semantics — first valid attempt wins, error-augmented retries, structured exhaustion
+/// envelope — are identical across languages.
+///
+public sealed class A2UIGenerationRecoveryTests
+{
+ private static A2UIValidationCatalog CreateCatalog() => new(new JsonObject
+ {
+ ["Row"] = new JsonObject { ["required"] = new JsonArray("children") },
+ ["HotelCard"] = new JsonObject { ["required"] = new JsonArray("name", "rating") },
+ });
+
+ private static JsonObject CreateRoot() => new()
+ {
+ ["id"] = "root",
+ ["component"] = "Row",
+ ["children"] = new JsonObject { ["componentId"] = "card", ["path"] = "/items" },
+ };
+
+ private static JsonObject CreateGoodCard() => new()
+ {
+ ["id"] = "card",
+ ["component"] = "HotelCard",
+ ["name"] = new JsonObject { ["path"] = "name" },
+ ["rating"] = new JsonObject { ["path"] = "rating" },
+ };
+
+ // Missing the catalog-required `rating` property.
+ private static JsonObject CreateBadCard() => new()
+ {
+ ["id"] = "card",
+ ["component"] = "HotelCard",
+ ["name"] = new JsonObject { ["path"] = "name" },
+ };
+
+ private static JsonObject CreateArgs(JsonObject card) => new()
+ {
+ ["surfaceId"] = "s1",
+ ["components"] = new JsonArray(CreateRoot(), card),
+ ["data"] = new JsonObject
+ {
+ ["items"] = new JsonArray(new JsonObject { ["name"] = "Ritz", ["rating"] = 4.8 }),
+ },
+ };
+
+ private static string BuildEnvelope(JsonObject args)
+ => new JsonObject { [A2UIConstants.A2UIOperationsKey] = args["components"]!.DeepClone() }.ToJsonString();
+
+ private static readonly IReadOnlyList s_sampleErrors =
+ [
+ new(A2UIValidationErrorCodes.MissingRequiredProp, "components[1].rating", "missing required prop 'rating'"),
+ ];
+
+ [Fact]
+ public void AugmentPromptWithValidationErrors_NoErrors_ReturnsPromptUnchanged()
+ {
+ // Act
+ string result = A2UIGenerationRecovery.AugmentPromptWithValidationErrors("BASE", []);
+
+ // Assert
+ Assert.Equal("BASE", result);
+ }
+
+ [Fact]
+ public void AugmentPromptWithValidationErrors_WithErrors_AppendsFormattedFixBlock()
+ {
+ // Act
+ string result = A2UIGenerationRecovery.AugmentPromptWithValidationErrors("BASE", s_sampleErrors);
+
+ // Assert
+ Assert.Contains("BASE", result);
+ Assert.Contains("rating", result);
+ Assert.Contains(A2UIGenerationRecovery.FormatValidationErrors(s_sampleErrors), result);
+ }
+
+ [Fact]
+ public async Task RunAsync_ValidFirstAttempt_ReturnsImmediatelyAsync()
+ {
+ // Arrange
+ List calls = [];
+
+ // Act
+ A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync(
+ "P",
+ (prompt, attempt, ct) =>
+ {
+ calls.Add(attempt);
+ return new ValueTask(CreateArgs(CreateGoodCard()));
+ },
+ BuildEnvelope,
+ CreateCatalog());
+
+ // Assert
+ Assert.True(result.Ok);
+ A2UIAttemptRecord record = Assert.Single(result.Attempts);
+ Assert.True(record.Ok);
+ Assert.Equal([1], calls);
+ JsonObject envelope = Assert.IsType(JsonNode.Parse(result.Envelope));
+ Assert.True(envelope.ContainsKey(A2UIConstants.A2UIOperationsKey));
+ }
+
+ [Fact]
+ public async Task RunAsync_InvalidFirstAttempt_RetriesWithErrorFeedbackAsync()
+ {
+ // Arrange
+ List prompts = [];
+
+ // Act
+ A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync(
+ "P",
+ (prompt, attempt, ct) =>
+ {
+ prompts.Add(prompt);
+ return new ValueTask(CreateArgs(attempt == 1 ? CreateBadCard() : CreateGoodCard()));
+ },
+ BuildEnvelope,
+ CreateCatalog());
+
+ // Assert
+ Assert.True(result.Ok);
+ Assert.Equal(2, result.Attempts.Count);
+ Assert.False(result.Attempts[0].Ok);
+ Assert.True(result.Attempts[1].Ok);
+ // The retry prompt carries the prior attempt's validation errors.
+ Assert.Contains("rating", prompts[1]);
+ }
+
+ [Fact]
+ public async Task RunAsync_AllAttemptsInvalid_ReturnsStructuredHardFailureAsync()
+ {
+ // Arrange
+ List seen = [];
+
+ // Act
+ A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync(
+ "P",
+ (prompt, attempt, ct) => new ValueTask(CreateArgs(CreateBadCard())),
+ BuildEnvelope,
+ CreateCatalog(),
+ onAttempt: seen.Add);
+
+ // Assert
+ Assert.False(result.Ok);
+ Assert.Equal(A2UIConstants.MaxA2UIAttempts, result.Attempts.Count);
+ Assert.Equal(A2UIConstants.MaxA2UIAttempts, seen.Count);
+ JsonObject envelope = Assert.IsType(JsonNode.Parse(result.Envelope));
+ Assert.Equal("a2ui_recovery_exhausted", (string?)envelope["code"]);
+ Assert.False(string.IsNullOrEmpty((string?)envelope["error"]));
+ Assert.IsType(envelope["attempts"]);
+ }
+
+ [Fact]
+ public async Task RunAsync_MaxAttemptsOverride_LimitsAttemptsAsync()
+ {
+ // Arrange
+ List calls = [];
+
+ // Act
+ A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync(
+ "P",
+ (prompt, attempt, ct) =>
+ {
+ calls.Add(attempt);
+ return new ValueTask(CreateArgs(CreateBadCard()));
+ },
+ BuildEnvelope,
+ CreateCatalog(),
+ new A2UIRecoveryConfig { MaxAttempts = 2 });
+
+ // Assert
+ Assert.False(result.Ok);
+ Assert.Equal([1, 2], calls);
+ }
+
+ [Fact]
+ public async Task RunAsync_MissingToolCall_IsRetryableAsync()
+ {
+ // Act
+ A2UIRecoveryResult result = await A2UIGenerationRecovery.RunAsync(
+ "P",
+ (prompt, attempt, ct) => new ValueTask(
+ attempt == 1 ? null : CreateArgs(CreateGoodCard())),
+ BuildEnvelope,
+ CreateCatalog());
+
+ // Assert
+ Assert.True(result.Ok);
+ Assert.Equal(2, result.Attempts.Count);
+ Assert.False(result.Attempts[0].Ok);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIOperationBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIOperationBuilderTests.cs
new file mode 100644
index 00000000000..457864ba948
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIOperationBuilderTests.cs
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests;
+
+///
+/// Unit tests for .
+///
+///
+/// Ported from the Python toolkit's TestOpBuilders and the TypeScript toolkit's
+/// op-builder tests to guarantee byte-level envelope parity across languages.
+///
+public sealed class A2UIOperationBuilderTests
+{
+ [Fact]
+ public void CreateSurface_ReturnsVersionedOperation()
+ {
+ // Act
+ JsonObject op = A2UIOperationBuilder.CreateSurface("s1", "catalog-1");
+
+ // Assert
+ Assert.Equal("v0.9", (string?)op["version"]);
+ JsonObject createSurface = Assert.IsType(op["createSurface"]);
+ Assert.Equal("s1", (string?)createSurface["surfaceId"]);
+ Assert.Equal("catalog-1", (string?)createSurface["catalogId"]);
+ }
+
+ [Fact]
+ public void UpdateComponents_WrapsComponentArray()
+ {
+ // Arrange
+ var components = new JsonNode?[]
+ {
+ new JsonObject { ["id"] = "root", ["component"] = "Row" },
+ };
+
+ // Act
+ JsonObject op = A2UIOperationBuilder.UpdateComponents("s1", components);
+
+ // Assert
+ Assert.Equal("v0.9", (string?)op["version"]);
+ JsonObject updateComponents = Assert.IsType(op["updateComponents"]);
+ Assert.Equal("s1", (string?)updateComponents["surfaceId"]);
+ JsonArray array = Assert.IsType(updateComponents["components"]);
+ JsonObject component = Assert.IsType(Assert.Single(array));
+ Assert.Equal("root", (string?)component["id"]);
+ }
+
+ [Fact]
+ public void UpdateDataModel_DefaultsToRootPath()
+ {
+ // Act
+ JsonObject op = A2UIOperationBuilder.UpdateDataModel("s1", new JsonObject { ["items"] = new JsonArray() });
+
+ // Assert
+ Assert.Equal("v0.9", (string?)op["version"]);
+ JsonObject updateDataModel = Assert.IsType(op["updateDataModel"]);
+ Assert.Equal("s1", (string?)updateDataModel["surfaceId"]);
+ Assert.Equal("/", (string?)updateDataModel["path"]);
+ Assert.NotNull(updateDataModel["value"]);
+ }
+
+ [Fact]
+ public void UpdateDataModel_HonorsCustomPath()
+ {
+ // Act
+ JsonObject op = A2UIOperationBuilder.UpdateDataModel("s1", JsonValue.Create(42), "/answer");
+
+ // Assert
+ JsonObject updateDataModel = Assert.IsType(op["updateDataModel"]);
+ Assert.Equal("/answer", (string?)updateDataModel["path"]);
+ Assert.Equal(42, (int?)updateDataModel["value"]);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIPromptBuildingTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIPromptBuildingTests.cs
new file mode 100644
index 00000000000..2472a8c05d0
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/A2UIPromptBuildingTests.cs
@@ -0,0 +1,218 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests;
+
+///
+/// Unit tests for and
+/// .
+///
+///
+/// Ported from the Python toolkit's TestBuildContextPrompt and
+/// TestBuildSubagentPrompt (mirrored in the TypeScript suite).
+///
+public sealed class A2UIPromptBuildingTests
+{
+ ///
+ /// Suppresses both built-in default blocks so structural tests can assert exact output
+ /// without the (large) default text. The empty string is the documented escape hatch
+ /// ( → default; "" → block omitted).
+ ///
+ private static A2UIGuidelines SuppressDefaults() => new()
+ {
+ GenerationGuidelines = string.Empty,
+ DesignGuidelines = string.Empty,
+ };
+
+ [Fact]
+ public void BuildContextPrompt_EmptyState_ReturnsEmpty()
+ {
+ // Act & Assert
+ Assert.Equal(string.Empty, A2UIToolkit.BuildContextPrompt(new A2UIAgentState()));
+ Assert.Equal(string.Empty, A2UIToolkit.BuildContextPrompt(null));
+ }
+
+ [Fact]
+ public void BuildContextPrompt_DescribedEntry_BecomesMarkdownSection()
+ {
+ // Arrange
+ var state = new A2UIAgentState
+ {
+ Context = [new A2UIContextEntry("Style guide", "use cards")],
+ };
+
+ // Act
+ string prompt = A2UIToolkit.BuildContextPrompt(state);
+
+ // Assert
+ Assert.Contains("## Style guide", prompt);
+ Assert.Contains("use cards", prompt);
+ }
+
+ [Fact]
+ public void BuildContextPrompt_ValueOnlyEntry_HasNoHeading()
+ {
+ // Arrange
+ var state = new A2UIAgentState
+ {
+ Context = [new A2UIContextEntry(null, "free-form note")],
+ };
+
+ // Act
+ string prompt = A2UIToolkit.BuildContextPrompt(state);
+
+ // Assert
+ Assert.Contains("free-form note", prompt);
+ Assert.DoesNotContain("##", prompt);
+ }
+
+ [Fact]
+ public void BuildContextPrompt_Schema_RendersAvailableComponentsSection()
+ {
+ // Arrange
+ var state = new A2UIAgentState { A2UISchema = "" };
+
+ // Act
+ string prompt = A2UIToolkit.BuildContextPrompt(state);
+
+ // Assert
+ Assert.Contains("## Available Components", prompt);
+ Assert.Contains("", prompt);
+ }
+
+ [Fact]
+ public void BuildContextPrompt_EmptyEntries_AreDropped()
+ {
+ // Arrange
+ var state = new A2UIAgentState { Context = [new A2UIContextEntry(null, null)] };
+
+ // Act & Assert
+ Assert.Equal(string.Empty, A2UIToolkit.BuildContextPrompt(state));
+ }
+
+ [Fact]
+ public void BuildSubagentPrompt_NoGuidelines_AppliesBuiltInDefaults()
+ {
+ // Act
+ string prompt = A2UIToolkit.BuildSubagentPrompt("ctx");
+
+ // Assert
+ Assert.Contains(A2UIPromptDefaults.GenerationGuidelines, prompt, StringComparison.Ordinal);
+ Assert.Contains("## Design Guidelines", prompt, StringComparison.Ordinal);
+ Assert.Contains(A2UIPromptDefaults.DesignGuidelines, prompt, StringComparison.Ordinal);
+ Assert.Contains("ctx", prompt, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void BuildSubagentPrompt_Sections_AppearInCanonicalOrder()
+ {
+ // Arrange: generation → design → context → composition.
+ var guidelines = new A2UIGuidelines
+ {
+ GenerationGuidelines = "GENMARK",
+ DesignGuidelines = "DESMARK",
+ CompositionGuide = "COMPMARK",
+ };
+
+ // Act
+ string prompt = A2UIToolkit.BuildSubagentPrompt("CTXMARK", guidelines);
+
+ // Assert
+ Assert.True(prompt.IndexOf("GENMARK", StringComparison.Ordinal) < prompt.IndexOf("DESMARK", StringComparison.Ordinal));
+ Assert.True(prompt.IndexOf("DESMARK", StringComparison.Ordinal) < prompt.IndexOf("CTXMARK", StringComparison.Ordinal));
+ Assert.True(prompt.IndexOf("CTXMARK", StringComparison.Ordinal) < prompt.IndexOf("COMPMARK", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void BuildSubagentPrompt_PerFieldOverride_KeepsOtherDefault()
+ {
+ // Arrange: override generation only → design still falls back to its default.
+ var guidelines = new A2UIGuidelines { GenerationGuidelines = "CUSTOM_GEN" };
+
+ // Act
+ string prompt = A2UIToolkit.BuildSubagentPrompt("ctx", guidelines);
+
+ // Assert
+ Assert.Contains("CUSTOM_GEN", prompt, StringComparison.Ordinal);
+ Assert.DoesNotContain(A2UIPromptDefaults.GenerationGuidelines, prompt, StringComparison.Ordinal);
+ Assert.Contains(A2UIPromptDefaults.DesignGuidelines, prompt, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void BuildSubagentPrompt_EmptyStringOverride_SuppressesBlock()
+ {
+ // Act
+ string prompt = A2UIToolkit.BuildSubagentPrompt("ctx", SuppressDefaults());
+
+ // Assert
+ Assert.DoesNotContain(A2UIPromptDefaults.GenerationGuidelines, prompt, StringComparison.Ordinal);
+ Assert.DoesNotContain(A2UIPromptDefaults.DesignGuidelines, prompt, StringComparison.Ordinal);
+ Assert.DoesNotContain("## Design Guidelines", prompt, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void BuildSubagentPrompt_ContextOnly_ReturnsContextVerbatim()
+ {
+ // Act & Assert
+ Assert.Equal("ctx", A2UIToolkit.BuildSubagentPrompt("ctx", SuppressDefaults()));
+ }
+
+ [Fact]
+ public void BuildSubagentPrompt_CompositionGuide_IsAppendedAfterContext()
+ {
+ // Arrange
+ var guidelines = new A2UIGuidelines
+ {
+ GenerationGuidelines = string.Empty,
+ DesignGuidelines = string.Empty,
+ CompositionGuide = "guide",
+ };
+
+ // Act & Assert
+ Assert.Equal("ctx\nguide", A2UIToolkit.BuildSubagentPrompt("ctx", guidelines));
+ }
+
+ [Fact]
+ public void BuildSubagentPrompt_EditContext_RendersPriorStateAndChanges()
+ {
+ // Arrange
+ var prior = new A2UIPriorSurface(
+ new JsonArray(new JsonObject { ["id"] = "root", ["component"] = "Row" }),
+ new JsonObject { ["x"] = 1 },
+ CatalogId: null);
+ var edit = new A2UIEditContext("s1", prior, "make the title bigger");
+
+ // Act
+ string prompt = A2UIToolkit.BuildSubagentPrompt("ctx", SuppressDefaults(), edit);
+
+ // Assert
+ Assert.Contains("Editing an existing surface", prompt, StringComparison.Ordinal);
+ Assert.Contains("'s1'", prompt, StringComparison.Ordinal);
+ Assert.Contains("\"id\": \"root\"", prompt, StringComparison.Ordinal);
+ Assert.Contains("\"x\": 1", prompt, StringComparison.Ordinal);
+ Assert.Contains("Requested changes", prompt, StringComparison.Ordinal);
+ Assert.Contains("make the title bigger", prompt, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void BuildSubagentPrompt_NoChanges_OmitsRequestedChangesSection()
+ {
+ // Arrange
+ var edit = new A2UIEditContext("s1", new A2UIPriorSurface(new JsonArray(), null, null));
+
+ // Act
+ string prompt = A2UIToolkit.BuildSubagentPrompt("ctx", SuppressDefaults(), edit);
+
+ // Assert
+ Assert.DoesNotContain("Requested changes", prompt, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void BuildSubagentPrompt_EmptyEverything_ReturnsEmpty()
+ {
+ // Act & Assert: empty context AND both default blocks suppressed → empty prompt.
+ Assert.Equal(string.Empty, A2UIToolkit.BuildSubagentPrompt(string.Empty, SuppressDefaults()));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/AGUIContextAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/AGUIContextAgentTests.cs
new file mode 100644
index 00000000000..3c0673b3451
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/AGUIContextAgentTests.cs
@@ -0,0 +1,129 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AGUI.A2UI.UnitTests;
+
+///
+/// Unit tests for : forwarded AG-UI context entries are
+/// surfaced to the model as a leading system message, with the catalog schema entry
+/// routed into the canonical ## Available Components section.
+///
+public sealed class AGUIContextAgentTests
+{
+ [Fact]
+ public async Task RunAsync_WithForwardedContext_PrependsSystemMessageAsync()
+ {
+ // Arrange
+ var inner = new RecordingAgent();
+ var agent = new AGUIContextAgent(inner);
+ var options = new ChatClientAgentRunOptions
+ {
+ ChatOptions = new ChatOptions
+ {
+ AdditionalProperties = new AdditionalPropertiesDictionary
+ {
+ ["ag_ui_context"] = new[]
+ {
+ new KeyValuePair(A2UIConstants.A2UISchemaContextDescription, "{\"components\":{}}"),
+ new KeyValuePair("Style guide", "use cards"),
+ },
+ },
+ },
+ };
+
+ // Act
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")], options: options);
+
+ // Assert: one system message prepended, carrying both the canonical schema
+ // section and the plain context section.
+ Assert.NotNull(inner.LastMessages);
+ Assert.Equal(2, inner.LastMessages!.Count);
+ ChatMessage system = inner.LastMessages[0];
+ Assert.Equal(ChatRole.System, system.Role);
+ Assert.Contains("## Available Components", system.Text);
+ Assert.Contains("{\"components\":{}}", system.Text);
+ Assert.Contains("## Style guide", system.Text);
+ Assert.Contains("use cards", system.Text);
+ Assert.Equal(ChatRole.User, inner.LastMessages[1].Role);
+ }
+
+ [Fact]
+ public async Task RunAsync_WithoutForwardedContext_PassesMessagesThroughAsync()
+ {
+ // Arrange
+ var inner = new RecordingAgent();
+ var agent = new AGUIContextAgent(inner);
+ var userMessage = new ChatMessage(ChatRole.User, "hi");
+
+ // Act
+ await agent.RunAsync([userMessage]);
+
+ // Assert: untouched — same single message instance, no system prefix.
+ Assert.NotNull(inner.LastMessages);
+ ChatMessage only = Assert.Single(inner.LastMessages!);
+ Assert.Same(userMessage, only);
+ }
+
+ [Fact]
+ public async Task RunAsync_WithEmptyContextEntries_DoesNotPrependAsync()
+ {
+ // Arrange: entries that render to an empty prompt must not produce an
+ // empty system message.
+ var inner = new RecordingAgent();
+ var agent = new AGUIContextAgent(inner);
+ var options = new ChatClientAgentRunOptions
+ {
+ ChatOptions = new ChatOptions
+ {
+ AdditionalProperties = new AdditionalPropertiesDictionary
+ {
+ ["ag_ui_context"] = Array.Empty>(),
+ },
+ },
+ };
+
+ // Act
+ await agent.RunAsync([new ChatMessage(ChatRole.User, "hi")], options: options);
+
+ // Assert
+ Assert.NotNull(inner.LastMessages);
+ ChatMessage only = Assert.Single(inner.LastMessages!);
+ Assert.Equal(ChatRole.User, only.Role);
+ }
+
+ /// An inner agent that records the messages it was run with.
+ private sealed class RecordingAgent : AIAgent
+ {
+ public List? LastMessages { get; private set; }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ this.LastMessages = messages.ToList();
+ return Task.FromResult(new AgentResponse());
+ }
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ this.LastMessages = messages.ToList();
+ await Task.CompletedTask.ConfigureAwait(false);
+ yield break;
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj
new file mode 100644
index 00000000000..58222be0571
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests/Microsoft.Agents.AI.AGUI.A2UI.UnitTests.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs
new file mode 100644
index 00000000000..8c9d9540f09
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/StreamingToolCallArgsTests.cs
@@ -0,0 +1,223 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
+using Microsoft.Extensions.AI;
+using OpenAI.Chat;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
+
+///
+/// Tests for progressive tool-call argument streaming: OpenAI streamed argument
+/// fragments observable on are
+/// surfaced as incremental s, and the coalesced
+/// that follows emits only the closing event.
+///
+public sealed class StreamingToolCallArgsTests
+{
+ private const string ThreadId = "thread1";
+ private const string RunId = "run1";
+ private const string CallId = "call_123";
+
+ private static readonly string[] s_sequentialCallIds = ["call_a", "call_b"];
+
+ private static ChatResponseUpdate FragmentUpdate(int index, string? callId, string? functionName, string argumentsDelta)
+ {
+ StreamingChatToolCallUpdate toolCallUpdate = OpenAIChatModelFactory.StreamingChatToolCallUpdate(
+ index: index,
+ toolCallId: callId,
+ functionName: functionName,
+ functionArgumentsUpdate: BinaryData.FromString(argumentsDelta));
+ StreamingChatCompletionUpdate rawUpdate = OpenAIChatModelFactory.StreamingChatCompletionUpdate(
+ toolCallUpdates: [toolCallUpdate]);
+ return new ChatResponseUpdate(ChatRole.Assistant, Array.Empty())
+ {
+ RawRepresentation = rawUpdate,
+ };
+ }
+
+ private static ChatResponseUpdate CoalescedFunctionCallUpdate(string callId, string functionName)
+ => new(ChatRole.Assistant, [new FunctionCallContent(callId, functionName, new Dictionary { ["city"] = "Paris" })]);
+
+ private static async Task> CollectAsync(IEnumerable updates)
+ {
+ List events = [];
+ await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(
+ ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None))
+ {
+ events.Add(evt);
+ }
+
+ return events;
+ }
+
+ [Fact]
+ public async Task AsAGUIEventStreamAsync_RawToolCallFragments_EmitIncrementalArgsAsync()
+ {
+ // Arrange: three fragments (first carries id+name) then the coalesced content.
+ List updates =
+ [
+ FragmentUpdate(0, CallId, "get_weather", "{\"ci"),
+ FragmentUpdate(0, callId: null, functionName: null, "ty\":\"Pa"),
+ FragmentUpdate(0, callId: null, functionName: null, "ris\"}"),
+ CoalescedFunctionCallUpdate(CallId, "get_weather"),
+ ];
+
+ // Act
+ List events = await CollectAsync(updates);
+
+ // Assert
+ ToolCallStartEvent start = Assert.Single(events.OfType());
+ Assert.Equal(CallId, start.ToolCallId);
+ Assert.Equal("get_weather", start.ToolCallName);
+
+ List args = events.OfType().ToList();
+ Assert.Equal(3, args.Count);
+ Assert.All(args, a => Assert.Equal(CallId, a.ToolCallId));
+ Assert.Equal("{\"city\":\"Paris\"}", string.Concat(args.Select(a => a.Delta)));
+
+ ToolCallEndEvent end = Assert.Single(events.OfType());
+ Assert.Equal(CallId, end.ToolCallId);
+ }
+
+ [Fact]
+ public async Task AsAGUIEventStreamAsync_CoalescedContentAfterFragments_DoesNotDuplicateArgsAsync()
+ {
+ // Arrange
+ List updates =
+ [
+ FragmentUpdate(0, CallId, "get_weather", "{\"city\":\"Paris\"}"),
+ CoalescedFunctionCallUpdate(CallId, "get_weather"),
+ ];
+
+ // Act
+ List events = await CollectAsync(updates);
+
+ // Assert: one Start, one Args (the fragment), one End — the coalesced
+ // FunctionCallContent must not re-emit the full arguments.
+ Assert.Single(events.OfType());
+ ToolCallArgsEvent argsEvent = Assert.Single(events.OfType());
+ Assert.Equal("{\"city\":\"Paris\"}", argsEvent.Delta);
+ Assert.Single(events.OfType());
+ }
+
+ [Fact]
+ public async Task AsAGUIEventStreamAsync_NoRawFragments_KeepsAtomicEmission()
+ {
+ // Arrange: providers without raw OpenAI fragments keep the existing behavior.
+ List updates = [CoalescedFunctionCallUpdate(CallId, "get_weather")];
+
+ // Act
+ List events = await CollectAsync(updates);
+
+ // Assert
+ Assert.Single(events.OfType());
+ ToolCallArgsEvent argsEvent = Assert.Single(events.OfType());
+ Assert.Contains("Paris", argsEvent.Delta);
+ Assert.Single(events.OfType