diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 40c791159ba..7a3b5016385 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -123,6 +123,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -670,4 +698,3 @@
-
diff --git a/dotnet/samples/02-agents/AGUI/README.md b/dotnet/samples/02-agents/AGUI/README.md
index b0e724bf14e..14e290a649b 100644
--- a/dotnet/samples/02-agents/AGUI/README.md
+++ b/dotnet/samples/02-agents/AGUI/README.md
@@ -186,6 +186,82 @@ cd Step05_StateManagement/Server
dotnet run
```
+### Step06_WorkflowSequential
+
+A two-agent sequential workflow hosted over AG-UI. The client displays executor `STEP_STARTED` and
+`STEP_FINISHED` events alongside the writer and reviewer text.
+
+```bash
+cd Step06_WorkflowSequential
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
+
+### Step07_WorkflowConcurrent
+
+A concurrent workflow that runs a researcher and critic together. The client displays each executor's steps
+and both streamed responses.
+
+```bash
+cd Step07_WorkflowConcurrent
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
+
+### Step08_WorkflowFailure
+
+A deterministic failing workflow. The client shows the failed executor step closing before terminal
+`RUN_ERROR`.
+
+```bash
+cd Step08_WorkflowFailure
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
+
+### Step09_WorkflowTools
+
+A workflow containing a backend-tool-enabled agent. The client displays executor steps, tool calls, tool
+results, and text without duplication.
+
+```bash
+cd Step09_WorkflowTools
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
+
+### Step10_WorkflowNested
+
+Two parallel subworkflows whose local executor IDs are both `Analyze`. The sample characterizes the
+currently ambiguous AG-UI step names tracked by issue #7763.
+
+```bash
+cd Step10_WorkflowNested
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
+
+### Step11_WorkflowApproval
+
+An expense workflow that pauses with an AG-UI approval interruption and resumes on the same persisted thread.
+
+```bash
+cd Step11_WorkflowApproval
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
+
+### Step12_WorkflowMultipleInputs
+
+A travel workflow that emits three input interruptions in one turn, accepts partial and out-of-order
+responses, and completes after the remaining input arrives.
+
+```bash
+cd Step12_WorkflowMultipleInputs
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
+
The server runs on port 8888 by default.
#### Client (`Step05_StateManagement/Client`)
diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj
new file mode 100644
index 00000000000..24da3ebca35
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Client.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs
new file mode 100644
index 00000000000..382634936e6
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Client/Program.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.Abstractions;
+using AGUI.Client;
+using Microsoft.Extensions.AI;
+
+string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
+using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
+using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
+
+Console.Write("Request: ");
+string request = Console.ReadLine() ?? "Write a short welcome message for a developer conference.";
+
+await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, request)]))
+{
+ switch (update.RawRepresentation)
+ {
+ case StepStartedEvent started:
+ Console.WriteLine($"\n[Step started: {started.StepName}]");
+ break;
+ case StepFinishedEvent finished:
+ Console.WriteLine($"\n[Step finished: {finished.StepName}]");
+ break;
+ }
+
+ foreach (TextContent text in update.Contents.OfType())
+ {
+ Console.Write(text.Text);
+ }
+}
+
+Console.WriteLine();
+
+static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
+ => new AGUIChatClient(new(httpClient, serverUrl));
diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md
new file mode 100644
index 00000000000..a37ea78aa8f
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/README.md
@@ -0,0 +1,15 @@
+# Sequential Workflow over AG-UI
+
+This sample hosts a two-agent sequential workflow over AG-UI. The `Writer` drafts a response and the
+`Reviewer` produces the final answer. The client prints AG-UI step lifecycle events alongside streamed text.
+
+## Run
+
+Set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME`, then:
+
+```powershell
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
+
+Expected step events include `STEP_STARTED` and `STEP_FINISHED` for `Writer` followed by `Reviewer`.
diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs
new file mode 100644
index 00000000000..76979c8120f
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Program.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.WorkflowSequential;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+using Microsoft.Agents.AI.Workflows;
+using OpenAI.Chat;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+builder.Services.AddHttpClient().AddLogging();
+builder.Services.AddAGUIServer();
+
+string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
+
+ChatClient chatClient = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new DefaultAzureCredential())
+ .GetChatClient(deploymentName);
+
+AIAgent writer = chatClient.AsAIAgent(
+ name: "Writer",
+ instructions: "Draft a concise answer to the user's request.");
+AIAgent reviewer = chatClient.AsAIAgent(
+ name: "Reviewer",
+ instructions: "Review the draft and return an improved final answer.");
+
+AIAgent workflowAgent = SequentialWorkflow.Create(writer, reviewer).AsAIAgent(name: "SequentialWorkflow");
+
+WebApplication app = builder.Build();
+app.MapAGUIServer("/", workflowAgent);
+await app.RunAsync();
diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs
new file mode 100644
index 00000000000..ba461b3ea68
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/SequentialWorkflow.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+
+namespace AGUI.WorkflowSequential;
+
+///
+/// Creates the sequential workflow used by the sample and its integration test.
+///
+public static class SequentialWorkflow
+{
+ ///
+ /// Creates a workflow that asks one agent to draft content and another to review it.
+ ///
+ /// The writer agent.
+ /// The reviewer agent.
+ /// The sequential workflow.
+ public static Workflow Create(AIAgent writer, AIAgent reviewer)
+ => new SequentialWorkflowBuilder(writer, reviewer).Build();
+}
diff --git a/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Server.csproj
new file mode 100644
index 00000000000..43fbf4e354a
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step06_WorkflowSequential/Server/Server.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj
new file mode 100644
index 00000000000..24da3ebca35
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Client.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs
new file mode 100644
index 00000000000..0abbcebf1b0
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Client/Program.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.Abstractions;
+using AGUI.Client;
+using Microsoft.Extensions.AI;
+
+string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
+using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
+using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
+
+Console.Write("Request: ");
+string request = Console.ReadLine() ?? "Assess the tradeoffs of adopting a new framework.";
+
+await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, request)]))
+{
+ switch (update.RawRepresentation)
+ {
+ case StepStartedEvent started:
+ Console.WriteLine($"\n[Step started: {started.StepName}]");
+ break;
+ case StepFinishedEvent finished:
+ Console.WriteLine($"\n[Step finished: {finished.StepName}]");
+ break;
+ }
+
+ foreach (TextContent text in update.Contents.OfType())
+ {
+ Console.Write(text.Text);
+ }
+}
+
+Console.WriteLine();
+
+static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
+ => new AGUIChatClient(new(httpClient, serverUrl));
diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/README.md b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/README.md
new file mode 100644
index 00000000000..424ee5b64ae
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/README.md
@@ -0,0 +1,13 @@
+# Concurrent Workflow over AG-UI
+
+This sample runs a `Researcher` and `Critic` concurrently. The AG-UI client displays each executor's
+step lifecycle and the output from both agents.
+
+## Run
+
+Set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME`, then:
+
+```powershell
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/ConcurrentWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/ConcurrentWorkflow.cs
new file mode 100644
index 00000000000..1039fc23298
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/ConcurrentWorkflow.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+
+namespace AGUI.WorkflowConcurrent;
+
+///
+/// Creates the concurrent workflow used by the sample and its integration test.
+///
+public static class ConcurrentWorkflow
+{
+ ///
+ /// Creates a workflow that runs all supplied agents concurrently.
+ ///
+ /// The agents to run concurrently.
+ /// The concurrent workflow.
+ public static Workflow Create(params AIAgent[] agents)
+ => new ConcurrentWorkflowBuilder(agents).Build();
+}
diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs
new file mode 100644
index 00000000000..33063a37679
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Program.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.WorkflowConcurrent;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+using Microsoft.Agents.AI.Workflows;
+using OpenAI.Chat;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+builder.Services.AddHttpClient().AddLogging();
+builder.Services.AddAGUIServer();
+
+string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
+
+ChatClient chatClient = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new DefaultAzureCredential())
+ .GetChatClient(deploymentName);
+
+AIAgent researcher = chatClient.AsAIAgent(
+ name: "Researcher",
+ instructions: "Identify the important facts in the user's request.");
+AIAgent critic = chatClient.AsAIAgent(
+ name: "Critic",
+ instructions: "Identify risks and missing considerations in the user's request.");
+
+AIAgent workflowAgent = ConcurrentWorkflow.Create(researcher, critic).AsAIAgent(name: "ConcurrentWorkflow");
+
+WebApplication app = builder.Build();
+app.MapAGUIServer("/", workflowAgent);
+await app.RunAsync();
diff --git a/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Server.csproj
new file mode 100644
index 00000000000..43fbf4e354a
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step07_WorkflowConcurrent/Server/Server.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj
new file mode 100644
index 00000000000..24da3ebca35
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Client.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs
new file mode 100644
index 00000000000..2ad3d58cc2d
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Client/Program.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.Abstractions;
+using AGUI.Client;
+using Microsoft.Extensions.AI;
+
+string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
+using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
+using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
+
+await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "Run the failing workflow.")]))
+{
+ switch (update.RawRepresentation)
+ {
+ case StepStartedEvent started:
+ Console.WriteLine($"[Step started: {started.StepName}]");
+ break;
+ case StepFinishedEvent finished:
+ Console.WriteLine($"[Step finished: {finished.StepName}]");
+ break;
+ case RunErrorEvent error:
+ Console.WriteLine($"[Error: {error.Message}]");
+ break;
+ }
+}
+
+static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
+ => new AGUIChatClient(new(httpClient, serverUrl));
diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md
new file mode 100644
index 00000000000..4d80203d4c7
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/README.md
@@ -0,0 +1,11 @@
+# Failing Workflow over AG-UI
+
+This sample demonstrates an executor failure. The AG-UI stream emits `STEP_FINISHED` for the failed executor
+and then terminates with `RUN_ERROR`; it does not append a successful `RUN_FINISHED`.
+
+## Run
+
+```powershell
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs
new file mode 100644
index 00000000000..5ff7252e1cf
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/FailingWorkflow.cs
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace AGUI.WorkflowFailure;
+
+///
+/// Creates the failing workflow used by the sample and its integration test.
+///
+public static class FailingWorkflow
+{
+ ///
+ /// Creates a workflow containing the supplied failing agent.
+ ///
+ /// The agent that fails during execution.
+ /// The failing workflow.
+ public static Workflow Create(AIAgent agent)
+ => new SequentialWorkflowBuilder(agent).Build();
+}
+
+///
+/// An agent that throws a deterministic exception for the failure sample.
+///
+public sealed class FailingAgent : AIAgent
+{
+ ///
+ public override string? Name => "FailingStep";
+
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => throw new InvalidOperationException("The sample executor failed.");
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ yield return new AgentResponseUpdate(ChatRole.Assistant, "Starting work before failure.")
+ {
+ MessageId = "failure-message",
+ ResponseId = "failure-response",
+ };
+ await Task.Yield();
+ throw new InvalidOperationException("The sample executor failed.");
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => new(new FailingSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(JsonSerializer.SerializeToElement(new Dictionary()));
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(new FailingSession());
+
+ private sealed class FailingSession : AgentSession;
+}
diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs
new file mode 100644
index 00000000000..879716a34f1
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Program.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.WorkflowFailure;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+using Microsoft.Agents.AI.Workflows;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+builder.Services.AddHttpClient().AddLogging();
+builder.Services.AddAGUIServer();
+
+AIAgent workflowAgent = FailingWorkflow.Create(new FailingAgent()).AsAIAgent(name: "FailingWorkflow");
+
+WebApplication app = builder.Build();
+app.MapAGUIServer("/", workflowAgent);
+await app.RunAsync();
diff --git a/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Server.csproj
new file mode 100644
index 00000000000..757b0cf7a57
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step08_WorkflowFailure/Server/Server.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj
new file mode 100644
index 00000000000..24da3ebca35
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Client.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs
new file mode 100644
index 00000000000..c872e2547f0
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Client/Program.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.Abstractions;
+using AGUI.Client;
+using Microsoft.Extensions.AI;
+
+string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
+using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
+using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
+
+await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "What is the weather in Seattle?")]))
+{
+ switch (update.RawRepresentation)
+ {
+ case StepStartedEvent started:
+ Console.WriteLine($"\n[Step started: {started.StepName}]");
+ break;
+ case StepFinishedEvent finished:
+ Console.WriteLine($"\n[Step finished: {finished.StepName}]");
+ break;
+ }
+
+ foreach (AIContent content in update.Contents)
+ {
+ switch (content)
+ {
+ case FunctionCallContent call:
+ Console.WriteLine($"\n[Tool call: {call.Name}]");
+ break;
+ case FunctionResultContent result:
+ Console.WriteLine($"\n[Tool result: {result.Result}]");
+ break;
+ case TextContent text:
+ Console.Write(text.Text);
+ break;
+ }
+ }
+}
+
+Console.WriteLine();
+
+static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
+ => new AGUIChatClient(new(httpClient, serverUrl));
diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/README.md b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/README.md
new file mode 100644
index 00000000000..80487b32e0d
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/README.md
@@ -0,0 +1,13 @@
+# Tool-enabled Workflow over AG-UI
+
+This sample hosts a workflow containing an agent with a backend weather tool. Executor steps, tool calls,
+tool results, and text all travel through the same AG-UI stream.
+
+## Run
+
+Set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME`, then:
+
+```powershell
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs
new file mode 100644
index 00000000000..c029c894762
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Program.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ComponentModel;
+using AGUI.WorkflowTools;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+using OpenAI.Chat;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+builder.Services.AddHttpClient().AddLogging();
+builder.Services.AddAGUIServer();
+
+string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
+
+[Description("Gets a deterministic weather forecast for a city.")]
+static string GetWeather([Description("The city to inspect.")] string city)
+ => $"The weather in {city} is sunny and 24 C.";
+
+ChatClient chatClient = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new DefaultAzureCredential())
+ .GetChatClient(deploymentName);
+
+AIAgent weatherAgent = chatClient.AsAIAgent(
+ name: "WeatherAgent",
+ instructions: "Use the weather tool and answer with the returned forecast.",
+ tools: [AIFunctionFactory.Create(GetWeather)]);
+AIAgent workflowAgent = ToolWorkflow.Create(weatherAgent).AsAIAgent(name: "ToolWorkflow");
+
+WebApplication app = builder.Build();
+app.MapAGUIServer("/", workflowAgent);
+await app.RunAsync();
diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Server.csproj
new file mode 100644
index 00000000000..43fbf4e354a
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/Server.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/ToolWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/ToolWorkflow.cs
new file mode 100644
index 00000000000..f4f6df1afe6
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step09_WorkflowTools/Server/ToolWorkflow.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+
+namespace AGUI.WorkflowTools;
+
+///
+/// Creates the tool-enabled workflow used by the sample and its integration test.
+///
+public static class ToolWorkflow
+{
+ ///
+ /// Creates a workflow containing the supplied tool-enabled agent.
+ ///
+ /// The tool-enabled agent.
+ /// The workflow.
+ public static Workflow Create(AIAgent agent)
+ => new SequentialWorkflowBuilder(agent).Build();
+}
diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj
new file mode 100644
index 00000000000..24da3ebca35
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Client.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs
new file mode 100644
index 00000000000..21e3d6bf0fd
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Client/Program.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.Abstractions;
+using AGUI.Client;
+using Microsoft.Extensions.AI;
+
+string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
+using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
+using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
+
+try
+{
+ await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, "Review this proposal.")]))
+ {
+ switch (update.RawRepresentation)
+ {
+ case StepStartedEvent started:
+ Console.WriteLine($"\n[Step started: {started.StepName}]");
+ break;
+ case StepFinishedEvent finished:
+ Console.WriteLine($"\n[Step finished: {finished.StepName}]");
+ break;
+ }
+
+ foreach (TextContent text in update.Contents.OfType())
+ {
+ Console.Write(text.Text);
+ }
+ }
+}
+catch (InvalidOperationException exception)
+{
+ Console.WriteLine($"\n[Known nested-step identity limitation (#7763): {exception.Message}]");
+}
+
+Console.WriteLine();
+
+static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
+ => new AGUIChatClient(new(httpClient, serverUrl));
diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/README.md b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/README.md
new file mode 100644
index 00000000000..6b55873acd9
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/README.md
@@ -0,0 +1,15 @@
+# Parallel Nested Workflows over AG-UI
+
+This sample runs `SecurityPipeline` and `StylePipeline` as parallel subworkflows. Each subworkflow contains
+a locally scoped executor named `Analyze`.
+
+The client currently receives duplicate `Analyze_Analyze` step names because nested executor lifecycle events
+do not carry their parent workflow scope. AG-UI rejects the second overlapping `STEP_STARTED` as already active.
+The client reports this known limitation, which is tracked by issue #7763.
+
+## Run
+
+```powershell
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs
new file mode 100644
index 00000000000..ec38137c992
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/NestedWorkflow.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace AGUI.WorkflowNested;
+
+///
+/// Creates parallel nested workflows with duplicate local executor IDs.
+///
+public static class NestedWorkflow
+{
+ ///
+ /// Creates the parent workflow.
+ ///
+ /// A parent workflow containing security and style subworkflows.
+ public static Workflow Create()
+ {
+ AnalysisGate gate = new();
+ ChatForwardingExecutor start = new("Start");
+ ExecutorBinding security = CreateAnalysisWorkflow("Security", gate).BindAsExecutor("SecurityPipeline");
+ ExecutorBinding style = CreateAnalysisWorkflow("Style", gate).BindAsExecutor("StylePipeline");
+
+ return new WorkflowBuilder(start)
+ .AddFanOutEdge(start, [security, style])
+ .WithOutputFrom(security, style)
+ .Build();
+ }
+
+ private static Workflow CreateAnalysisWorkflow(string analysisType, AnalysisGate gate)
+ => new SequentialWorkflowBuilder(new AnalysisAgent(analysisType, gate)).Build();
+}
+
+internal sealed class AnalysisAgent(string analysisType, AnalysisGate gate) : AIAgent
+{
+ protected override string? IdCore => "Analyze";
+
+ public override string? Name => "Analyze";
+
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => Task.FromResult(new AgentResponse(
+ new ChatMessage(ChatRole.Assistant, $"{analysisType} analysis complete.")));
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await gate.SignalAndWaitAsync(cancellationToken).ConfigureAwait(false);
+ yield return new AgentResponseUpdate(ChatRole.Assistant, $"{analysisType} analysis complete.")
+ {
+ MessageId = $"{analysisType}-message",
+ ResponseId = $"{analysisType}-response",
+ };
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => new(new AnalysisSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(JsonSerializer.SerializeToElement(new Dictionary()));
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(new AnalysisSession());
+
+ private sealed class AnalysisSession : AgentSession;
+}
+
+internal sealed class AnalysisGate
+{
+ private readonly TaskCompletionSource _bothStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private int _startedCount;
+
+ public async Task SignalAndWaitAsync(CancellationToken cancellationToken)
+ {
+ if (Interlocked.Increment(ref this._startedCount) == 2)
+ {
+ this._bothStarted.TrySetResult();
+ }
+
+ await this._bothStarted.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs
new file mode 100644
index 00000000000..74d39b42316
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Program.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.WorkflowNested;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+using Microsoft.Agents.AI.Workflows;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+builder.Services.AddHttpClient().AddLogging();
+builder.Services.AddAGUIServer();
+
+AIAgent workflowAgent = NestedWorkflow.Create().AsAIAgent(
+ name: "NestedWorkflow",
+ includeWorkflowOutputsInResponse: true);
+
+WebApplication app = builder.Build();
+app.MapAGUIServer("/", workflowAgent);
+await app.RunAsync();
diff --git a/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Server.csproj
new file mode 100644
index 00000000000..757b0cf7a57
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step10_WorkflowNested/Server/Server.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj
new file mode 100644
index 00000000000..24da3ebca35
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Client.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs
new file mode 100644
index 00000000000..fdfac1c4d68
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Client/Program.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using AGUI.Client;
+using Microsoft.Extensions.AI;
+
+string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
+using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
+using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
+ChatOptions options = new();
+
+var expenseReport = new
+{
+ id = "EXP-100",
+ employee = "Taylor",
+ amount = 125.00m,
+ businessPurpose = "Developer conference registration",
+ receiptAttached = true,
+};
+string reportJson = JsonSerializer.Serialize(expenseReport);
+
+List firstTurn = await chatClient
+ .GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, $"Review and submit this expense report:\n{reportJson}")],
+ options)
+ .ToListAsync();
+
+#pragma warning disable MEAI001 // Tool approval content is experimental.
+ToolApprovalRequestContent approvalRequest = firstTurn
+ .SelectMany(static update => update.Contents)
+ .OfType()
+ .Single();
+FunctionCallContent toolCall = (FunctionCallContent)approvalRequest.ToolCall;
+
+Console.WriteLine($"The workflow completed its checks and wants to call {toolCall.Name}.");
+Console.Write($"Approve submission of expense {expenseReport.id}? [y/N]: ");
+bool approved = string.Equals(Console.ReadLine(), "y", StringComparison.OrdinalIgnoreCase);
+ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(
+ approved,
+ approved ? "Approved by the sample user." : "Rejected by the sample user.");
+
+List approvalMessages =
+[
+ new(ChatRole.Assistant, [approvalRequest]),
+ new(ChatRole.Tool, [approvalResponse]),
+];
+
+await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(approvalMessages, options))
+{
+ foreach (TextContent text in update.Contents.OfType())
+ {
+ Console.Write(text.Text);
+ }
+}
+#pragma warning restore MEAI001
+
+Console.WriteLine();
+
+static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
+ => new AGUIChatClient(new(httpClient, serverUrl));
diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md
new file mode 100644
index 00000000000..073b148ae90
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/README.md
@@ -0,0 +1,18 @@
+# Approval Workflow over AG-UI
+
+This sample hosts a workflow containing one expense-review agent. The agent checks that the report has a
+business purpose, an attached receipt, a positive amount no greater than 500 USD, and a plausible business
+expense. If every check passes, it calls an approval-required `SubmitExpense` tool.
+
+The AG-UI client sends the expense report, receives `ToolApprovalRequestContent`, creates the paired
+`ToolApprovalResponseContent`, and resumes the same workflow thread through `IChatClient`.
+
+The sample uses an in-memory session store without user isolation for local demonstration only. Production
+hosts must isolate persisted sessions by authenticated principal.
+
+## Run
+
+```powershell
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs
new file mode 100644
index 00000000000..e0f324af8f0
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/ApprovalWorkflow.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+
+namespace AGUI.WorkflowApproval;
+
+///
+/// Creates the approval workflow used by the sample and its integration test.
+///
+public static class ApprovalWorkflow
+{
+ ///
+ /// Creates a workflow containing one expense-review agent.
+ ///
+ /// The agent that checks and submits expense reports.
+ /// The expense approval workflow.
+ public static Workflow Create(AIAgent expenseReviewer)
+ => new SequentialWorkflowBuilder(expenseReviewer).Build();
+}
+
+///
+/// An expense report submitted to the workflow.
+///
+/// The report identifier.
+/// The submitting employee.
+/// The total expense amount.
+/// The business purpose.
+/// Whether a receipt is attached.
+public sealed record ExpenseReport(
+ string Id,
+ string Employee,
+ decimal Amount,
+ string BusinessPurpose,
+ bool ReceiptAttached);
diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs
new file mode 100644
index 00000000000..7a556bc2ab6
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Program.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ComponentModel;
+using AGUI.WorkflowApproval;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting;
+using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+using OpenAI.Chat;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+builder.Services.AddHttpClient().AddLogging();
+builder.Services.AddAGUIServer();
+
+string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
+ ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
+
+[Description("Submits an expense report after the user approves the operation.")]
+static string SubmitExpense(ExpenseReport report)
+ => $"Expense report {report.Id} for {report.Employee} was submitted.";
+
+#pragma warning disable MEAI001 // ApprovalRequiredAIFunction is experimental.
+AITool submitExpense = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SubmitExpense));
+#pragma warning restore MEAI001
+
+ChatClient chatClient = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new DefaultAzureCredential())
+ .GetChatClient(deploymentName);
+
+AIAgent expenseReviewer = chatClient.AsAIAgent(
+ name: "ExpenseReviewer",
+ instructions: """
+ Review the expense report supplied by the user. Perform every check below:
+ 1. The report has a non-empty business purpose.
+ 2. A receipt is attached.
+ 3. The amount is positive and no greater than 500 USD.
+ 4. The expense is plausibly business-related.
+
+ If any check fails, explain every failed check and do not call SubmitExpense.
+ If all checks pass, call SubmitExpense with the complete report. The tool requires user approval.
+ """,
+ tools: [submitExpense]);
+
+Workflow workflow = ApprovalWorkflow.Create(expenseReviewer);
+AIAgent workflowAgent = workflow.AsAIAgent(name: "ApprovalWorkflow");
+
+builder.Services.AddAIAgent("ApprovalWorkflow", (_, _) => workflowAgent)
+ .WithInMemorySessionStore(withIsolation: false);
+
+WebApplication app = builder.Build();
+app.MapAGUIServer("ApprovalWorkflow", "/");
+await app.RunAsync();
diff --git a/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj
new file mode 100644
index 00000000000..43fbf4e354a
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step11_WorkflowApproval/Server/Server.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj
new file mode 100644
index 00000000000..24da3ebca35
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Client.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs
new file mode 100644
index 00000000000..9c6de6ba573
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Client/Program.cs
@@ -0,0 +1,67 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using AGUI.Abstractions;
+using AGUI.Client;
+using Microsoft.Extensions.AI;
+
+string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
+using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
+using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
+
+List firstTurn = await chatClient
+ .GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Plan my conference trip.")])
+ .ToListAsync();
+RunFinishedEvent firstFinished = firstTurn.Select(static update => update.RawRepresentation)
+ .OfType()
+ .Single();
+AGUIInterrupt[] requests = [.. ((RunFinishedInterruptOutcome)firstFinished.Outcome!).Interrupts];
+
+AGUIInterrupt dates = requests.Single(static item => item.Message!.Contains("TravelDates"));
+AGUIInterrupt travelers = requests.Single(static item => item.Message!.Contains("TravelerDetails"));
+AGUIInterrupt preferences = requests.Single(static item => item.Message!.Contains("TravelPreferences"));
+
+List partialTurn = await chatClient.GetStreamingResponseAsync(
+ [],
+ CreateResumeOptions(
+ firstFinished,
+ [
+ Resume(travelers, new { kind = "travelers", count = 2, accessibility = "none" }),
+ Resume(dates, new { kind = "dates", departure = "2026-10-10", returnDate = "2026-10-14" }),
+ ])).ToListAsync();
+RunFinishedEvent partialFinished = partialTurn.Select(static update => update.RawRepresentation)
+ .OfType()
+ .Single();
+
+List finalTurn = await chatClient.GetStreamingResponseAsync(
+ [],
+ CreateResumeOptions(
+ partialFinished,
+ [Resume(preferences, new { kind = "preferences", budget = 2500, cabin = "economy", hotel = "downtown" })]))
+ .ToListAsync();
+
+Console.WriteLine(string.Concat(finalTurn.Select(static update => update.Text)));
+
+ChatOptions CreateResumeOptions(RunFinishedEvent previous, IList resumes)
+ => new()
+ {
+ RawRepresentationFactory = _ => new RunAgentInput
+ {
+ Messages = [],
+ ParentRunId = previous.RunId,
+ Resume = resumes,
+ RunId = Guid.NewGuid().ToString("N"),
+ ThreadId = previous.ThreadId,
+ },
+ };
+
+static AGUIResume Resume(AGUIInterrupt interrupt, object payload)
+ => new()
+ {
+ InterruptId = interrupt.Id,
+ Payload = JsonSerializer.SerializeToElement(payload),
+ Status = "resolved",
+ };
+
+static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
+ => new AGUIChatClient(new(httpClient, serverUrl));
diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/README.md b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/README.md
new file mode 100644
index 00000000000..ca34322130f
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/README.md
@@ -0,0 +1,15 @@
+# Multiple Workflow Inputs over AG-UI
+
+This travel-planning workflow requests dates, traveler details, and travel preferences in the same turn.
+The client responds to two requests out of order, then submits the remaining preference response in a later
+continuation. The workflow produces its final result only after all three inputs are available.
+
+The sample uses an in-memory session store without user isolation for local demonstration only. Production
+hosts must isolate persisted sessions by authenticated principal.
+
+## Run
+
+```powershell
+dotnet run --project Server --urls http://localhost:8888
+dotnet run --project Client
+```
diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/MultipleInputsWorkflow.cs b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/MultipleInputsWorkflow.cs
new file mode 100644
index 00000000000..ec53c12457d
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/MultipleInputsWorkflow.cs
@@ -0,0 +1,102 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace AGUI.WorkflowMultipleInputs;
+
+///
+/// Creates the multiple-input travel workflow used by the sample and its integration test.
+///
+public static class MultipleInputsWorkflow
+{
+ ///
+ /// Creates a workflow that requests three independent pieces of travel information.
+ ///
+ /// The travel-planning workflow.
+ public static Workflow Create()
+ {
+ TravelPlannerExecutor executor = new();
+ return new WorkflowBuilder(executor)
+ .AddExternalCall(executor, "TravelDates")
+ .AddExternalCall(executor, "TravelerDetails")
+ .AddExternalCall(executor, "TravelPreferences")
+ .WithOutputFrom(executor)
+ .Build();
+ }
+}
+
+/// Requests departure and return dates.
+public sealed record TravelDatesRequest(string Destination);
+
+/// Requests traveler count and accessibility needs.
+public sealed record TravelerDetailsRequest(string Destination);
+
+/// Requests budget, cabin, and hotel preferences.
+public sealed record TravelPreferencesRequest(string Destination);
+
+[SendsMessage(typeof(TravelDatesRequest))]
+[SendsMessage(typeof(TravelerDetailsRequest))]
+[SendsMessage(typeof(TravelPreferencesRequest))]
+internal sealed partial class TravelPlannerExecutor()
+ : ChatProtocolExecutor("TravelPlanner", new ChatProtocolExecutorOptions { AutoSendTurnToken = false })
+{
+ private const string StateScope = "TravelInputs";
+ private static readonly string[] s_inputKinds = ["dates", "travelers", "preferences"];
+
+ protected override async ValueTask TakeTurnAsync(
+ List messages,
+ IWorkflowContext context,
+ bool? emitEvents,
+ CancellationToken cancellationToken = default)
+ {
+ const string Destination = "Seattle";
+ await context.SendMessageAsync(new TravelDatesRequest(Destination), cancellationToken).ConfigureAwait(false);
+ await context.SendMessageAsync(new TravelerDetailsRequest(Destination), cancellationToken).ConfigureAwait(false);
+ await context.SendMessageAsync(new TravelPreferencesRequest(Destination), cancellationToken).ConfigureAwait(false);
+ }
+
+ [MessageHandler]
+ public async ValueTask HandleInputAsync(
+ JsonElement response,
+ IWorkflowContext context,
+ CancellationToken cancellationToken = default)
+ {
+ string kind = response.GetProperty("kind").GetString()
+ ?? throw new InvalidOperationException("The response kind is required.");
+ await context.QueueStateUpdateAsync(kind, response.Clone(), StateScope, cancellationToken).ConfigureAwait(false);
+
+ string[] otherKinds = [.. s_inputKinds.Where(candidate => candidate != kind)];
+ bool allOtherInputsAvailable = true;
+ foreach (string otherKind in otherKinds)
+ {
+ JsonElement? value = await context.ReadStateAsync(
+ otherKind,
+ StateScope,
+ cancellationToken).ConfigureAwait(false);
+ allOtherInputsAvailable &= value.HasValue;
+ }
+
+ if (!allOtherInputsAvailable)
+ {
+ return;
+ }
+
+ AgentResponseUpdate update = new(
+ ChatRole.Assistant,
+ "Travel plan ready for Seattle using all requested dates, traveler details, and preferences.")
+ {
+ MessageId = "travel-plan",
+ ResponseId = "travel-response",
+ };
+ await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
+ await context.SendMessageAsync(new TurnToken(false), cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs
new file mode 100644
index 00000000000..6a7998ae368
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Program.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using AGUI.WorkflowMultipleInputs;
+using Microsoft.Agents.AI.Hosting;
+using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+using Microsoft.Agents.AI.Workflows;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+builder.Services.AddHttpClient().AddLogging();
+builder.Services.AddAGUIServer();
+builder.Services.AddAIAgent(
+ "MultipleInputsWorkflow",
+ static (_, _) => MultipleInputsWorkflow.Create().AsAIAgent(name: "MultipleInputsWorkflow"))
+ .WithInMemorySessionStore(withIsolation: false);
+
+WebApplication app = builder.Build();
+app.MapAGUIServer("MultipleInputsWorkflow", "/");
+await app.RunAsync();
diff --git a/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Server.csproj
new file mode 100644
index 00000000000..6d6151d24c4
--- /dev/null
+++ b/dotnet/samples/02-agents/AGUI/Step12_WorkflowMultipleInputs/Server/Server.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
index 348d4a86625..99d227ba879 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
@@ -147,14 +147,18 @@ public static IEndpointConventionBuilder MapAGUIServer(
var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false);
+ IEnumerable messages = ctx.Messages.MapAGUIInterruptResponsesToFunctionResults();
+
var events = hostAgent
.RunStreamingAsync(
- ctx.Messages,
+ messages,
session: session,
options: new ChatClientAgentRunOptions { ChatOptions = ctx.ChatOptions },
cancellationToken: cancellationToken)
.AsChatResponseUpdatesAsync()
- .AsAGUIEventStreamAsync(ctx, cancellationToken);
+ .MapWorkflowEventsToAGUI()
+ .AsAGUIEventStreamAsync(ctx, cancellationToken)
+ .MakeRunErrorTerminalAsync();
// Wrap the event stream to save the session after streaming completes.
var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken);
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs
index 27a23ed4d62..1a06e5436e9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ConfigureAGUIJsonOptions.cs
@@ -22,5 +22,6 @@ public void Configure(JsonOptions options)
// configured ASP.NET Core JsonSerializerOptions).
chain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
chain.Add(AGUIJsonSerializerContext.Default.Options.TypeInfoResolver!);
+ AGUIJsonUtilities.RegisterInterruptContentTypes(options.SerializerOptions);
}
}
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 3a46871daad..7303e976938 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
@@ -19,6 +19,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs
new file mode 100644
index 00000000000..de976298724
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/WorkflowAGUIExtensions.cs
@@ -0,0 +1,254 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using AGUI.Abstractions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
+
+internal static class WorkflowAGUIExtensions
+{
+#pragma warning disable VSTHRD200 // The name describes a stream transformation, consistent with the requested pipeline.
+ internal static async IAsyncEnumerable MapWorkflowEventsToAGUI(
+ this IAsyncEnumerable updates)
+ {
+ ArgumentNullException.ThrowIfNull(updates);
+ List interruptions = [];
+ List uncorrelatedApprovalRequests = [];
+
+ await foreach (ChatResponseUpdate update in updates.ConfigureAwait(false))
+ {
+ switch (update.RawRepresentation)
+ {
+ case AgentResponseUpdate { RawRepresentation: RequestInfoEvent }
+ when update.Contents.OfType().SingleOrDefault() is { } approvalRequest:
+ uncorrelatedApprovalRequests.RemoveAll(candidate =>
+ candidate.Contents.OfType().Any(candidateRequest =>
+ candidateRequest.ToolCall.CallId == approvalRequest.ToolCall.CallId));
+ yield return update;
+ break;
+
+ case AgentResponseUpdate { RawRepresentation: RequestInfoEvent requestInfo }
+ when update.Contents.OfType().SingleOrDefault() is { } request:
+ update.Contents =
+ [
+ new InterruptRequestContent(requestInfo.Request.RequestId)
+ {
+ Message = $"Input required for {request.Name}.",
+ Reason = InterruptReasons.InputRequired,
+ ToolCallId = request.CallId,
+ },
+ ];
+ update.RawRepresentation = null;
+ interruptions.Add(update);
+ break;
+
+ case AgentResponseUpdate { RawRepresentation: ExecutorInvokedEvent invoked }:
+ update.RawRepresentation = new StepStartedEvent { StepName = invoked.ExecutorId };
+ yield return update;
+ break;
+
+ case AgentResponseUpdate { RawRepresentation: ExecutorCompletedEvent completed }:
+ update.RawRepresentation = new StepFinishedEvent { StepName = completed.ExecutorId };
+ yield return update;
+ break;
+
+ case AgentResponseUpdate { RawRepresentation: ExecutorFailedEvent failed }:
+ yield return CreateEventUpdate(
+ update,
+ new StepFinishedEvent { StepName = failed.ExecutorId },
+ includeContents: false);
+ yield return CreateEventUpdate(
+ update,
+ new RunErrorEvent
+ {
+ Message = update.Contents.OfType().SingleOrDefault()?.Message
+ ?? "An error occurred while executing the workflow.",
+ },
+ includeContents: false);
+ break;
+
+ case var _ when update.Contents.OfType().Any():
+ uncorrelatedApprovalRequests.Add(update);
+ break;
+
+ default:
+ yield return update;
+ break;
+ }
+ }
+
+ foreach (ChatResponseUpdate interruption in interruptions)
+ {
+ yield return interruption;
+ }
+
+ foreach (ChatResponseUpdate approvalRequest in uncorrelatedApprovalRequests)
+ {
+ yield return approvalRequest;
+ }
+ }
+#pragma warning restore VSTHRD200
+
+ // TODO: Remove this adapter after consuming an AG-UI .NET release containing
+ // https://github.com/ag-ui-protocol/ag-ui/pull/2455, which makes RUN_ERROR terminal
+ // and prevents the SDK from appending RUN_FINISHED(success).
+ internal static async IAsyncEnumerable MakeRunErrorTerminalAsync(
+ this IAsyncEnumerable events)
+ {
+ ArgumentNullException.ThrowIfNull(events);
+ RunErrorEvent? runError = null;
+ List activeSteps = [];
+ List activeTextMessages = [];
+ List activeToolCalls = [];
+ List activeReasoning = [];
+ List activeReasoningMessages = [];
+
+ await foreach (BaseEvent evt in events.ConfigureAwait(false))
+ {
+ if (runError is null)
+ {
+ if (evt is RunErrorEvent error)
+ {
+ runError = error;
+ }
+ else
+ {
+ TrackLifecycle(evt);
+ yield return evt;
+ }
+ }
+ else if (IsMatchingClosure(evt))
+ {
+ yield return evt;
+ }
+ }
+
+ if (runError is not null)
+ {
+ foreach (string toolCallId in activeToolCalls)
+ {
+ yield return new ToolCallEndEvent { ToolCallId = toolCallId };
+ }
+
+ foreach (string messageId in activeTextMessages)
+ {
+ yield return new TextMessageEndEvent { MessageId = messageId };
+ }
+
+ foreach (string messageId in activeReasoningMessages)
+ {
+ yield return new ReasoningMessageEndEvent { MessageId = messageId };
+ }
+
+ foreach (string messageId in activeReasoning)
+ {
+ yield return new ReasoningEndEvent { MessageId = messageId };
+ }
+
+ foreach (string stepName in activeSteps)
+ {
+ yield return new StepFinishedEvent { StepName = stepName };
+ }
+
+ yield return runError;
+ }
+
+ void TrackLifecycle(BaseEvent evt)
+ {
+ switch (evt)
+ {
+ case StepStartedEvent started:
+ Add(activeSteps, started.StepName);
+ break;
+ case StepFinishedEvent finished:
+ activeSteps.Remove(finished.StepName);
+ break;
+ case TextMessageStartEvent started:
+ Add(activeTextMessages, started.MessageId);
+ break;
+ case TextMessageEndEvent finished:
+ activeTextMessages.Remove(finished.MessageId);
+ break;
+ case ToolCallStartEvent started:
+ Add(activeToolCalls, started.ToolCallId);
+ break;
+ case ToolCallEndEvent finished:
+ activeToolCalls.Remove(finished.ToolCallId);
+ break;
+ case ReasoningStartEvent started:
+ Add(activeReasoning, started.MessageId);
+ break;
+ case ReasoningEndEvent finished:
+ activeReasoning.Remove(finished.MessageId);
+ break;
+ case ReasoningMessageStartEvent started:
+ Add(activeReasoningMessages, started.MessageId);
+ break;
+ case ReasoningMessageEndEvent finished:
+ activeReasoningMessages.Remove(finished.MessageId);
+ break;
+ }
+ }
+
+ bool IsMatchingClosure(BaseEvent evt)
+ => evt switch
+ {
+ StepFinishedEvent finished => activeSteps.Remove(finished.StepName),
+ TextMessageEndEvent finished => activeTextMessages.Remove(finished.MessageId),
+ ToolCallEndEvent finished => activeToolCalls.Remove(finished.ToolCallId),
+ ReasoningEndEvent finished => activeReasoning.Remove(finished.MessageId),
+ ReasoningMessageEndEvent finished => activeReasoningMessages.Remove(finished.MessageId),
+ _ => false,
+ };
+
+ static void Add(List activeItems, string id)
+ {
+ if (!activeItems.Contains(id, StringComparer.Ordinal))
+ {
+ activeItems.Add(id);
+ }
+ }
+ }
+
+ private static ChatResponseUpdate CreateEventUpdate(
+ ChatResponseUpdate update,
+ BaseEvent evt,
+ bool includeContents = true)
+ => new()
+ {
+ AdditionalProperties = update.AdditionalProperties,
+ AuthorName = update.AuthorName,
+ Contents = includeContents ? update.Contents : [],
+ CreatedAt = update.CreatedAt,
+ FinishReason = update.FinishReason,
+ MessageId = update.MessageId,
+ RawRepresentation = evt,
+ ResponseId = update.ResponseId,
+ Role = update.Role,
+ ContinuationToken = update.ContinuationToken,
+ };
+
+ internal static List MapAGUIInterruptResponsesToFunctionResults(
+ this IEnumerable messages)
+ => [.. messages.Select(static message =>
+ {
+ AIContent[] contents = [.. message.Contents.Select(static content =>
+ content is InterruptResponseContent response
+ ? new FunctionResultContent(response.RequestId, response.Payload)
+ : content)];
+
+ return new ChatMessage(message.Role, contents)
+ {
+ AdditionalProperties = message.AdditionalProperties,
+ AuthorName = message.AuthorName,
+ CreatedAt = message.CreatedAt,
+ MessageId = message.MessageId,
+ RawRepresentation = message.RawRepresentation,
+ };
+ })];
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj
index 019f0eb7a16..c0cf1534c60 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj
@@ -27,6 +27,24 @@
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ApprovalWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ApprovalWorkflowTests.cs
new file mode 100644
index 00000000000..4df4139c60f
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ApprovalWorkflowTests.cs
@@ -0,0 +1,132 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using AGUI.Client;
+using AGUI.WorkflowApproval;
+using FluentAssertions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows;
+
+public sealed class ApprovalWorkflowTests
+{
+ [Fact]
+ public async Task ClientApprovesToolRequestAndWorkflowResumesAsync()
+ {
+ // Arrange
+ Workflow workflow = ApprovalWorkflow.Create(new DeterministicExpenseReviewer());
+ AIAgent workflowAgent = workflow.AsAIAgent(name: "ApprovalWorkflow");
+ await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(workflowAgent, persistSession: true);
+ using AGUIChatClient chatClient = new(new(host.Client, ""));
+ ChatOptions options = new();
+ ExpenseReport report = new(
+ "EXP-100",
+ "Taylor",
+ 125.00m,
+ "Developer conference registration",
+ ReceiptAttached: true);
+
+ // Act - the reviewer requests approval to submit the report.
+ List firstTurn = await chatClient
+ .GetStreamingResponseAsync(
+ [new ChatMessage(ChatRole.User, JsonSerializer.Serialize(report))],
+ options)
+ .ToListAsync();
+
+#pragma warning disable MEAI001 // Tool approval content is experimental.
+ ToolApprovalRequestContent approvalRequest = firstTurn
+ .SelectMany(static update => update.Contents)
+ .OfType()
+ .Single();
+ FunctionCallContent toolCall = approvalRequest.ToolCall.Should()
+ .BeOfType().Subject;
+ toolCall.Name.Should().Be("SubmitExpense");
+
+ ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(
+ approved: true,
+ reason: "Approved by integration test.");
+ List approvalMessages =
+ [
+ new(ChatRole.Assistant, [approvalRequest]),
+ new(ChatRole.Tool, [approvalResponse]),
+ ];
+ List secondTurn = await chatClient
+ .GetStreamingResponseAsync(approvalMessages, options)
+ .ToListAsync();
+#pragma warning restore MEAI001
+
+ // Assert
+ secondTurn.Should().Contain(static update => update.Text == "Expense report EXP-100 was submitted.");
+ }
+
+ private sealed class DeterministicExpenseReviewer : AIAgent
+ {
+ public override string? Name => "ExpenseReviewer";
+
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+#pragma warning disable MEAI001 // Tool approval content is experimental.
+ ToolApprovalResponseContent? approvalResponse = messages
+ .SelectMany(static message => message.Contents)
+ .OfType()
+ .LastOrDefault();
+
+ if (approvalResponse is not null)
+ {
+ yield return CreateUpdate(approvalResponse.Approved
+ ? new TextContent("Expense report EXP-100 was submitted.")
+ : new TextContent("Expense report EXP-100 was rejected."));
+ yield break;
+ }
+
+ FunctionCallContent toolCall = new(
+ "submit-expense-call",
+ "SubmitExpense",
+ new Dictionary { ["reportId"] = "EXP-100" });
+ yield return CreateUpdate(new ToolApprovalRequestContent("submit-expense-approval", toolCall));
+#pragma warning restore MEAI001
+ await Task.Yield();
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => new(new ExpenseSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(JsonSerializer.SerializeToElement(new Dictionary()));
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(new ExpenseSession());
+
+ private static AgentResponseUpdate CreateUpdate(AIContent content)
+ => new(ChatRole.Assistant, [content])
+ {
+ MessageId = "expense-review-message",
+ ResponseId = "expense-review-response",
+ };
+
+ private sealed class ExpenseSession : AgentSession;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ConcurrentWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ConcurrentWorkflowTests.cs
new file mode 100644
index 00000000000..07784b3d9cf
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ConcurrentWorkflowTests.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using AGUI.Abstractions;
+using AGUI.Client;
+using AGUI.WorkflowConcurrent;
+using FluentAssertions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows;
+
+public sealed class ConcurrentWorkflowTests
+{
+ [Fact]
+ public async Task ClientReceivesIndependentExecutorStepsAsync()
+ {
+ // Arrange
+ AIAgent researcher = new DeterministicAgent("Researcher", "facts");
+ AIAgent critic = new DeterministicAgent("Critic", "risks");
+ Workflow workflow = ConcurrentWorkflow.Create(researcher, critic);
+ await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(
+ workflow.AsAIAgent(name: "ConcurrentWorkflow"));
+ AGUIChatClient chatClient = new(new(host.Client, ""));
+ AIAgent clientAgent = chatClient.AsAIAgent(name: "client");
+ AgentSession session = await clientAgent.CreateSessionAsync();
+
+ // Act
+ List updates = await clientAgent
+ .RunStreamingAsync(new ChatMessage(ChatRole.User, "start"), session)
+ .ToListAsync();
+
+ // Assert
+ object?[] lifecycle = [.. updates
+ .Select(static update => update.AsChatResponseUpdate().RawRepresentation)
+ .Where(static raw => raw is StepStartedEvent or StepFinishedEvent)];
+
+ int researcherStart = Array.FindIndex(lifecycle, static raw =>
+ raw is StepStartedEvent evt && evt.StepName.StartsWith("Researcher_", StringComparison.Ordinal));
+ int criticStart = Array.FindIndex(lifecycle, static raw =>
+ raw is StepStartedEvent evt && evt.StepName.StartsWith("Critic_", StringComparison.Ordinal));
+ int researcherFinish = Array.FindIndex(lifecycle, static raw =>
+ raw is StepFinishedEvent evt && evt.StepName.StartsWith("Researcher_", StringComparison.Ordinal));
+ int criticFinish = Array.FindIndex(lifecycle, static raw =>
+ raw is StepFinishedEvent evt && evt.StepName.StartsWith("Critic_", StringComparison.Ordinal));
+
+ researcherStart.Should().BeGreaterThanOrEqualTo(0);
+ criticStart.Should().BeGreaterThanOrEqualTo(0);
+ researcherFinish.Should().BeGreaterThan(researcherStart);
+ criticFinish.Should().BeGreaterThan(criticStart);
+ updates.Count(static update => update.Text == "facts").Should().Be(1);
+ updates.Count(static update => update.Text == "risks").Should().Be(1);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/DeterministicAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/DeterministicAgent.cs
new file mode 100644
index 00000000000..9bb2023d93b
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/DeterministicAgent.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows;
+
+internal sealed class DeterministicAgent(string name, string response) : AIAgent
+{
+ public override string? Name => name;
+
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, response)));
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.Yield();
+ yield return new AgentResponseUpdate(ChatRole.Assistant, response)
+ {
+ AuthorName = name,
+ MessageId = $"{name}-message",
+ ResponseId = $"{name}-response",
+ };
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => new(new TestSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(JsonSerializer.SerializeToElement(new Dictionary()));
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(new TestSession());
+
+ private sealed class TestSession : AgentSession;
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/FailingWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/FailingWorkflowTests.cs
new file mode 100644
index 00000000000..8900f4274cf
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/FailingWorkflowTests.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Threading.Tasks;
+using AGUI.Abstractions;
+using AGUI.Client;
+using AGUI.WorkflowFailure;
+using FluentAssertions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows;
+
+public sealed class FailingWorkflowTests
+{
+ [Fact]
+ public async Task ClientReceivesStepFinishedForFailedExecutorAsync()
+ {
+ // Arrange
+ Workflow workflow = FailingWorkflow.Create(new FailingAgent());
+ await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(
+ workflow.AsAIAgent(name: "FailingWorkflow"));
+ RunAgentInput input = new()
+ {
+ Messages = new[] { new ChatMessage(ChatRole.User, "start") }.AsAGUIMessages().ToList(),
+ RunId = "failure-run",
+ ThreadId = "failure-thread",
+ };
+
+ // Act
+ List events = await SendAsync(host.Client, input);
+
+ // Assert
+ int stepStarted = events.FindIndex(static evt =>
+ evt is StepStartedEvent step
+ && step.StepName.StartsWith("FailingStep_", StringComparison.Ordinal));
+ int stepFinished = events.FindIndex(static evt =>
+ evt is StepFinishedEvent step
+ && step.StepName.StartsWith("FailingStep_", StringComparison.Ordinal));
+ int textEnd = events.FindLastIndex(static evt => evt is TextMessageEndEvent);
+ int runError = events.FindIndex(static evt => evt is RunErrorEvent);
+
+ stepStarted.Should().BeGreaterThanOrEqualTo(0);
+ stepFinished.Should().BeGreaterThan(stepStarted);
+ textEnd.Should().BeGreaterThan(stepFinished);
+ runError.Should().BeGreaterThan(stepFinished);
+ runError.Should().BeGreaterThan(textEnd);
+ events[runError].Should().BeOfType()
+ .Which.Message.Should().Be("An error occurred while executing the workflow.");
+ events.Skip(runError + 1).Should().BeEmpty();
+ events.OfType().Should().BeEmpty();
+ events.OfType().Should().ContainSingle();
+ events.OfType().Should().ContainSingle();
+
+ int startedCount = events
+ .OfType()
+ .Count(static evt => evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal));
+ int finishedCount = events
+ .OfType()
+ .Count(static evt => evt.StepName.StartsWith("FailingStep_", StringComparison.Ordinal));
+ startedCount.Should().BeGreaterThan(0);
+ finishedCount.Should().Be(startedCount);
+ }
+
+ private static async Task> SendAsync(HttpClient client, RunAgentInput input)
+ {
+ using JsonContent content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput);
+ using HttpResponseMessage response = await client.PostAsync(new Uri("", UriKind.Relative), content);
+ response.EnsureSuccessStatusCode();
+ return await response.ReadAGUIEventStreamAsync().ToListAsync();
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/MultipleInputsWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/MultipleInputsWorkflowTests.cs
new file mode 100644
index 00000000000..c9250986fbe
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/MultipleInputsWorkflowTests.cs
@@ -0,0 +1,95 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading.Tasks;
+using AGUI.Abstractions;
+using AGUI.Client;
+using AGUI.WorkflowMultipleInputs;
+using FluentAssertions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows;
+
+public sealed class MultipleInputsWorkflowTests
+{
+ [Fact]
+ public async Task ClientRespondsPartiallyOutOfOrderThenCompletesWorkflowAsync()
+ {
+ // Arrange
+ AIAgent workflowAgent = MultipleInputsWorkflow.Create().AsAIAgent(name: "MultipleInputsWorkflow");
+ await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(workflowAgent, persistSession: true);
+ RunAgentInput initialInput = new()
+ {
+ Messages = new[] { new ChatMessage(ChatRole.User, "plan trip") }.AsAGUIMessages().ToList(),
+ RunId = "travel-run-1",
+ ThreadId = "travel-thread",
+ };
+
+ // Act - collect three same-turn interruptions.
+ List firstTurn = await SendAsync(host.Client, initialInput);
+ RunFinishedEvent firstFinished = firstTurn.OfType().Single();
+ AGUIInterrupt[] requests =
+ [
+ .. firstFinished.Outcome.Should().BeOfType().Subject.Interrupts,
+ ];
+ requests.Should().HaveCount(3);
+ AGUIInterrupt dates = requests.Single(static item => item.Message!.Contains("TravelDates"));
+ AGUIInterrupt travelers = requests.Single(static item => item.Message!.Contains("TravelerDetails"));
+ AGUIInterrupt preferences = requests.Single(static item => item.Message!.Contains("TravelPreferences"));
+
+ // Respond to traveler details before dates, leaving preferences pending.
+ List partialTurn = await SendAsync(host.Client, CreateResume(
+ firstFinished,
+ "travel-run-2",
+ [
+ Resume(travelers, new { kind = "travelers", count = 2 }),
+ Resume(dates, new { kind = "dates", departure = "2026-10-10", returnDate = "2026-10-14" }),
+ ]));
+ partialTurn.OfType().Should().BeEmpty();
+ RunFinishedEvent partialFinished = partialTurn.OfType().Single();
+
+ List finalTurn = await SendAsync(host.Client, CreateResume(
+ partialFinished,
+ "travel-run-3",
+ [Resume(preferences, new { kind = "preferences", budget = 2500 })]));
+
+ // Assert
+ string text = string.Concat(finalTurn.OfType().Select(static evt => evt.Delta));
+ text.Should().Contain("Travel plan ready for Seattle");
+ }
+
+ private static RunAgentInput CreateResume(
+ RunFinishedEvent previous,
+ string runId,
+ IList resumes)
+ => new()
+ {
+ Messages = [],
+ ParentRunId = previous.RunId,
+ Resume = resumes,
+ RunId = runId,
+ ThreadId = previous.ThreadId,
+ };
+
+ private static AGUIResume Resume(AGUIInterrupt interrupt, object payload)
+ => new()
+ {
+ InterruptId = interrupt.Id,
+ Payload = JsonSerializer.SerializeToElement(payload),
+ Status = "resolved",
+ };
+
+ private static async Task> SendAsync(HttpClient client, RunAgentInput input)
+ {
+ using JsonContent content = JsonContent.Create(input, AGUIJsonSerializerContext.Default.RunAgentInput);
+ using HttpResponseMessage response = await client.PostAsync(new Uri("", UriKind.Relative), content);
+ response.EnsureSuccessStatusCode();
+ return await response.ReadAGUIEventStreamAsync().ToListAsync();
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/NestedWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/NestedWorkflowTests.cs
new file mode 100644
index 00000000000..44b82ceaf49
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/NestedWorkflowTests.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using AGUI.Client;
+using AGUI.WorkflowNested;
+using FluentAssertions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows;
+
+public sealed class NestedWorkflowTests
+{
+ [Fact]
+ public async Task ClientRejectsDuplicateActiveLocalExecutorIdsFromParallelSubworkflowsAsync()
+ {
+ // Arrange
+ Workflow workflow = NestedWorkflow.Create();
+ await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(
+ workflow.AsAIAgent(name: "NestedWorkflow", includeWorkflowOutputsInResponse: true));
+ AGUIChatClient chatClient = new(new(host.Client, ""));
+ AIAgent clientAgent = chatClient.AsAIAgent(name: "client");
+ AgentSession session = await clientAgent.CreateSessionAsync();
+
+ // Act
+ Func act = async () => _ = await clientAgent
+ .RunStreamingAsync(new ChatMessage(ChatRole.User, "start"), session)
+ .ToListAsync();
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*Step \"Analyze_Analyze\" is already active*");
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/SequentialWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/SequentialWorkflowTests.cs
new file mode 100644
index 00000000000..74fe2b94e04
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/SequentialWorkflowTests.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using AGUI.Abstractions;
+using AGUI.Client;
+using AGUI.WorkflowSequential;
+using FluentAssertions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows;
+
+public sealed class SequentialWorkflowTests
+{
+ [Fact]
+ public async Task ClientReceivesOrderedExecutorStepsAndTextAsync()
+ {
+ // Arrange
+ AIAgent writer = new DeterministicAgent("Writer", "draft");
+ AIAgent reviewer = new DeterministicAgent("Reviewer", "final");
+ Workflow workflow = SequentialWorkflow.Create(writer, reviewer);
+ await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(
+ workflow.AsAIAgent(name: "SequentialWorkflow"));
+ AGUIChatClient chatClient = new(new(host.Client, ""));
+ AIAgent clientAgent = chatClient.AsAIAgent(name: "client");
+ AgentSession session = await clientAgent.CreateSessionAsync();
+
+ // Act
+ List updates = await clientAgent
+ .RunStreamingAsync(new ChatMessage(ChatRole.User, "start"), session)
+ .ToListAsync();
+
+ // Assert
+ string[] started = [.. updates
+ .Select(static update => update.AsChatResponseUpdate().RawRepresentation)
+ .OfType()
+ .Select(static evt => evt.StepName)];
+ string[] finished = [.. updates
+ .Select(static update => update.AsChatResponseUpdate().RawRepresentation)
+ .OfType()
+ .Select(static evt => evt.StepName)];
+
+ int writerStart = Array.FindIndex(started, static name => name.StartsWith("Writer_", StringComparison.Ordinal));
+ int reviewerStart = Array.FindIndex(started, static name => name.StartsWith("Reviewer_", StringComparison.Ordinal));
+ int writerFinish = Array.FindIndex(finished, static name => name.StartsWith("Writer_", StringComparison.Ordinal));
+ int reviewerFinish = Array.FindIndex(finished, static name => name.StartsWith("Reviewer_", StringComparison.Ordinal));
+
+ writerStart.Should().BeGreaterThanOrEqualTo(0);
+ reviewerStart.Should().BeGreaterThan(writerStart);
+ writerFinish.Should().BeGreaterThanOrEqualTo(0);
+ reviewerFinish.Should().BeGreaterThan(writerFinish);
+ updates.Count(static update => update.Text == "draft").Should().Be(1);
+ updates.Count(static update => update.Text == "final").Should().Be(1);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ToolWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ToolWorkflowTests.cs
new file mode 100644
index 00000000000..82eff4ebafc
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/ToolWorkflowTests.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using AGUI.Abstractions;
+using AGUI.Client;
+using AGUI.WorkflowTools;
+using FluentAssertions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows;
+
+public sealed class ToolWorkflowTests
+{
+ [Fact]
+ public async Task ClientReceivesStepsToolContentAndTextOnceAsync()
+ {
+ // Arrange
+ Workflow workflow = ToolWorkflow.Create(new DeterministicToolAgent());
+ await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(
+ workflow.AsAIAgent(name: "ToolWorkflow"));
+ AGUIChatClient chatClient = new(new(host.Client, ""));
+ AIAgent clientAgent = chatClient.AsAIAgent(name: "client");
+ AgentSession session = await clientAgent.CreateSessionAsync();
+
+ // Act
+ List updates = await clientAgent
+ .RunStreamingAsync(new ChatMessage(ChatRole.User, "weather"), session)
+ .ToListAsync();
+
+ // Assert
+ updates.Select(static update => update.AsChatResponseUpdate().RawRepresentation)
+ .OfType()
+ .Should().Contain(static evt => evt.StepName.StartsWith("WeatherAgent_"));
+ updates.Select(static update => update.AsChatResponseUpdate().RawRepresentation)
+ .OfType()
+ .Should().Contain(static evt => evt.StepName.StartsWith("WeatherAgent_"));
+ updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle();
+ updates.SelectMany(static update => update.Contents).OfType().Should().ContainSingle();
+ updates.Count(static update => update.Text == "Sunny").Should().Be(1);
+ }
+
+ private sealed class DeterministicToolAgent : AIAgent
+ {
+ public override string? Name => "WeatherAgent";
+
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ yield return CreateUpdate(new FunctionCallContent(
+ "weather-call",
+ "GetWeather",
+ new Dictionary { ["city"] = "Seattle" }));
+ yield return CreateUpdate(new FunctionResultContent("weather-call", "Sunny"));
+ yield return CreateUpdate(new TextContent("Sunny"));
+ await Task.Yield();
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => new(new ToolSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(JsonSerializer.SerializeToElement(new Dictionary()));
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default)
+ => new(new ToolSession());
+
+ private static AgentResponseUpdate CreateUpdate(AIContent content)
+ => new(ChatRole.Assistant, [content])
+ {
+ MessageId = "weather-message",
+ ResponseId = "weather-response",
+ };
+
+ private sealed class ToolSession : AgentSession;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs
new file mode 100644
index 00000000000..362667dd1de
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Workflows/WorkflowTestHost.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows;
+
+internal sealed class WorkflowTestHost : IAsyncDisposable
+{
+ private readonly WebApplication _app;
+
+ private WorkflowTestHost(WebApplication app, HttpClient client)
+ {
+ this._app = app;
+ this.Client = client;
+ }
+
+ public HttpClient Client { get; }
+
+ public static async Task StartAsync(AIAgent agent, bool persistSession = false)
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddAGUIServer();
+
+ if (persistSession)
+ {
+ string agentName = agent.Name ?? throw new InvalidOperationException("A named agent is required for session persistence.");
+ builder.Services.AddAIAgent(agentName, (_, _) => agent)
+ .WithInMemorySessionStore(withIsolation: false);
+ }
+
+ WebApplication app = builder.Build();
+ if (persistSession)
+ {
+ app.MapAGUIServer(agent.Name!, "/agent");
+ }
+ else
+ {
+ app.MapAGUIServer("/agent", agent);
+ }
+ await app.StartAsync();
+
+ TestServer server = app.Services.GetRequiredService() as TestServer
+ ?? throw new InvalidOperationException("TestServer not found.");
+ HttpClient client = server.CreateClient();
+ client.BaseAddress = new Uri("http://localhost/agent");
+ return new WorkflowTestHost(app, client);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ this.Client.Dispose();
+ await this._app.DisposeAsync();
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj
index ed65db63289..925f6f30891 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj
@@ -14,6 +14,7 @@
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs
new file mode 100644
index 00000000000..91f99ebf6ba
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/WorkflowAGUIExtensionsTests.cs
@@ -0,0 +1,231 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using AGUI.Abstractions;
+using FluentAssertions;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
+
+///
+/// Tests workflow lifecycle and interruption mapping to AG-UI.
+///
+public sealed class WorkflowAGUIExtensionsTests
+{
+ [Fact]
+ public async Task MapWorkflowEventsToAGUI_MapsExecutorInvokedToStepStartedAsync()
+ {
+ // Arrange
+ AgentResponseUpdate update = CreateUpdate(new ExecutorInvokedEvent("reviewer", "input"));
+
+ // Act
+ ChatResponseUpdate result = await ToAsyncEnumerableAsync(update)
+ .AsChatResponseUpdatesAsync()
+ .MapWorkflowEventsToAGUI()
+ .SingleAsync();
+
+ // Assert
+ result.RawRepresentation.Should().BeOfType()
+ .Which.StepName.Should().Be("reviewer");
+ }
+
+ [Fact]
+ public async Task MapWorkflowEventsToAGUI_MapsExecutorCompletedToStepFinishedAsync()
+ {
+ // Arrange
+ AgentResponseUpdate update = CreateUpdate(new ExecutorCompletedEvent("reviewer", "result"));
+
+ // Act
+ ChatResponseUpdate result = await ToAsyncEnumerableAsync(update)
+ .AsChatResponseUpdatesAsync()
+ .MapWorkflowEventsToAGUI()
+ .SingleAsync();
+
+ // Assert
+ result.RawRepresentation.Should().BeOfType()
+ .Which.StepName.Should().Be("reviewer");
+ }
+
+ [Fact]
+ public async Task MapWorkflowEventsToAGUI_MapsExecutorFailedToStepFinishedAndRunErrorAsync()
+ {
+ // Arrange
+ ErrorContent error = new("An error occurred while executing the workflow.");
+ AgentResponseUpdate update = CreateUpdate(
+ new ExecutorFailedEvent("reviewer", new InvalidOperationException("internal")),
+ error);
+
+ // Act
+ List results = await ToAsyncEnumerableAsync(update)
+ .AsChatResponseUpdatesAsync()
+ .MapWorkflowEventsToAGUI()
+ .ToListAsync();
+
+ // Assert
+ results.Should().HaveCount(2);
+ results[0].RawRepresentation.Should().BeOfType()
+ .Which.StepName.Should().Be("reviewer");
+ results[0].Contents.Should().BeEmpty();
+ results[1].RawRepresentation.Should().BeOfType()
+ .Which.Message.Should().Be(error.Message);
+ results[1].Contents.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task MakeRunErrorTerminalAsync_EmitsCleanupThenErrorWithoutSuccessAsync()
+ {
+ // Arrange
+ BaseEvent[] events =
+ [
+ new StepStartedEvent { StepName = "reviewer" },
+ new TextMessageStartEvent { MessageId = "message", Role = "assistant" },
+ new RunErrorEvent { Message = "failed" },
+ new TextMessageStartEvent { MessageId = "orphan", Role = "assistant" },
+ new TextMessageEndEvent { MessageId = "orphan" },
+ new RunFinishedEvent
+ {
+ RunId = "run",
+ ThreadId = "thread",
+ Outcome = new RunFinishedSuccessOutcome(),
+ },
+ ];
+
+ // Act
+ List results = await ToAsyncEnumerableAsync(events)
+ .MakeRunErrorTerminalAsync()
+ .ToListAsync();
+
+ // Assert
+ results.Should().HaveCount(5);
+ results[^3].Should().BeOfType()
+ .Which.MessageId.Should().Be("message");
+ results[^2].Should().BeOfType()
+ .Which.StepName.Should().Be("reviewer");
+ results[^1].Should().BeOfType();
+ results.OfType().Should().ContainSingle()
+ .Which.MessageId.Should().Be("message");
+ }
+
+ [Fact]
+ public async Task MapWorkflowEventsToAGUI_ForwardsOtherUpdatesUnchangedAsync()
+ {
+ // Arrange
+ WorkflowStartedEvent workflowStarted = new("workflow");
+ TextContent text = new("hello");
+ AgentResponseUpdate update = CreateUpdate(workflowStarted, text);
+
+ // Act
+ ChatResponseUpdate convertedUpdate = await ToAsyncEnumerableAsync(update)
+ .AsChatResponseUpdatesAsync()
+ .SingleAsync();
+ ChatResponseUpdate result = await ToAsyncEnumerableAsync(convertedUpdate)
+ .MapWorkflowEventsToAGUI()
+ .SingleAsync();
+
+ // Assert
+ result.Should().BeSameAs(convertedUpdate);
+ result.RawRepresentation.Should().BeSameAs(update);
+ result.Contents.Should().ContainSingle().Which.Should().BeSameAs(text);
+ }
+
+ [Fact]
+ public void MapAGUIInterruptResponsesToFunctionResults_MapsResponseUnconditionally()
+ {
+ // Arrange
+ JsonElement payload = JsonSerializer.SerializeToElement(new { approved = true });
+ ChatMessage message = new(
+ ChatRole.User,
+ [new InterruptResponseContent("request-1") { Payload = payload }]);
+
+ // Act
+ List results = new[] { message }.MapAGUIInterruptResponsesToFunctionResults();
+
+ // Assert
+ ChatMessage result = results.Should().ContainSingle().Subject;
+ result.Role.Should().Be(ChatRole.User);
+ FunctionResultContent functionResult = result.Contents.Should().ContainSingle()
+ .Which.Should().BeOfType().Subject;
+ functionResult.CallId.Should().Be("request-1");
+ functionResult.Result.Should().Be(payload);
+ }
+
+ [Fact]
+ public async Task MapWorkflowEventsToAGUI_PrefersWorkflowCorrelatedApprovalRequestAsync()
+ {
+ // Arrange
+ FunctionCallContent toolCall = new(
+ "call-1",
+ "SubmitExpense",
+ new Dictionary());
+ ToolApprovalRequestContent originalRequest = new("agent-request", toolCall);
+ ToolApprovalRequestContent correlatedRequest = new("workflow-request", toolCall);
+ AgentResponseUpdate original = CreateUpdate(raw: new object(), originalRequest);
+ AgentResponseUpdate correlated = CreateUpdate(
+ new RequestInfoEvent(ExternalRequest.Create(
+ RequestPort.Create("approval"),
+ originalRequest,
+ "workflow-request")),
+ correlatedRequest);
+
+ // Act
+ List results = await ToAsyncEnumerableAsync([original, correlated])
+ .AsChatResponseUpdatesAsync()
+ .MapWorkflowEventsToAGUI()
+ .ToListAsync();
+
+ // Assert
+ ToolApprovalRequestContent request = results.SelectMany(static update => update.Contents)
+ .OfType()
+ .Should().ContainSingle().Subject;
+ request.RequestId.Should().Be("workflow-request");
+ }
+
+ private static AgentResponseUpdate CreateUpdate(object raw, params AIContent[] contents)
+ => new(ChatRole.Assistant, contents)
+ {
+ AuthorName = "author",
+ CreatedAt = DateTimeOffset.UtcNow,
+ MessageId = "message",
+ RawRepresentation = raw,
+ ResponseId = "response",
+ };
+
+ private static async IAsyncEnumerable ToAsyncEnumerableAsync(
+ AgentResponseUpdate update)
+ {
+ await Task.Yield();
+ yield return update;
+ }
+
+ private static async IAsyncEnumerable ToAsyncEnumerableAsync(
+ IEnumerable updates)
+ {
+ await Task.Yield();
+ foreach (AgentResponseUpdate update in updates)
+ {
+ yield return update;
+ }
+ }
+
+ private static async IAsyncEnumerable ToAsyncEnumerableAsync(
+ ChatResponseUpdate update)
+ {
+ await Task.Yield();
+ yield return update;
+ }
+
+ private static async IAsyncEnumerable ToAsyncEnumerableAsync(
+ IEnumerable events)
+ {
+ await Task.Yield();
+ foreach (BaseEvent evt in events)
+ {
+ yield return evt;
+ }
+ }
+}