Use native tool approval in workflow sample

Build the approval workflow around one expense-review agent with an explicit checklist and approval-required submission tool. Handle the native approval request/response pair through IChatClient, deduplicate the workflow-correlated request, and map generic interrupt responses unconditionally to function results.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 681114d3-3e0d-47f8-8b87-4e5873ecdd6d
This commit is contained in:
Javier Calvarro Nelson
2026-08-21 15:55:37 +02:00
parent 0f04079751
commit 59ff780136
16 changed files with 327 additions and 154 deletions
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
Console.Write("Request: ");
string request = Console.ReadLine() ?? "Write a short welcome message for a developer conference.";
@@ -31,3 +31,6 @@ await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync
}
Console.WriteLine();
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
=> new AGUIChatClient(new(httpClient, serverUrl));
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
Console.Write("Request: ");
string request = Console.ReadLine() ?? "Assess the tradeoffs of adopting a new framework.";
@@ -31,3 +31,6 @@ await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync
}
Console.WriteLine();
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
=> new AGUIChatClient(new(httpClient, serverUrl));
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(
[new ChatMessage(ChatRole.User, "Run the failing workflow.")]))
@@ -24,3 +24,6 @@ await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync
break;
}
}
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
=> new AGUIChatClient(new(httpClient, serverUrl));
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(
[new ChatMessage(ChatRole.User, "What is the weather in Seattle?")]))
@@ -39,3 +39,6 @@ await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync
}
Console.WriteLine();
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
=> new AGUIChatClient(new(httpClient, serverUrl));
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
IChatClient chatClient = new AGUIChatClient(new(httpClient, serverUrl));
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
try
{
@@ -35,3 +35,6 @@ catch (InvalidOperationException exception)
}
Console.WriteLine();
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
=> new AGUIChatClient(new(httpClient, serverUrl));
@@ -1,46 +1,60 @@
// 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 = new AGUIChatClient(new(httpClient, serverUrl));
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<ChatResponseUpdate> firstTurn = await chatClient
.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Submit expense EXP-100.")])
.GetStreamingResponseAsync(
[new ChatMessage(ChatRole.User, $"Review and submit this expense report:\n{reportJson}")],
options)
.ToListAsync();
RunFinishedEvent finished = firstTurn.Select(static update => update.RawRepresentation)
.OfType<RunFinishedEvent>()
#pragma warning disable MEAI001 // Tool approval content is experimental.
ToolApprovalRequestContent approvalRequest = firstTurn
.SelectMany(static update => update.Contents)
.OfType<ToolApprovalRequestContent>()
.Single();
AGUIInterrupt interrupt = ((RunFinishedInterruptOutcome)finished.Outcome!).Interrupts.Single();
FunctionCallContent toolCall = (FunctionCallContent)approvalRequest.ToolCall;
Console.Write($"{interrupt.Message ?? "Approve expense?"} [y/N]: ");
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.");
ChatOptions resumeOptions = new()
List<ChatMessage> approvalMessages =
[
new(ChatRole.Assistant, [approvalRequest]),
new(ChatRole.Tool, [approvalResponse]),
];
await foreach (ChatResponseUpdate update in chatClient.GetStreamingResponseAsync(approvalMessages, options))
{
RawRepresentationFactory = _ => new RunAgentInput
foreach (TextContent text in update.Contents.OfType<TextContent>())
{
Messages = [],
ParentRunId = finished.RunId,
Resume =
[
new AGUIResume
{
InterruptId = interrupt.Id,
Payload = JsonSerializer.SerializeToElement(new { approved }),
Status = "resolved",
},
],
RunId = Guid.NewGuid().ToString("N"),
ThreadId = finished.ThreadId,
},
};
Console.Write(text.Text);
}
}
#pragma warning restore MEAI001
List<ChatResponseUpdate> secondTurn = await chatClient
.GetStreamingResponseAsync([], resumeOptions)
.ToListAsync();
Console.WriteLine(string.Concat(secondTurn.Select(static update => update.Text)));
Console.WriteLine();
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
=> new AGUIChatClient(new(httpClient, serverUrl));
@@ -1,7 +1,11 @@
# Approval Workflow over AG-UI
This sample pauses a workflow for approval before submitting an expense. The AG-UI client reads the
interruption from `RUN_FINISHED`, asks the user for a decision, and resumes the same workflow thread.
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.
@@ -1,12 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
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.WorkflowApproval;
@@ -16,51 +11,25 @@ namespace AGUI.WorkflowApproval;
public static class ApprovalWorkflow
{
/// <summary>
/// Creates a workflow that pauses for approval before submitting an expense.
/// Creates a workflow containing one expense-review agent.
/// </summary>
/// <returns>The approval workflow.</returns>
public static Workflow Create()
{
ExpenseApprovalExecutor executor = new();
return new WorkflowBuilder(executor)
.AddExternalCall<ExpenseApprovalRequest, JsonElement>(executor, "ApprovalInput")
.WithOutputFrom(executor)
.Build();
}
/// <param name="expenseReviewer">The agent that checks and submits expense reports.</param>
/// <returns>The expense approval workflow.</returns>
public static Workflow Create(AIAgent expenseReviewer)
=> new SequentialWorkflowBuilder(expenseReviewer).Build();
}
/// <summary>
/// The expense approval request presented to the client.
/// An expense report submitted to the workflow.
/// </summary>
/// <param name="ExpenseId">The expense identifier.</param>
/// <param name="Amount">The expense amount.</param>
public sealed record ExpenseApprovalRequest(string ExpenseId, decimal Amount);
[SendsMessage(typeof(ExpenseApprovalRequest))]
internal sealed partial class ExpenseApprovalExecutor()
: ChatProtocolExecutor("ExpenseApproval", new ChatProtocolExecutorOptions { AutoSendTurnToken = false })
{
protected override ValueTask TakeTurnAsync(
List<ChatMessage> messages,
IWorkflowContext context,
bool? emitEvents,
CancellationToken cancellationToken = default)
=> context.SendMessageAsync(new ExpenseApprovalRequest("EXP-100", 125.00m), cancellationToken);
[MessageHandler]
public async ValueTask HandleApprovalAsync(
JsonElement response,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
bool approved = response.GetProperty("approved").GetBoolean();
string result = approved ? "Expense approved and submitted." : "Expense rejected.";
AgentResponseUpdate update = new(ChatRole.Assistant, result)
{
MessageId = "expense-result",
ResponseId = "expense-response",
};
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(new TurnToken(false), cancellationToken).ConfigureAwait(false);
}
}
/// <param name="Id">The report identifier.</param>
/// <param name="Employee">The submitting employee.</param>
/// <param name="Amount">The total expense amount.</param>
/// <param name="BusinessPurpose">The business purpose.</param>
/// <param name="ReceiptAttached">Whether a receipt is attached.</param>
public sealed record ExpenseReport(
string Id,
string Employee,
decimal Amount,
string BusinessPurpose,
bool ReceiptAttached);
@@ -1,19 +1,56 @@
// 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();
builder.Services.AddAIAgent(
"ApprovalWorkflow",
static (_, _) => ApprovalWorkflow.Create().AsAIAgent(
name: "ApprovalWorkflow",
includeWorkflowOutputsInResponse: true))
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();
@@ -7,13 +7,15 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Generators\Microsoft.Agents.AI.Workflows.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"
GlobalPropertiesToRemove="TargetFramework" />
</ItemGroup>
</Project>
@@ -7,7 +7,7 @@ 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 = new AGUIChatClient(new(httpClient, serverUrl));
using IChatClient chatClient = CreateChatClient(httpClient, serverUrl);
List<ChatResponseUpdate> firstTurn = await chatClient
.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Plan my conference trip.")])
@@ -62,3 +62,6 @@ static AGUIResume Resume(AGUIInterrupt interrupt, object payload)
Payload = JsonSerializer.SerializeToElement(payload),
Status = "resolved",
};
static IChatClient CreateChatClient(HttpClient httpClient, string serverUrl)
=> new AGUIChatClient(new(httpClient, serverUrl));
@@ -147,9 +147,7 @@ public static class AGUIEndpointRouteBuilderExtensions
var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false);
IEnumerable<ChatMessage> messages = aiAgent.GetService<Microsoft.Agents.AI.Workflows.Workflow>() is not null
? ctx.Messages.MapAGUIInterruptResponsesToWorkflow()
: ctx.Messages;
IEnumerable<ChatMessage> messages = ctx.Messages.MapAGUIInterruptResponsesToFunctionResults();
var events = hostAgent
.RunStreamingAsync(
@@ -18,11 +18,20 @@ internal static class WorkflowAGUIExtensions
{
ArgumentNullException.ThrowIfNull(updates);
List<ChatResponseUpdate> interruptions = [];
List<ChatResponseUpdate> uncorrelatedApprovalRequests = [];
await foreach (ChatResponseUpdate update in updates.ConfigureAwait(false))
{
switch (update.RawRepresentation)
{
case AgentResponseUpdate { RawRepresentation: RequestInfoEvent }
when update.Contents.OfType<ToolApprovalRequestContent>().SingleOrDefault() is { } approvalRequest:
uncorrelatedApprovalRequests.RemoveAll(candidate =>
candidate.Contents.OfType<ToolApprovalRequestContent>().Any(candidateRequest =>
candidateRequest.ToolCall.CallId == approvalRequest.ToolCall.CallId));
yield return update;
break;
case AgentResponseUpdate { RawRepresentation: RequestInfoEvent requestInfo }
when update.Contents.OfType<FunctionCallContent>().SingleOrDefault() is { } request:
update.Contents =
@@ -63,6 +72,10 @@ internal static class WorkflowAGUIExtensions
includeContents: false);
break;
case var _ when update.Contents.OfType<ToolApprovalRequestContent>().Any():
uncorrelatedApprovalRequests.Add(update);
break;
default:
yield return update;
break;
@@ -73,9 +86,17 @@ internal static class WorkflowAGUIExtensions
{
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<BaseEvent> MakeRunErrorTerminalAsync(
this IAsyncEnumerable<BaseEvent> events)
{
@@ -212,7 +233,7 @@ internal static class WorkflowAGUIExtensions
ContinuationToken = update.ContinuationToken,
};
internal static List<ChatMessage> MapAGUIInterruptResponsesToWorkflow(
internal static List<ChatMessage> MapAGUIInterruptResponsesToFunctionResults(
this IEnumerable<ChatMessage> messages)
=> [.. messages.Select(static message =>
{
@@ -55,12 +55,6 @@ internal sealed class WorkflowHostAgent : AIAgent
public override string? Name { get; }
public override string? Description { get; }
/// <inheritdoc />
public override object? GetService(Type serviceType, object? serviceKey = null)
=> serviceKey is null && serviceType == typeof(Workflow)
? this._workflow
: base.GetService(serviceType, serviceKey);
private string GenerateNewId()
{
string result;
@@ -1,13 +1,11 @@
// 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.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AGUI.Abstractions;
using AGUI.Client;
using AGUI.WorkflowApproval;
using FluentAssertions;
@@ -19,61 +17,116 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.Workflows
public sealed class ApprovalWorkflowTests
{
[Fact]
public async Task ClientApprovesInterruptionAndWorkflowResumesAsync()
public async Task ClientApprovesToolRequestAndWorkflowResumesAsync()
{
// Arrange
AIAgent workflowAgent = ApprovalWorkflow.Create().AsAIAgent(
name: "ApprovalWorkflow",
includeWorkflowOutputsInResponse: true);
Workflow workflow = ApprovalWorkflow.Create(new DeterministicExpenseReviewer());
AIAgent workflowAgent = workflow.AsAIAgent(name: "ApprovalWorkflow");
await using WorkflowTestHost host = await WorkflowTestHost.StartAsync(workflowAgent, persistSession: true);
RunAgentInput initialInput = new()
{
Messages = new[] { new ChatMessage(ChatRole.User, "submit") }.AsAGUIMessages().ToList(),
RunId = "approval-run-1",
ThreadId = "approval-thread",
};
using AGUIChatClient chatClient = new(new(host.Client, ""));
ChatOptions options = new();
ExpenseReport report = new(
"EXP-100",
"Taylor",
125.00m,
"Developer conference registration",
ReceiptAttached: true);
// Act - initial run pauses for approval.
List<BaseEvent> firstTurn = await SendAsync(host.Client, initialInput);
RunFinishedEvent finished = firstTurn.OfType<RunFinishedEvent>().Single();
RunFinishedInterruptOutcome outcome = finished.Outcome.Should()
.BeOfType<RunFinishedInterruptOutcome>().Subject;
AGUIInterrupt interrupt = outcome.Interrupts.Should().ContainSingle().Subject;
interrupt.Reason.Should().Be(InterruptReasons.InputRequired);
// Act - the reviewer requests approval to submit the report.
List<ChatResponseUpdate> firstTurn = await chatClient
.GetStreamingResponseAsync(
[new ChatMessage(ChatRole.User, JsonSerializer.Serialize(report))],
options)
.ToListAsync();
RunAgentInput resumeInput = new()
{
Messages = [],
ParentRunId = finished.RunId,
Resume =
[
new AGUIResume
{
InterruptId = interrupt.Id,
Payload = JsonSerializer.SerializeToElement(new { approved = true }),
Status = "resolved",
},
],
RunId = "approval-run-2",
ThreadId = finished.ThreadId,
};
List<BaseEvent> secondTurn = await SendAsync(host.Client, resumeInput);
#pragma warning disable MEAI001 // Tool approval content is experimental.
ToolApprovalRequestContent approvalRequest = firstTurn
.SelectMany(static update => update.Contents)
.OfType<ToolApprovalRequestContent>()
.Single();
FunctionCallContent toolCall = approvalRequest.ToolCall.Should()
.BeOfType<FunctionCallContent>().Subject;
toolCall.Name.Should().Be("SubmitExpense");
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(
approved: true,
reason: "Approved by integration test.");
List<ChatMessage> approvalMessages =
[
new(ChatRole.Assistant, [approvalRequest]),
new(ChatRole.Tool, [approvalResponse]),
];
List<ChatResponseUpdate> secondTurn = await chatClient
.GetStreamingResponseAsync(approvalMessages, options)
.ToListAsync();
#pragma warning restore MEAI001
// Assert
string text = string.Concat(secondTurn.OfType<TextMessageContentEvent>().Select(static evt => evt.Delta));
text.Should().Contain(
"Expense approved and submitted.",
"events were {0}",
string.Join(", ", secondTurn.Select(static evt => evt.GetType().Name)));
secondTurn.OfType<StepStartedEvent>()
.Should().Contain(static evt => evt.StepName == "ExpenseApproval");
secondTurn.Should().Contain(static update => update.Text == "Expense report EXP-100 was submitted.");
}
private static async Task<List<BaseEvent>> SendAsync(HttpClient client, RunAgentInput input)
private sealed class DeterministicExpenseReviewer : AIAgent
{
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();
public override string? Name => "ExpenseReviewer";
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
=> this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> 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<ToolApprovalResponseContent>()
.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<string, object?> { ["reportId"] = "EXP-100" });
yield return CreateUpdate(new ToolApprovalRequestContent("submit-expense-approval", toolCall));
#pragma warning restore MEAI001
await Task.Yield();
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new ExpenseSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions = null,
CancellationToken cancellationToken = default)
=> new(JsonSerializer.SerializeToElement(new Dictionary<string, string>()));
protected override ValueTask<AgentSession> 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;
}
}
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AGUI.Abstractions;
using FluentAssertions;
@@ -12,7 +13,7 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
/// <summary>
/// Tests workflow executor lifecycle mapping to AG-UI step events.
/// Tests workflow lifecycle and interruption mapping to AG-UI.
/// </summary>
public sealed class WorkflowAGUIExtensionsTests
{
@@ -132,6 +133,58 @@ public sealed class WorkflowAGUIExtensionsTests
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<ChatMessage> 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<FunctionResultContent>().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<string, object?>());
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<ToolApprovalRequestContent, ToolApprovalResponseContent>("approval"),
originalRequest,
"workflow-request")),
correlatedRequest);
// Act
List<ChatResponseUpdate> results = await ToAsyncEnumerableAsync([original, correlated])
.AsChatResponseUpdatesAsync()
.MapWorkflowEventsToAGUI()
.ToListAsync();
// Assert
ToolApprovalRequestContent request = results.SelectMany(static update => update.Contents)
.OfType<ToolApprovalRequestContent>()
.Should().ContainSingle().Subject;
request.RequestId.Should().Be("workflow-request");
}
private static AgentResponseUpdate CreateUpdate(object raw, params AIContent[] contents)
=> new(ChatRole.Assistant, contents)
{
@@ -149,6 +202,16 @@ public sealed class WorkflowAGUIExtensionsTests
yield return update;
}
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(
IEnumerable<AgentResponseUpdate> updates)
{
await Task.Yield();
foreach (AgentResponseUpdate update in updates)
{
yield return update;
}
}
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(
ChatResponseUpdate update)
{