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()); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_ParallelToolCalls_StreamIndependentlyAsync() + { + // Arrange: two interleaved calls on distinct indexes. + List updates = + [ + FragmentUpdate(0, "call_a", "get_weather", "{\"city\":"), + FragmentUpdate(1, "call_b", "get_time", "{\"zone\":"), + FragmentUpdate(0, callId: null, functionName: null, "\"Paris\"}"), + FragmentUpdate(1, callId: null, functionName: null, "\"CET\"}"), + ]; + + // Act + List events = await CollectAsync(updates); + + // Assert: each call gets its own Start (no shared parent message collapsing + // the two calls into one bubble). + List starts = events.OfType().ToList(); + Assert.Equal(2, starts.Count); + Assert.Equal(s_sequentialCallIds, starts.Select(e => e.ToolCallId).ToArray()); + Assert.Equal( + "{\"city\":\"Paris\"}", + string.Concat(events.OfType().Where(a => a.ToolCallId == "call_a").Select(a => a.Delta))); + Assert.Equal( + "{\"zone\":\"CET\"}", + string.Concat(events.OfType().Where(a => a.ToolCallId == "call_b").Select(a => a.Delta))); + // Both calls were started on the wire, so both must close — here via the + // end-of-stream sweep, since no coalesced content ever arrives. The sweep + // promises deterministic tool-call index order, so assert the wire order. + Assert.Equal( + s_sequentialCallIds, + events.OfType().Select(e => e.ToolCallId).ToArray()); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_IndexReusedAcrossRounds_StartsANewCallAsync() + { + // Arrange: two sequential tool-call rounds in one stream; OpenAI restarts the + // fragment index at 0 for the second round. + List updates = + [ + FragmentUpdate(0, "call_a", "get_weather", "{\"city\":\"Paris\"}"), + CoalescedFunctionCallUpdate("call_a", "get_weather"), + FragmentUpdate(0, "call_b", "get_time", "{\"zone\":\"CET\"}"), + CoalescedFunctionCallUpdate("call_b", "get_time"), + ]; + + // Act + List events = await CollectAsync(updates); + + // Assert: each round gets its own Start, its args land on its own call id, and + // each call closes exactly once (no duplicate atomic re-emission). + Assert.Equal( + s_sequentialCallIds, + events.OfType().Select(e => e.ToolCallId).ToArray()); + Assert.Equal( + "{\"city\":\"Paris\"}", + string.Concat(events.OfType().Where(a => a.ToolCallId == "call_a").Select(a => a.Delta))); + Assert.Equal( + "{\"zone\":\"CET\"}", + string.Concat(events.OfType().Where(a => a.ToolCallId == "call_b").Select(a => a.Delta))); + Assert.Equal( + s_sequentialCallIds, + events.OfType().Select(e => e.ToolCallId).ToArray()); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_FragmentsWithoutCoalescedContent_CloseAtEndOfStreamAsync() + { + // Arrange: a raw-streamed call whose coalesced FunctionCallContent never arrives. + List updates = [FragmentUpdate(0, CallId, "get_weather", "{\"city\":\"Paris\"}")]; + + // Act + List events = await CollectAsync(updates); + + // Assert: the end-of-stream sweep closes the call before RunFinished. + ToolCallEndEvent end = Assert.Single(events.OfType()); + Assert.Equal(CallId, end.ToolCallId); + Assert.True( + events.FindIndex(e => e is ToolCallEndEvent) < events.FindIndex(e => e is RunFinishedEvent), + "ToolCallEnd must precede RunFinished"); + } + + [Fact] + public async Task AsAGUIEventStreamAsync_WrappedRawRepresentation_IsUnwrappedAsync() + { + // Arrange: agent pipelines wrap the provider update one level deep. + ChatResponseUpdate fragment = FragmentUpdate(0, CallId, "get_weather", "{}"); + var wrapped = new ChatResponseUpdate(ChatRole.Assistant, Array.Empty()) + { + RawRepresentation = fragment, + }; + + // Act + List events = await CollectAsync([wrapped]); + + // Assert + Assert.Single(events.OfType()); + } +}