diff --git a/Directory.Packages.props b/Directory.Packages.props
index a515905..e49997d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -32,6 +32,11 @@
+
+
+
+
+
diff --git a/README.md b/README.md
index b8a9509..df4bfe1 100644
--- a/README.md
+++ b/README.md
@@ -54,8 +54,8 @@ app.MapAgentCoreHost();
app.Run();
```
-`AddAgentCoreHost` loads the document (`config/example.yaml` unless `AgentCore__ConfigurationPath` names another) and binds every vendor seam. `MapAgentCoreHost` serves the health check, the OpenAI-compatible Responses endpoint, and the conversation socket.
-Every entry under `entries:` answers on `POST /v1/{entry}/responses` and on the conversation socket at `/v1/{entry}/call`.
+`AddAgentCoreHost` loads the document (`config/example.yaml` unless `AgentCore__ConfigurationPath` names another) and binds every vendor seam. `MapAgentCoreHost` serves the health check, the OpenAI-compatible Responses endpoint, the conversation socket, and the AG-UI endpoint.
+Every entry under `entries:` answers on `POST /v1/{entry}/responses`, on the conversation socket at `/v1/{entry}/call`, and on AG-UI at `POST /agui/{entry}`.
An OpenAI SDK reaches one entry through its base URL alone: `base_url = https://host/v1/phone`.
A document it cannot find is a startup failure, never a silent default.
@@ -84,7 +84,7 @@ Assistant: How can I help you today!
| `AgentCore.Domain` | Pure domain records. Zero dependencies. |
| `AgentCore.Application` | Orchestration, configuration compilation, and every port interface. |
| `AgentCore.Infrastructure` | Outbound adapters: OpenAI, Zilliz, Telnyx call control, Postgres, B2, Git. |
-| `AgentCore.AspNetCore` | Inbound adapters as `Map*` extensions: Telnyx Conversation Relay, Telnyx webhooks, Responses. |
+| `AgentCore.AspNetCore` | Inbound adapters as `Map*` extensions: Telnyx Conversation Relay, Telnyx webhooks, Responses, AG-UI. |
| `AgentCore.Hosting` | The batteries-included host: every vendor seam bound and every route mapped, in two calls. |
They share one version and ship as a set. Mixing versions across them is unsupported.
diff --git a/src/AgentCore.Application/Blobs/BlobRef.cs b/src/AgentCore.Application/Blobs/BlobRef.cs
index 8e34639..1edf21f 100644
--- a/src/AgentCore.Application/Blobs/BlobRef.cs
+++ b/src/AgentCore.Application/Blobs/BlobRef.cs
@@ -5,9 +5,11 @@ namespace AgentCore.Application.Blobs
/// The name it is stored under.
/// The IANA media type, such as image/png.
/// How many bytes it holds.
+ /// The file name a link serves it under, or for .
public sealed record BlobRef(
string OwnerId,
string Name,
string MediaType,
- long Length);
+ long Length,
+ string? DownloadName = null);
}
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs b/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs
index e636c0a..1c6b90d 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs
@@ -97,6 +97,10 @@ public AgentCompilationContext(IChatClientFactory chatClients)
/// hears notices but runs no gate.
///
public IReadOnlyList? Hooks { get; init; }
+ ///
+ /// Gets or sets the store inbound caller files are filed in, or when the document names none.
+ ///
+ public IBlobStore? Blobs { get; init; }
///
/// Gets or sets the compaction stages every agent runs.
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs
index cbfcd0f..f4bd2cb 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs
@@ -60,7 +60,7 @@ public static List Build(
+ "adapter's table.");
}
- providers.Add(new ReaderContentFilterProvider());
+ providers.Add(new ReaderContentFilterProvider(context.Blobs));
CompactionStages stages = context.Compaction ?? CompactionStrategyFactory.Create(context.ChatClients.GetChatClient(model), contextWindow);
providers.AddRange(CompactionStrategyFactory.BuildProviders(stages, history, walk.Hooks));
diff --git a/src/AgentCore.Application/Conversation/PendingApprovals.cs b/src/AgentCore.Application/Conversation/PendingApprovals.cs
new file mode 100644
index 0000000..c018d3d
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/PendingApprovals.cs
@@ -0,0 +1,39 @@
+using AgentCore.Application.Runtime.Harness;
+using AgentCore.Domain;
+
+namespace AgentCore.Application.Conversation
+{
+ ///
+ /// The tool calls a stored conversation still waits on a human answer for, for host history
+ /// routes to re-arm approvals after a reload. The source is the provider state the asking turn
+ /// filed: the pending queue minus the answers held or already collected.
+ ///
+ public static class PendingApprovals
+ {
+ /// Reads what a stored conversation still waits on.
+ /// The conversation, as the store holds it.
+ ///
+ /// One entry per open request, oldest first, shaped like ;
+ /// empty when nothing waits on the caller.
+ ///
+ public static IReadOnlyList Read(StoredConversation stored)
+ {
+ ArgumentNullException.ThrowIfNull(stored);
+
+ return Read(stored.Conversation);
+ }
+
+ /// Reads what a conversation row still waits on.
+ /// The conversation's row, carrying the state its last turn filed.
+ ///
+ /// One entry per open request, oldest first, shaped like ;
+ /// empty when nothing waits on the caller.
+ ///
+ public static IReadOnlyList Read(ConversationRecord record)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+
+ return record.State?.Providers is { } providers ? PendingApprovalQueue.OpenOf(providers) : [];
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/AgentHook.cs b/src/AgentCore.Application/Hooks/AgentHook.cs
index 0462e1c..84d58df 100644
--- a/src/AgentCore.Application/Hooks/AgentHook.cs
+++ b/src/AgentCore.Application/Hooks/AgentHook.cs
@@ -26,6 +26,15 @@ public virtual ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken canc
return default;
}
+ /// A request names a conversation, before AgentCore opens it or runs a turn on it. Refuse with .
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask BeforeConversationAsync(ConversationGate gate, CancellationToken cancellationToken)
+ {
+ return default;
+ }
+
/// The user's message arrives, before moderation and the model.
/// The facts and the verbs of this gate.
/// Cancelled at the gate's deadline.
diff --git a/src/AgentCore.Application/Hooks/GatePoint.cs b/src/AgentCore.Application/Hooks/GatePoint.cs
index 514e3d5..76574b1 100644
--- a/src/AgentCore.Application/Hooks/GatePoint.cs
+++ b/src/AgentCore.Application/Hooks/GatePoint.cs
@@ -1,7 +1,7 @@
namespace AgentCore.Application.Hooks
{
///
- /// One of the 13 points where AgentCore awaits its hooks. The point, not the hook, fixes the deadline and the
+ /// One of the 14 points where AgentCore awaits its hooks. The point, not the hook, fixes the deadline and the
/// default failure policy.
///
public sealed class GatePoint
@@ -20,6 +20,9 @@ private GatePoint(string name, TimeSpan deadline, HookFailure defaultFailure)
/// Gets the gate where a request needs an entry. Fails closed: the request is refused.
public static GatePoint BeforeEntry { get; } = new(nameof(BeforeEntry), TimeSpan.FromSeconds(2), HookFailure.Closed);
+ /// Gets the gate where a request names a conversation. Fails closed: the request is refused.
+ public static GatePoint BeforeConversation { get; } = new(nameof(BeforeConversation), TimeSpan.FromSeconds(2), HookFailure.Closed);
+
/// Gets the gate where the user's message arrives, before moderation and the model.
public static GatePoint BeforeTurn { get; } = new(nameof(BeforeTurn), TimeSpan.FromSeconds(2), HookFailure.Open);
@@ -56,7 +59,7 @@ private GatePoint(string name, TimeSpan deadline, HookFailure defaultFailure)
/// Gets every gate point, in gate order.
public static IReadOnlyList All { get; } =
[
- BeforeCall, BeforeEntry, BeforeTurn, BeforeRun, BeforeCompaction, BeforeModel, AfterModel,
+ BeforeCall, BeforeEntry, BeforeConversation, BeforeTurn, BeforeRun, BeforeCompaction, BeforeModel, AfterModel,
AfterModelFailed, BeforeToolApproval, BeforeTool, AfterTool, AfterToolFailed, AfterRun,
];
diff --git a/src/AgentCore.Application/Hooks/Gates/ConversationGate.cs b/src/AgentCore.Application/Hooks/Gates/ConversationGate.cs
new file mode 100644
index 0000000..ff3c661
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/ConversationGate.cs
@@ -0,0 +1,68 @@
+using System.Security.Claims;
+
+namespace AgentCore.Application.Hooks.Gates
+{
+ ///
+ /// A request names a conversation, before AgentCore opens it or runs a turn on it. A hook refuses a caller who may
+ /// not touch the conversation. Every turn on an HTTP route passes this gate, an approval answer too.
+ ///
+ public sealed class ConversationGate : HookGate
+ {
+ internal ConversationGate(
+ HookScope scope,
+ ClaimsPrincipal? user,
+ IReadOnlyDictionary headers,
+ string route,
+ string entry,
+ string conversationId,
+ EntryTransport transport,
+ bool resuming)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(headers);
+ ArgumentNullException.ThrowIfNull(route);
+ ArgumentNullException.ThrowIfNull(entry);
+ ArgumentNullException.ThrowIfNull(conversationId);
+
+ User = user;
+ Headers = headers;
+ Route = route;
+ Entry = entry;
+ ConversationId = conversationId;
+ Transport = transport;
+ Resuming = resuming;
+ }
+
+ /// Gets the authenticated user, or when the request is anonymous.
+ public ClaimsPrincipal? User { get; }
+
+ /// Gets the HTTP request's headers. A header with several values joins them with a comma.
+ public IReadOnlyDictionary Headers { get; }
+
+ /// Gets the route pattern that matched.
+ public string Route { get; }
+
+ /// Gets the entry the request runs.
+ public string Entry { get; }
+
+ /// Gets the conversation the request names. It may be anything the caller sent.
+ public string ConversationId { get; }
+
+ /// Gets how the request arrived.
+ public EntryTransport Transport { get; }
+
+ /// Gets whether the request answers an approval instead of sending words.
+ public bool Resuming { get; }
+
+ internal bool Refused { get; private set; }
+
+ ///
+ /// Refuses the request. Terminal. The caller gets the same answer as for a conversation that does not exist, so a
+ /// caller cannot learn which ids name somebody else's conversation.
+ ///
+ public void Refuse()
+ {
+ Stage(terminal: true, () => Refused = true);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs b/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs
index 1d91fa3..9549d08 100644
--- a/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs
+++ b/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs
@@ -8,5 +8,8 @@ public enum EntryTransport
/// A phone call.
Call,
+
+ /// An HTTP request on the AG-UI route.
+ AgUi,
}
}
diff --git a/src/AgentCore.Application/Hooks/Layers/ConversationGateChain.cs b/src/AgentCore.Application/Hooks/Layers/ConversationGateChain.cs
new file mode 100644
index 0000000..69fcf64
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/ConversationGateChain.cs
@@ -0,0 +1,58 @@
+using System.Security.Claims;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ /// What a request offers the conversation gate.
+ /// The authenticated user, or when the request is anonymous.
+ /// The HTTP request's headers.
+ /// The route pattern that matched.
+ /// The entry the request runs.
+ /// The conversation the request names.
+ /// How the request arrived.
+ /// Whether the request answers an approval.
+ internal sealed record ConversationOffer(
+ ClaimsPrincipal? User,
+ IReadOnlyDictionary Headers,
+ string Route,
+ string Entry,
+ string ConversationId,
+ EntryTransport Transport,
+ bool Resuming);
+
+ /// Asks the hooks whether a request may touch the conversation it names. No hook means yes.
+ internal static class ConversationGateChain
+ {
+ /// Runs the chain.
+ /// The compiled hooks.
+ /// The request on offer.
+ /// The request's token.
+ /// when no hook refused and none failed closed.
+ internal static async ValueTask AllowAsync(HookRuntime hooks, ConversationOffer offer, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(hooks);
+ ArgumentNullException.ThrowIfNull(offer);
+
+ if (!hooks.Gates.Overrides(GatePoint.BeforeConversation))
+ {
+ return true;
+ }
+
+ HookScope scope = HookScopes.ForGate(offer.ConversationId, offer.Entry, hooks.Timers.GetUtcNow());
+
+ bool refused = await hooks.Gates.RunAsync(
+ GatePoint.BeforeConversation,
+ scope,
+ false,
+ _ => new ConversationGate(scope, offer.User, offer.Headers, offer.Route, offer.Entry, offer.ConversationId, offer.Transport, offer.Resuming),
+ static (hook, gate, token) => hook.BeforeConversationAsync(gate, token),
+ static (state, gate) => state || gate.Refused,
+ static _ => true,
+ hooks.RaiseHostFault,
+ cancellationToken).ConfigureAwait(false);
+
+ return !refused;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt
index ce74225..e3e361b 100644
--- a/src/AgentCore.Application/PublicAPI.Unshipped.txt
+++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt
@@ -1151,6 +1151,8 @@ AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.MessageId.get -> strin
AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.MessageId.init -> void
AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.NamesParent.get -> bool
AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.NamesParent.init -> void
+AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.KeepsWithdrawnTools.get -> bool
+AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.KeepsWithdrawnTools.init -> void
AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.ParentMessageId.get -> string?
AgentCore.Application.Runtime.Turn.ConversationTurnOrigin.ParentMessageId.init -> void
AgentCore.Application.Runtime.Cut.TurnCut
@@ -1948,8 +1950,10 @@ AgentCore.Application.Blobs.BlobRead.MediaType.get -> string!
AgentCore.Application.Blobs.BlobRead.MediaType.init -> void
AgentCore.Application.Blobs.BlobRef
AgentCore.Application.Blobs.BlobRef.$() -> AgentCore.Application.Blobs.BlobRef!
-AgentCore.Application.Blobs.BlobRef.BlobRef(string! OwnerId, string! Name, string! MediaType, long Length) -> void
-AgentCore.Application.Blobs.BlobRef.Deconstruct(out string! OwnerId, out string! Name, out string! MediaType, out long Length) -> void
+AgentCore.Application.Blobs.BlobRef.BlobRef(string! OwnerId, string! Name, string! MediaType, long Length, string? DownloadName = null) -> void
+AgentCore.Application.Blobs.BlobRef.Deconstruct(out string! OwnerId, out string! Name, out string! MediaType, out long Length, out string? DownloadName) -> void
+AgentCore.Application.Blobs.BlobRef.DownloadName.get -> string?
+AgentCore.Application.Blobs.BlobRef.DownloadName.init -> void
AgentCore.Application.Blobs.BlobRef.Equals(AgentCore.Application.Blobs.BlobRef? other) -> bool
AgentCore.Application.Blobs.BlobRef.Length.get -> long
AgentCore.Application.Blobs.BlobRef.Length.init -> void
@@ -2365,6 +2369,7 @@ AgentCore.Application.Hooks.Notices.ConversationUnloaded.ConversationUnloaded(Ag
AgentCore.Application.Hooks.Notices.ConversationUnloaded.Deconstruct(out AgentCore.Application.Hooks.HookScope! Scope, out AgentCore.Application.Hooks.Notices.UnloadCause Cause) -> void
AgentCore.Application.Hooks.Notices.ConversationUnloaded.Equals(AgentCore.Application.Hooks.Notices.ConversationUnloaded? other) -> bool
AgentCore.Application.Hooks.Gates.EntryTransport
+AgentCore.Application.Hooks.Gates.EntryTransport.AgUi = 2 -> AgentCore.Application.Hooks.Gates.EntryTransport
AgentCore.Application.Hooks.Gates.EntryTransport.Call = 1 -> AgentCore.Application.Hooks.Gates.EntryTransport
AgentCore.Application.Hooks.Gates.EntryTransport.Http = 0 -> AgentCore.Application.Hooks.Gates.EntryTransport
AgentCore.Application.Hooks.Notices.Fault
@@ -2928,6 +2933,15 @@ AgentCore.Application.Hooks.Gates.CompactionGate.Cancel() -> void
AgentCore.Application.Hooks.Gates.CompactionGate.Items.get -> System.Collections.Generic.IDictionary!
AgentCore.Application.Hooks.Gates.CompactionGate.MessageCount.get -> int
AgentCore.Application.Hooks.Gates.CompactionGate.ReplaceSummary(string! text) -> void
+AgentCore.Application.Hooks.Gates.ConversationGate
+AgentCore.Application.Hooks.Gates.ConversationGate.ConversationId.get -> string!
+AgentCore.Application.Hooks.Gates.ConversationGate.Entry.get -> string!
+AgentCore.Application.Hooks.Gates.ConversationGate.Headers.get -> System.Collections.Generic.IReadOnlyDictionary!
+AgentCore.Application.Hooks.Gates.ConversationGate.Refuse() -> void
+AgentCore.Application.Hooks.Gates.ConversationGate.Resuming.get -> bool
+AgentCore.Application.Hooks.Gates.ConversationGate.Route.get -> string!
+AgentCore.Application.Hooks.Gates.ConversationGate.Transport.get -> AgentCore.Application.Hooks.Gates.EntryTransport
+AgentCore.Application.Hooks.Gates.ConversationGate.User.get -> System.Security.Claims.ClaimsPrincipal?
AgentCore.Application.Hooks.Gates.EntryGate
AgentCore.Application.Hooks.Gates.EntryGate.Choose(string! entry) -> void
AgentCore.Application.Hooks.Gates.EntryGate.Headers.get -> System.Collections.Generic.IReadOnlyDictionary!
@@ -3019,6 +3033,7 @@ static AgentCore.Application.Hooks.GatePoint.AfterTool.get -> AgentCore.Applicat
static AgentCore.Application.Hooks.GatePoint.All.get -> System.Collections.Generic.IReadOnlyList!
static AgentCore.Application.Hooks.GatePoint.BeforeCall.get -> AgentCore.Application.Hooks.GatePoint!
static AgentCore.Application.Hooks.GatePoint.BeforeCompaction.get -> AgentCore.Application.Hooks.GatePoint!
+static AgentCore.Application.Hooks.GatePoint.BeforeConversation.get -> AgentCore.Application.Hooks.GatePoint!
static AgentCore.Application.Hooks.GatePoint.BeforeEntry.get -> AgentCore.Application.Hooks.GatePoint!
static AgentCore.Application.Hooks.GatePoint.BeforeModel.get -> AgentCore.Application.Hooks.GatePoint!
static AgentCore.Application.Hooks.GatePoint.BeforeRun.get -> AgentCore.Application.Hooks.GatePoint!
@@ -3032,6 +3047,7 @@ virtual AgentCore.Application.Hooks.AgentHook.AfterToolAsync(AgentCore.Applicati
virtual AgentCore.Application.Hooks.AgentHook.AfterToolFailedAsync(AgentCore.Application.Hooks.Gates.ToolFailureGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
virtual AgentCore.Application.Hooks.AgentHook.BeforeCallAsync(AgentCore.Application.Hooks.Gates.CallGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
virtual AgentCore.Application.Hooks.AgentHook.BeforeCompactionAsync(AgentCore.Application.Hooks.Gates.CompactionGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
+virtual AgentCore.Application.Hooks.AgentHook.BeforeConversationAsync(AgentCore.Application.Hooks.Gates.ConversationGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
virtual AgentCore.Application.Hooks.AgentHook.BeforeEntryAsync(AgentCore.Application.Hooks.Gates.EntryGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
virtual AgentCore.Application.Hooks.AgentHook.BeforeModelAsync(AgentCore.Application.Hooks.Gates.ModelGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
virtual AgentCore.Application.Hooks.AgentHook.BeforeRunAsync(AgentCore.Application.Hooks.Gates.RunGate! gate, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
@@ -3194,3 +3210,13 @@ static AgentCore.Application.Conversation.Commands.SetVoicePlanCommand.operator
static AgentCore.Application.Conversation.Commands.SetVoicePlanCommand.operator !=(AgentCore.Application.Conversation.Commands.SetVoicePlanCommand? left, AgentCore.Application.Conversation.Commands.SetVoicePlanCommand? right) -> bool
const AgentCore.Application.Conversation.Commands.SetVoicePlanCommand.MaxLength = 1000 -> int
const AgentCore.Application.Tools.Builtin.BuiltinToolNames.VoicePlan = "voice.plan" -> string!
+AgentCore.Application.Conversation.PendingApprovals
+static AgentCore.Application.Conversation.PendingApprovals.Read(AgentCore.Application.Conversation.StoredConversation! stored) -> System.Collections.Generic.IReadOnlyList!
+static AgentCore.Application.Conversation.PendingApprovals.Read(AgentCore.Application.Conversation.ConversationRecord! record) -> System.Collections.Generic.IReadOnlyList!
+AgentCore.Application.Transcript.InboundFiles
+static AgentCore.Application.Transcript.InboundFiles.FileAsync(AgentCore.Application.Ports.IBlobStore! blobs, string! conversationId, Microsoft.Extensions.AI.ChatMessage! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
+const AgentCore.Application.Transcript.InboundFiles.FileIdPrefix = "upload:" -> string!
+static AgentCore.Application.Transcript.InboundFiles.Is(AgentCore.Application.Transcript.FileContent! file) -> bool
+static AgentCore.Application.Transcript.InboundFiles.RehydrateAsync(AgentCore.Application.Ports.IBlobStore! blobs, string! conversationId, System.Collections.Generic.IEnumerable! messages, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>
+AgentCore.Application.Configuration.Compilation.AgentCompilationContext.Blobs.get -> AgentCore.Application.Ports.IBlobStore?
+AgentCore.Application.Configuration.Compilation.AgentCompilationContext.Blobs.init -> void
diff --git a/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs
index edadbf3..e38b771 100644
--- a/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs
+++ b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs
@@ -151,6 +151,12 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session,
? TurnApprovalAnswers.MovedOn(userInput, open)
: userInput;
+ if (origin?.MessageId is { } messageId && string.IsNullOrEmpty(spoken.MessageId))
+ {
+ spoken = spoken.Clone();
+ spoken.MessageId = messageId;
+ }
+
_session.History.BeginTurn(session, _session.State.TurnIndex);
activity = AgentCoreTelemetry.StartTurn(_session.ConversationId, _session.State.TurnIndex, _session.State.Stage);
diff --git a/src/AgentCore.Application/Runtime/Compaction/ReaderContentFilterProvider.cs b/src/AgentCore.Application/Runtime/Compaction/ReaderContentFilterProvider.cs
index a7923f3..a57b485 100644
--- a/src/AgentCore.Application/Runtime/Compaction/ReaderContentFilterProvider.cs
+++ b/src/AgentCore.Application/Runtime/Compaction/ReaderContentFilterProvider.cs
@@ -1,28 +1,43 @@
+using AgentCore.Application.Ports;
+using AgentCore.Application.Runtime.Turn;
using AgentCore.Application.Transcript;
using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
namespace AgentCore.Application.Runtime.Compaction
{
///
- /// Removes reader content () from the messages bound for the model.
+ /// Removes reader content () from the messages bound for the model. A file the caller
+ /// attached is read back from the blob store first, so the model gets its bytes on every turn ().
///
- internal sealed class ReaderContentFilterProvider : AIContextProvider
+ internal sealed class ReaderContentFilterProvider(IBlobStore? blobs = null) : AIContextProvider
{
///
- protected override ValueTask InvokingCoreAsync(
+ protected override async ValueTask InvokingCoreAsync(
InvokingContext context, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
AIContext input = context.AIContext;
- return input.Messages is null
- ? new(input)
- : new(new AIContext
- {
- Instructions = input.Instructions,
- Messages = ReaderContent.Strip(input.Messages).ToList(),
- Tools = input.Tools,
- });
+ if (input.Messages is null)
+ {
+ return input;
+ }
+
+ IEnumerable messages = input.Messages;
+ if (blobs is not null
+ && TurnRegistry.For(context.Session)?.ConversationId is { } owner
+ && messages.Any(message => message.Contents.Any(content => content is FileContent file && InboundFiles.Is(file))))
+ {
+ messages = await InboundFiles.RehydrateAsync(blobs, owner, messages, cancellationToken).ConfigureAwait(false);
+ }
+
+ return new AIContext
+ {
+ Instructions = input.Instructions,
+ Messages = ReaderContent.Strip(messages).ToList(),
+ Tools = input.Tools,
+ };
}
}
}
diff --git a/src/AgentCore.Application/Runtime/Harness/PendingApprovalQueue.cs b/src/AgentCore.Application/Runtime/Harness/PendingApprovalQueue.cs
index 1012cab..48539fe 100644
--- a/src/AgentCore.Application/Runtime/Harness/PendingApprovalQueue.cs
+++ b/src/AgentCore.Application/Runtime/Harness/PendingApprovalQueue.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Text.Json.Nodes;
using AgentCore.Application.Transcript;
using AgentCore.Domain;
using Microsoft.Agents.AI;
@@ -139,6 +140,43 @@ internal static IReadOnlyList Read(JsonElement bag)
return pending;
}
+ ///
+ /// Reads the queued approvals no answer covers yet out of a stored conversation's provider state:
+ /// the queue minus the answers AgentCore holds and the ones MAF's approval layer already collected.
+ ///
+ /// What ConversationSessionState.Providers holds: each value a clone of one serialized state-bag entry.
+ /// The open approvals, oldest first; empty when the state carries no queue.
+ internal static IReadOnlyList OpenOf(IReadOnlyDictionary providers)
+ {
+ ArgumentNullException.ThrowIfNull(providers);
+
+ if (!providers.TryGetValue(PendingStateKey, out JsonElement queue))
+ {
+ return [];
+ }
+
+ JsonObject bag = new() { [PendingStateKey] = JsonNode.Parse(queue.GetRawText()) };
+ if (providers.TryGetValue(HeldStateKey, out JsonElement held))
+ {
+ bag[HeldStateKey] = JsonNode.Parse(held.GetRawText());
+ }
+
+ if (providers.TryGetValue(StandingStateKey, out JsonElement standing))
+ {
+ bag[StandingStateKey] = JsonNode.Parse(standing.GetRawText());
+ }
+
+ JsonElement envelope = JsonSerializer.SerializeToElement(bag);
+ List open = [.. Read(envelope)];
+ if (open.Count == 0)
+ {
+ return open;
+ }
+
+ HashSet answered = new(Collected(envelope), StringComparer.Ordinal);
+ return [.. open.Where(approval => !answered.Contains(approval.RequestId) && !IsHeld(envelope, approval.RequestId))];
+ }
+
/// Builds the answer message for one pending request.
/// What AgentSessionStateBag.Serialize produced.
/// The id the caller answers.
diff --git a/src/AgentCore.Application/Runtime/Turn/ConversationTurnOrigin.cs b/src/AgentCore.Application/Runtime/Turn/ConversationTurnOrigin.cs
index 2d6e858..e3864ec 100644
--- a/src/AgentCore.Application/Runtime/Turn/ConversationTurnOrigin.cs
+++ b/src/AgentCore.Application/Runtime/Turn/ConversationTurnOrigin.cs
@@ -13,5 +13,11 @@ public sealed record ConversationTurnOrigin(string? MessageId, string? ParentMes
{
/// Gets whether the caller answered for where this turn hangs.
public required bool NamesParent { get; init; }
+
+ ///
+ /// Gets whether the turn keeps the withdrawn tool calls and results even when its message id
+ /// is new.
+ ///
+ public bool KeepsWithdrawnTools { get; init; }
}
}
diff --git a/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnAgent.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnAgent.cs
index f9eb6ac..9951fd4 100644
--- a/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnAgent.cs
+++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnAgent.cs
@@ -161,8 +161,12 @@ protected override async IAsyncEnumerable RunCoreStreamingA
IReadOnlyList carried = [];
if (turn.Invocation.Origin is { NamesParent: true } origin)
{
- IReadOnlyList ran = TurnMessages.ToolPairs(_history.RowsAfter(turn.HistorySession, origin.ParentMessageId).Rows);
+ (IReadOnlyList Rows, bool _) after = _history.RowsAfter(turn.HistorySession, origin.ParentMessageId);
+
+ IReadOnlyList ran = TurnMessages.ToolPairs(after.Rows);
+
IReadOnlySet asked = TurnApprovalAnswers.Withdrawn(_history, turn.HistorySession, origin);
+
WithdrawnTurns? withdrawn = _history.CanTruncateFrom(turn.HistorySession, origin.ParentMessageId)
? await _history.TruncateFromAsync(turn.HistorySession, origin.ParentMessageId, cancellationToken).ConfigureAwait(false)
: await _history.CutUnderSummaryAsync(turn.HistorySession, origin.ParentMessageId, cancellationToken).ConfigureAwait(false);
@@ -170,7 +174,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA
if (withdrawn is { } taken)
{
turn.Completer.Superseded(taken);
- carried = ran;
+ carried = Resent(origin, after.Rows) ? ran : [];
PendingApprovalQueue.Withdraw(turn.HistorySession, asked);
}
}
@@ -183,6 +187,18 @@ protected override async IAsyncEnumerable RunCoreStreamingA
return (carried.Count == 0 ? request : [.. request, .. carried], carried);
}
+ ///
+ /// Whether the turn resends the user message it withdraws, rather than editing it. The rows are
+ /// s read off the live history, so a resent id is a user row of the
+ /// withdrawn span whose message id matches; the phone call keeps withdrawn tools on every ask.
+ ///
+ private static bool Resent(ConversationTurnOrigin origin, IReadOnlyList withdrawn)
+ {
+ return origin.KeepsWithdrawnTools
+ || (origin.MessageId is { } messageId
+ && withdrawn.Any(row => row.Role == ChatRole.User && row.MessageId == messageId));
+ }
+
/// Seals the turn: decide it, write it once, and raise its events.
/// The turn being sealed.
/// The tool calls and results the turn's edit kept from the turns it withdrew.
diff --git a/src/AgentCore.Application/Transcript/InboundFiles.cs b/src/AgentCore.Application/Transcript/InboundFiles.cs
new file mode 100644
index 0000000..e8e6375
--- /dev/null
+++ b/src/AgentCore.Application/Transcript/InboundFiles.cs
@@ -0,0 +1,141 @@
+using AgentCore.Application.Blobs;
+using AgentCore.Application.Ports;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Transcript;
+
+///
+/// Files the caller attached. On arrival each file's bytes go to the blob store under the conversation, and the
+/// message keeps a that names the blob, so a stored row never holds the bytes. The model
+/// still gets the bytes: reads them back each time the model is called.
+///
+public static class InboundFiles
+{
+ /// What starts with on a file the caller sent, followed by the caller's file name.
+ public const string FileIdPrefix = "upload:";
+
+ /// Whether a file reference names a file the caller sent, not one the reply published.
+ /// The reference.
+ public static bool Is(FileContent file)
+ {
+ ArgumentNullException.ThrowIfNull(file);
+
+ return file.Kept && file.FileId.StartsWith(FileIdPrefix, StringComparison.Ordinal);
+ }
+
+ /// Writes every of a message to the store and returns the message with references in their place.
+ /// The store.
+ /// The owner the blobs are written under; deleting the conversation deletes them.
+ /// The caller's message.
+ /// Cancels the writes.
+ /// The same instance when it carries no bytes; otherwise a copy.
+ public static async ValueTask FileAsync(IBlobStore blobs, string conversationId, ChatMessage message, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(blobs);
+ ArgumentNullException.ThrowIfNull(message);
+
+ if (!message.Contents.Any(content => content is DataContent))
+ {
+ return message;
+ }
+
+ List contents = [];
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is not DataContent data)
+ {
+ contents.Add(content);
+ continue;
+ }
+
+ string name = "in-" + Guid.NewGuid().ToString("N")[..16] + ExtensionOf(data.MediaType);
+ byte[] bytes = data.Data.ToArray();
+ using MemoryStream stream = new(bytes, writable: false);
+ BlobRef blob = await blobs.PutAsync(new BlobWrite(conversationId, name, data.MediaType, stream, bytes.Length), cancellationToken).ConfigureAwait(false);
+ contents.Add(new FileContent
+ {
+ Name = blob.Name,
+ FileId = FileIdPrefix + (data.Name ?? string.Empty),
+ Title = data.Name,
+ MediaType = blob.MediaType,
+ Length = blob.Length,
+ Kept = true,
+ });
+ }
+
+ return new ChatMessage(message.Role, contents)
+ {
+ AuthorName = message.AuthorName,
+ MessageId = message.MessageId,
+ CreatedAt = message.CreatedAt,
+ AdditionalProperties = message.AdditionalProperties,
+ };
+ }
+
+ ///
+ /// The same messages with each reference to a caller's file replaced by its bytes, read from the store. A file the
+ /// store no longer holds becomes a line of text saying so. Messages without one are returned as they are.
+ ///
+ /// The store.
+ /// The owner the blobs were written under.
+ /// The messages bound for the model.
+ /// Cancels the reads.
+ public static async ValueTask> RehydrateAsync(
+ IBlobStore blobs, string conversationId, IEnumerable messages, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(blobs);
+ ArgumentNullException.ThrowIfNull(messages);
+
+ List result = [];
+ foreach (ChatMessage message in messages)
+ {
+ if (!message.Contents.Any(content => content is FileContent file && Is(file)))
+ {
+ result.Add(message);
+ continue;
+ }
+
+ List contents = [];
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is not FileContent file || !Is(file))
+ {
+ contents.Add(content);
+ continue;
+ }
+
+ await using BlobRead? read = await blobs.OpenReadAsync(conversationId, file.Name, cancellationToken).ConfigureAwait(false);
+ if (read is null)
+ {
+ contents.Add(new TextContent($"[the attachment '{file.Title ?? file.Name}' is no longer stored]"));
+ continue;
+ }
+
+ using MemoryStream copy = new();
+ await read.Content.CopyToAsync(copy, cancellationToken).ConfigureAwait(false);
+ contents.Add(new DataContent(copy.ToArray(), file.MediaType) { Name = file.Title });
+ }
+
+ result.Add(new ChatMessage(message.Role, contents)
+ {
+ AuthorName = message.AuthorName,
+ MessageId = message.MessageId,
+ CreatedAt = message.CreatedAt,
+ AdditionalProperties = message.AdditionalProperties,
+ RawRepresentation = message.RawRepresentation,
+ });
+ }
+
+ return result;
+ }
+
+ private static string ExtensionOf(string mediaType) => mediaType.ToLowerInvariant() switch
+ {
+ "image/png" => ".png",
+ "image/jpeg" => ".jpg",
+ "image/gif" => ".gif",
+ "image/webp" => ".webp",
+ "application/pdf" => ".pdf",
+ _ => string.Empty,
+ };
+}
diff --git a/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj b/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj
index a698147..23d1fb5 100644
--- a/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj
+++ b/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj
@@ -15,6 +15,9 @@
+
+
+
diff --git a/src/AgentCore.AspNetCore/Calls/CallAsks.cs b/src/AgentCore.AspNetCore/Calls/CallAsks.cs
index 2b7a309..364c1df 100644
--- a/src/AgentCore.AspNetCore/Calls/CallAsks.cs
+++ b/src/AgentCore.AspNetCore/Calls/CallAsks.cs
@@ -156,7 +156,7 @@ private CallAnswer Fallback()
string words = older is null ? callerWords : older.Words + " " + callerWords;
CallAsk ask = new(words, older is null ? before : [.. older.Before, .. before], CancellationTokenSource.CreateLinkedTokenSource(cancellationToken));
- return (ask, new ConversationTurnOrigin(askId, _anchor) { NamesParent = true });
+ return (ask, new ConversationTurnOrigin(askId, _anchor) { NamesParent = true, KeepsWithdrawnTools = true });
}
private async Task ReplaceAsync(CallAsk older)
diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs
index b10c22b..51bd565 100644
--- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs
+++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs
@@ -272,6 +272,7 @@ internal async ValueTask BootAsync(CancellationToken cancellationToken)
Clock = _options.TimeProvider,
Hooks = hooks,
Secrets = secrets,
+ Blobs = blobs,
})
.ConfigureAwait(false);
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiApprovals.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiApprovals.cs
new file mode 100644
index 0000000..6d26dbc
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiApprovals.cs
@@ -0,0 +1,153 @@
+using System.Text.Json;
+using AGUI.Abstractions;
+using AGUI.Formatting;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Runtime.Harness;
+using AgentCore.Application.Runtime.Session;
+using AgentCore.Application.Transcript;
+using Microsoft.Agents.AI;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ ///
+ /// Turns a run's resume array into the turn's approval answers, by the AG-UI interrupt rules
+ /// (https://docs.ag-ui.com/concepts/interrupts, "Contract rules"):
+ ///
+ /// - One resume must answer every open interrupt; a partial one is refused and nothing is held.
+ /// - An id the conversation does not hold open is refused, unless the stored transcript already holds the same
+ /// answer for it: then the resume is a replay and the run ends with nothing done.
+ /// - resolved carries payload.approved and an optional payload.reason, which reaches the model in
+ /// the rejection. cancelled is a refusal with .
+ /// - New words beside a resume that cancels everything (assistant-ui's steerAway) run as a plain turn, so
+ /// the conversation refuses the open requests as "moved on" itself.
+ ///
+ ///
+ internal static class AgUiApprovals
+ {
+ internal const string CancelledReason = "the user dismissed the request without answering.";
+
+ /// What a resume comes to: the turn's message, a replay with nothing to do, or a refusal.
+ internal sealed record Reading(ChatMessage? Message, bool Replay = false, string? Code = null, string? Error = null);
+
+ ///
+ /// The new user words of a resume run: the last user message with text, when the client's own last message is a
+ /// user message. The converted messages cannot tell: the SDK adds the resume to them.
+ ///
+ internal static ChatMessage? WordsBeside(RunAgentInput input, IEnumerable messages)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(messages);
+
+ return input.Messages.LastOrDefault() is AGUIUserMessage
+ ? AgUiAttachments.LastUserInput([.. messages])
+ : null;
+ }
+
+ internal static async Task ReadAsync(
+ ConversationSession conversation, IList resume, ChatMessage? words, IConversations conversations, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(conversation);
+ ArgumentNullException.ThrowIfNull(resume);
+ ArgumentNullException.ThrowIfNull(conversations);
+
+ // A resumed conversation has run no turn in this process yet; opening it loads the queue its last turn filed.
+ AgentSession session = await conversation.Ledger.OpenSessionAsync(ct).ConfigureAwait(false);
+ Dictionary open = PendingApprovalQueue.Open(session).ToDictionary(r => r.RequestId, StringComparer.Ordinal);
+
+ if (resume.GroupBy(r => r.InterruptId, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1) is { } twice)
+ {
+ return new Reading(null, Code: "duplicate_resume", Error: $"the resume answers interrupt '{twice.Key}' more than once.");
+ }
+
+ if (resume.FirstOrDefault(r => !open.ContainsKey(r.InterruptId)) is { } unknown)
+ {
+ return open.Count == 0 && await AnsweredAlikeAsync(conversation.ConversationId, resume, conversations, ct).ConfigureAwait(false)
+ ? new Reading(null, Replay: true)
+ : new Reading(null, Code: "no_pending_approval", Error: $"the conversation queues no approval under id '{unknown.InterruptId}'.");
+ }
+
+ if (open.Keys.Where(id => !resume.Any(r => r.InterruptId == id)).ToList() is { Count: > 0 } missing)
+ {
+ return new Reading(null, Code: "resume_incomplete",
+ Error: $"one resume must answer every open interrupt; it answers {resume.Count} of {open.Count} (missing: {string.Join(", ", missing)}).");
+ }
+
+ if (words is not null && resume.All(r => r.Status == ResumeStatus.Cancelled))
+ {
+ return new Reading(words);
+ }
+
+ List answers = [];
+ foreach (AGUIResume entry in resume)
+ {
+ ToolApprovalResponseContent answer = conversation.TryCreateApprovalAnswer(entry.InterruptId, Approved(entry))!
+ .Contents.OfType().Single();
+ answer.Reason = ReasonOf(entry);
+ answers.Add(answer);
+ }
+
+ return new Reading(new ChatMessage(ChatRole.User, [.. answers, .. words?.Contents ?? []]));
+ }
+
+ internal static bool Approved(AGUIResume resume)
+ {
+ ArgumentNullException.ThrowIfNull(resume);
+
+ return resume.Status == ResumeStatus.Resolved
+ && resume.Payload is { ValueKind: JsonValueKind.Object } payload
+ && payload.TryGetProperty("approved", out JsonElement approved)
+ && approved.ValueKind == JsonValueKind.True;
+ }
+
+ private static string? ReasonOf(AGUIResume resume)
+ {
+ return resume.Status == ResumeStatus.Cancelled
+ ? CancelledReason
+ : resume.Payload is { ValueKind: JsonValueKind.Object } payload
+ && payload.TryGetProperty("reason", out JsonElement reason)
+ && reason.ValueKind == JsonValueKind.String
+ && reason.GetString() is { Length: > 0 } text
+ ? text
+ : null;
+ }
+
+ /// True when the stored transcript already answers every entry the same way, so the resume was sent before.
+ private static async Task AnsweredAlikeAsync(string conversationId, IList resume, IConversations conversations, CancellationToken ct)
+ {
+ if (await conversations.LoadWindowAsync(conversationId, new TranscriptWindow(null, int.MaxValue), ct).ConfigureAwait(false) is not { } stored)
+ {
+ return false;
+ }
+
+ Dictionary answered = stored.Messages
+ .SelectMany(r => r.Content.Contents.OfType())
+ .GroupBy(a => a.RequestId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Last().Approved, StringComparer.Ordinal);
+ return resume.All(r => answered.TryGetValue(r.InterruptId, out bool approved) && approved == Approved(r));
+ }
+
+ /// A replayed resume: the run starts and finishes with nothing done, so the client sees no error.
+ internal static async Task WriteReplayAsync(HttpContext http, RunAgentInput input)
+ {
+ ArgumentNullException.ThrowIfNull(http);
+ ArgumentNullException.ThrowIfNull(input);
+
+ http.Response.ContentType = SseEventStreamFormatter.ServerSentEventsMediaType;
+ await new SseEventStreamFormatter().WriteAsync(Each(
+ new RunStartedEvent { ThreadId = input.ThreadId, RunId = input.RunId },
+ new RunFinishedEvent { ThreadId = input.ThreadId, RunId = input.RunId }), http.Response.Body, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ private static async IAsyncEnumerable Each(params BaseEvent[] events)
+ {
+ foreach (BaseEvent evt in events)
+ {
+ await Task.Yield();
+ yield return evt;
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiAttachments.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiAttachments.cs
new file mode 100644
index 0000000..2b2ee7b
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiAttachments.cs
@@ -0,0 +1,99 @@
+using AgentCore.Application.Transcript;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.AspNetCore.Endpoints;
+
+///
+/// What a run's newest user message may carry. The limits guard the HTTP door and differ per
+/// host, not per agent.
+///
+internal sealed record AgUiAttachmentLimits
+{
+ /// Bytes one decoded part may hold.
+ public long MaxPartBytes { get; init; } = 5 * 1024 * 1024;
+
+ /// Bytes all decoded parts of one message may hold together.
+ public long MaxMessageBytes { get; init; } = 10 * 1024 * 1024;
+
+ /// How many non-text parts one message may carry.
+ public int MaxParts { get; init; } = 4;
+
+ /// Media types a part may declare. Matched exactly, case-insensitive.
+ public IReadOnlyList AllowedMediaTypes { get; init; } = ["image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf"];
+
+ /// Whether a part may name a URL instead of carrying its bytes. Off: the model vendor would fetch it.
+ public bool AllowUrlSources { get; init; }
+}
+
+/// Picks the run's newest user input and checks its attachments before any turn starts.
+internal static class AgUiAttachments
+{
+ public const string TooLarge = "attachment_too_large";
+ public const string TypeNotAllowed = "attachment_type_not_allowed";
+ public const string UrlNotAllowed = "attachment_url_not_allowed";
+ public const string TooMany = "attachment_too_many";
+
+ /// The last user message that carries words or a file; an image alone is a turn.
+ public static ChatMessage? LastUserInput(IReadOnlyList messages)
+ {
+ ChatMessage? picked = null;
+ foreach (ChatMessage message in messages)
+ {
+ if (message.Role == ChatRole.User
+ && (message.Text is { Length: > 0 } || message.Contents.Any(c => c is DataContent or UriContent)))
+ {
+ picked = message;
+ }
+ }
+
+ return picked;
+ }
+
+ /// Why the message is refused, or null when it may run.
+ public static (int Status, string Code, string Message)? Check(ChatMessage message, AgUiAttachmentLimits limits)
+ {
+ long total = 0;
+ int parts = 0;
+ foreach (AIContent content in message.Contents)
+ {
+ switch (content)
+ {
+ case UriContent uri when !limits.AllowUrlSources:
+ return (400, UrlNotAllowed, $"an attachment must carry its bytes; '{uri.Uri}' is a link.");
+ case UriContent uri when !Allowed(uri.MediaType, limits):
+ return (415, TypeNotAllowed, $"'{uri.MediaType}' is not an accepted attachment type.");
+ case DataContent data when !Allowed(data.MediaType, limits):
+ return (415, TypeNotAllowed, $"'{data.MediaType}' is not an accepted attachment type.");
+ case DataContent data when data.Data.Length > limits.MaxPartBytes:
+ return (413, TooLarge, $"'{data.Name ?? data.MediaType}' is {data.Data.Length} bytes; the limit is {limits.MaxPartBytes}.");
+ case DataContent data:
+ total += data.Data.Length;
+ parts++;
+ break;
+ case UriContent:
+ parts++;
+ break;
+ case FileContent file when !Allowed(file.MediaType, limits):
+ return (415, TypeNotAllowed, $"'{file.MediaType}' is not an accepted attachment type.");
+ case FileContent file when file.Length > limits.MaxPartBytes:
+ return (413, TooLarge, $"'{file.Title ?? file.Name}' is {file.Length} bytes; the limit is {limits.MaxPartBytes}.");
+ case FileContent file:
+ total += file.Length;
+ parts++;
+ break;
+ }
+ }
+
+ if (parts > limits.MaxParts)
+ {
+ return (400, TooMany, $"a message carries at most {limits.MaxParts} attachments; this one carries {parts}.");
+ }
+
+ return total > limits.MaxMessageBytes
+ ? (413, TooLarge, $"the attachments total {total} bytes; the limit is {limits.MaxMessageBytes}.")
+ : null;
+ }
+
+ private static bool Allowed(string mediaType, AgUiAttachmentLimits limits)
+ => limits.AllowedMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase);
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiContent.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiContent.cs
new file mode 100644
index 0000000..c58cf2a
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiContent.cs
@@ -0,0 +1,127 @@
+using System.Buffers;
+using System.Net.ServerSentEvents;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using AGUI.Abstractions;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ ///
+ /// The content rules of the AG-UI endpoint, for one run.
+ ///
+ /// - A compaction notice is one ACTIVITY_SNAPSHOT per pass (activityType: "compaction",
+ /// one messageId per turn, replace: true), so the end replaces the start in place.
+ /// - A failed tool answer leaves as TOOL_CALL_RESULT with isError: true and
+ /// _meta.agentcore.failed: the only failure flag react-ag-ui reads off the stream (its MCP
+ /// tool-result path). AGUI.Abstractions has no such field, so the SSE writer adds it.
+ /// - An update's AdditionalProperties never reach the converter: the fallback reply carries
+ /// a TurnDisposition holding the model's exception, and serializing it into rawEvent
+ /// ended the run with RUN_ERROR before the fallback text left.
+ ///
+ ///
+ internal sealed class AgUiContent(int turnIndex)
+ {
+ private readonly HashSet _failed = new(StringComparer.Ordinal);
+
+ /// Gets the activity id one compaction pass of this turn replaces in place.
+ public string CompactionMessageId => AgUiTurnIds.TurnMessageId(turnIndex) + ".compaction";
+
+ /// Remembers the calls whose answer AgentCore marked as a failure.
+ /// One browser part of a streaming update.
+ public void Note(TurnStreamPart part)
+ {
+ if (part.Payload is ToolPayload { Phase: "result", Failed: true } tool)
+ {
+ _ = _failed.Add(tool.CallId);
+ }
+ }
+
+ /// Reads whether the answer of one call was marked as a failure.
+ /// The id the call and its answer share.
+ public bool Failed(string callId) => _failed.Contains(callId);
+
+ /// Frames one compaction notice as the activity snapshot that replaces the pass's note in place.
+ /// The compaction part, carrying a .
+ public ActivitySnapshotEvent ActivityFor(TurnStreamPart part)
+ {
+ return new ActivitySnapshotEvent
+ {
+ MessageId = CompactionMessageId,
+ ActivityType = "compaction",
+ Content = JsonSerializer.SerializeToElement(part.Payload, part.Payload.GetType(), ResponsesJson.Options),
+ Replace = true,
+ };
+ }
+
+ ///
+ /// Strips an update's AdditionalProperties before AGUI.Server sees it: the converter
+ /// serializes every update into rawEvent first, and a value it cannot serialize
+ /// (the fallback's disposition holds the model's thrown exception) ends the run with
+ /// RUN_ERROR. This is the one strip; the yield below needs no second check.
+ ///
+ /// One update of the turn stream.
+ public static ChatResponseUpdate Prepare(ChatResponseUpdate update)
+ {
+ if (update.AdditionalProperties is not { Count: > 0 })
+ {
+ return update;
+ }
+
+ return new ChatResponseUpdate(update.Role, update.Contents)
+ {
+ MessageId = update.MessageId,
+ ResponseId = update.ResponseId,
+ ConversationId = update.ConversationId,
+ AuthorName = update.AuthorName,
+ CreatedAt = update.CreatedAt,
+ FinishReason = update.FinishReason,
+ ModelId = update.ModelId,
+ RawRepresentation = update.RawRepresentation,
+ };
+ }
+
+ /// Writes the events as SSE, the way SseEventStreamFormatter does, plus the failure flag.
+ /// The run's AG-UI events.
+ /// The response body.
+ /// The content rules of this run, naming the failed calls.
+ /// A token to cancel the write.
+ public static Task WriteAsync(
+ IAsyncEnumerable events,
+ Stream output,
+ AgUiContent content,
+ CancellationToken cancellationToken)
+ {
+ return SseFormatter.WriteAsync(
+ Items(events, cancellationToken),
+ output,
+ (item, writer) => Serialize(item.Data, writer, content),
+ cancellationToken);
+ }
+
+ private static async IAsyncEnumerable> Items(
+ IAsyncEnumerable events,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (BaseEvent evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
+ {
+ yield return new SseItem(evt);
+ }
+ }
+
+ private static void Serialize(BaseEvent evt, IBufferWriter buffer, AgUiContent content)
+ {
+ using Utf8JsonWriter writer = new(buffer);
+ if (evt is ToolCallResultEvent result && content.Failed(result.ToolCallId))
+ {
+ JsonObject node = JsonSerializer.SerializeToNode(evt, AGUIJsonSerializerContext.Default.BaseEvent)!.AsObject();
+ node["isError"] = true;
+ node["_meta"] = new JsonObject { ["agentcore"] = new JsonObject { ["failed"] = true } };
+ node.WriteTo(writer);
+ return;
+ }
+
+ JsonSerializer.Serialize(writer, evt, AGUIJsonSerializerContext.Default.BaseEvent);
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiEndpoints.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiEndpoints.cs
new file mode 100644
index 0000000..69d0b6d
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiEndpoints.cs
@@ -0,0 +1,343 @@
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Runtime;
+using AgentCore.Application.Runtime.Harness;
+using AgentCore.Application.Runtime.Session;
+using AgentCore.Application.Runtime.Turn;
+using AgentCore.Application.Runtime.Turn.Lifecycle;
+using AgentCore.Application.Transcript;
+using AgentCore.AspNetCore.DependencyInjection;
+using AgentCore.AspNetCore.DependencyInjection.Startup;
+using AGUI.Abstractions;
+using AGUI.Formatting;
+using AGUI.Server;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ ///
+ /// The AG-UI path: an AG-UI-compatible POST /agui/{entry} beside the Responses route.
+ /// A run clears the refusal prefix, then runs one turn of the entry's conversation and streams
+ /// the reply back as AG-UI events.
+ ///
+ public static class AgUiEndpointRouteBuilderExtensions
+ {
+ /// The route every entry answers on when the host names none.
+ public const string DefaultPattern = "/agui/{entry}";
+
+ /// Maps every entry on , with the URL naming the entry.
+ /// The route builder of the host.
+ /// The mapped endpoint, so a host adds its own conventions.
+ public static IEndpointConventionBuilder MapAgUi(this IEndpointRouteBuilder endpoints)
+ {
+ return endpoints.MapAgUi(DefaultPattern);
+ }
+
+ ///
+ /// Maps every entry on one route, with the thread routes (file door, link, turns) beside it, all behind the G-7
+ /// ownership hook. The thread routes name no entry: a host that declares two or more entries must supply a
+ /// hook that picks the entry, or they answer not found.
+ ///
+ /// The route builder of the host.
+ ///
+ /// The route to answer on. It carries the {entry} parameter, or a hook's
+ /// picks the entry. Startup fails when it has neither.
+ ///
+ /// The mapped endpoint, so a host adds its own conventions.
+ public static IEndpointConventionBuilder MapAgUi(this IEndpointRouteBuilder endpoints, string pattern)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentException.ThrowIfNullOrEmpty(pattern);
+
+ IEndpointConventionBuilder run = endpoints.MapPost(pattern, HandleAsync).WithMetadata(AgentCoreRouteMetadata.AgUi);
+
+ // The thread routes name no entry, so a second MapAgUi on the same builder would map them twice and make every request ambiguous.
+ if (ThreadRoutesMapped.TryAdd(endpoints, new object()))
+ {
+ _ = endpoints.MapAgUiFileLink();
+ _ = endpoints.MapAgUiFileDoor();
+ _ = endpoints.MapAgUiTurnHistory();
+ }
+
+ return run;
+ }
+
+ /// Runs one AG-UI run: the refusal prefix, then one turn of the entry's conversation.
+ private static async Task HandleAsync(HttpContext http)
+ {
+ CancellationToken cancellationToken = http.RequestAborted;
+ RunAgentInput? input;
+ try
+ {
+ input = await JsonSerializer.DeserializeAsync(http.Request.Body, AGUIJsonSerializerContext.Default.RunAgentInput, cancellationToken).ConfigureAwait(false);
+ }
+ catch (JsonException e)
+ {
+ await AgUiRefusal.WriteAsync(http, null, 400, "malformed_body", e.Message).ConfigureAwait(false);
+ return;
+ }
+
+ if (input is null || string.IsNullOrWhiteSpace(input.ThreadId))
+ {
+ await AgUiRefusal.WriteAsync(http, input, 400, "missing_thread", "a run names the thread it belongs to.").ConfigureAwait(false);
+ return;
+ }
+
+ if (await AgentCoreEntries.ResolveAsync(http, cancellationToken).ConfigureAwait(false) is not { } entry)
+ {
+ await AgUiRefusal.WriteAsync(http, input, 403, "entry_refused", "this route runs no entry for this caller.").ConfigureAwait(false);
+ return;
+ }
+
+ EntryRegistry entries = http.RequestServices.GetRequiredService().Entries;
+ if (!entries.Agents.TryGetValue(entry, out AgentCoreAgent? agent))
+ {
+ await AgUiRefusal.WriteAsync(http, input, 404, "unknown_entry", EntryRegistry.UnknownEntryMessage(entry, entries.Entries)).ConfigureAwait(false);
+ return;
+ }
+
+ string notFound = $"no thread '{input.ThreadId}' for this caller. Create the thread first.";
+ if (await http.RequestServices.GetRequiredService().GetAsync(input.ThreadId, cancellationToken).ConfigureAwait(false) is null)
+ {
+ await AgUiRefusal.WriteAsync(http, input, 404, AgentCoreConversationGate.NotFoundCode, notFound).ConfigureAwait(false);
+ return;
+ }
+
+ if (!await AgentCoreConversationGate.AllowAsync(http, entry, input.ThreadId, resuming: (input.Resume?.Count ?? 0) > 0, cancellationToken).ConfigureAwait(false))
+ {
+ await AgUiRefusal.WriteAsync(http, input, 404, AgentCoreConversationGate.NotFoundCode, notFound).ConfigureAwait(false);
+ return;
+ }
+
+ // AgentCore runs no client tools.
+ input.Tools = [];
+ ChatRequestContext context = input.ToChatRequestContext(Json);
+ IList resume = input.Resume ?? [];
+
+ // The client resends the whole thread every run; the conversation already holds it, so only the newest words count.
+ // Beside a resume, only words sent after the interrupted message count (assistant-ui's steerAway).
+ ChatMessage? words = resume.Count == 0
+ ? AgUiAttachments.LastUserInput(context.Messages)
+ : AgUiApprovals.WordsBeside(input, context.Messages);
+
+ if (resume.Count == 0 && words is null)
+ {
+ await AgUiRefusal.WriteAsync(http, input, 400, "no_user_message", "the run carries no user message with text or a file, so there is no turn to run.").ConfigureAwait(false);
+ return;
+ }
+
+ if (words is not null && resume.Count == 0)
+ {
+ (ChatMessage Resolved, (int Status, string Code, string Message)? Refused) resolved =
+ await AgUiFileRefs.ResolveAsync(
+ words,
+ input.ThreadId,
+ http.Request.PathBase,
+ http.RequestServices.GetRequiredService(),
+ cancellationToken).ConfigureAwait(false);
+ if (resolved.Refused is { } refused)
+ {
+ await AgUiRefusal.WriteAsync(http, input, refused.Status, refused.Code, refused.Message).ConfigureAwait(false);
+ return;
+ }
+
+ words = resolved.Resolved;
+ }
+ // A regenerate names the stored user row it re-asks; the row's files stand in for the
+ // body's bytes, so a live regenerate files nothing twice and an after-reload one (whose
+ // references ResolveAsync already restored) keeps working. A count mismatch files normally.
+ // Raw URLs never stand in: after resolve the only UriContent left is non-reference links,
+ // which the attachment check below must still refuse.
+ if (words is not null && resume.Count == 0
+ && words.Contents.Any(c => c is DataContent)
+ && AgUiTurnIds.Read(input).MessageId is { } named)
+ {
+ words = await StandInStoredFilesAsync(
+ words,
+ named,
+ input.ThreadId,
+ http.RequestServices.GetRequiredService(),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ if (words is not null && AgUiAttachments.Check(words, new AgUiAttachmentLimits()) is { } denied)
+ {
+ await AgUiRefusal.WriteAsync(http, input, denied.Status, denied.Code, denied.Message).ConfigureAwait(false);
+ return;
+ }
+
+ if (words is not null && AgUiInput.CheckSize(words) is { } tooLarge)
+ {
+ await AgUiRefusal.WriteAsync(http, input, tooLarge.Status, tooLarge.Code, tooLarge.Message).ConfigureAwait(false);
+ return;
+ }
+
+ ChatMessage? sanitized = words is null ? null : AgUiInput.Sanitize(words);
+
+ Microsoft.Agents.AI.AgentSession session;
+ try
+ {
+ session = await agent.CreateSessionAsync(input.ThreadId, cancellationToken).ConfigureAwait(false);
+ }
+ catch (ConversationInUseException e)
+ {
+ await AgUiRefusal.WriteAsync(http, input, 409, "conversation_in_use", e.Message).ConfigureAwait(false);
+ return;
+ }
+
+ ConversationSession conversation;
+ try
+ {
+ conversation = await agent.EnterRequestAsync(session, cancellationToken).ConfigureAwait(false);
+ }
+ catch (ConversationTurnConflictException)
+ {
+ await AgUiRefusal.WriteAsync(http, input, 409, ResponsesTurnConflict.Code, ResponsesTurnConflict.BusyMessage).ConfigureAwait(false);
+ return;
+ }
+
+ try
+ {
+ if (CallerTimeZone.Parse(http.Request.Headers[ResponsesEndpointRouteBuilderExtensions.TimeZoneHeaderName]) is { } zone)
+ {
+ CallerTimeZone.Set(session, zone);
+ }
+
+ ChatMessage? turnInput = sanitized;
+ ConversationTurnOrigin? origin = null;
+
+ if (resume.Count > 0)
+ {
+ AgUiApprovals.Reading reading = await AgUiApprovals.ReadAsync(
+ conversation,
+ resume,
+ sanitized,
+ http.RequestServices.GetRequiredService(),
+ cancellationToken).ConfigureAwait(false);
+
+ if (reading.Replay)
+ {
+ await AgUiApprovals.WriteReplayAsync(http, input).ConfigureAwait(false);
+ return;
+ }
+
+ if (reading.Message is null)
+ {
+ await AgUiRefusal.WriteAsync(http, input, 409, reading.Code!, reading.Error!).ConfigureAwait(false);
+ return;
+ }
+ turnInput = reading.Message;
+ }
+
+ // The caller's files go to the blob store; the row keeps a reference. Skipped when no store.
+ if (turnInput is not null && http.RequestServices.GetService() is { } blobs)
+ {
+ turnInput = await InboundFiles.FileAsync(blobs, conversation.ConversationId, turnInput, cancellationToken).ConfigureAwait(false);
+ }
+
+ // A resume run carries no anchor and the endpoint ignores one: only words name where the turn hangs.
+ if (sanitized is not null)
+ {
+ origin = await AgUiTurnIds.OriginAsync(
+ AgUiTurnIds.Read(input),
+ conversation.ConversationId,
+ http.RequestServices.GetRequiredService(),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ // Started before the headers: only a started turn has opened its stored state and caught up on
+ // other sessions', so only then does the stage name the one the turn speaks in.
+ // Without a resume the null-words run was refused above; with one the reading returned or set it.
+ await using TurnRun run = await conversation.StartTurnAsync(turnInput!, origin, cancellationToken).ConfigureAwait(false);
+ AgUiTurnIds.MessageIds ids = new(run.TurnIndex);
+
+ // A resumed approval re-runs calls the client's messages already show; their announcements
+ // are dropped so each result lands on the asking turn's part.
+ HashSet? shown = resume.Count == 0
+ ? null
+ : [.. context.Messages.SelectMany(m => m.Contents.OfType()).Select(c => c.CallId)];
+
+ http.Response.StatusCode = StatusCodes.Status200OK;
+ http.Response.ContentType = SseEventStreamFormatter.ServerSentEventsMediaType;
+ http.Response.Headers.CacheControl = "no-cache";
+ http.Response.Headers[ResponsesEndpointRouteBuilderExtensions.StageHeaderName] = conversation.Stage;
+
+ AgUiContent content = new(run.TurnIndex);
+
+ await AgUiContent.WriteAsync(
+ AgUiEventStream.EventsAsync(conversation, run.Updates, ids, context, shown, content, name => AgUiFileRefs.RefOf(http.Request, conversation.ConversationId, name), cancellationToken),
+ http.Response.Body,
+ content,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (ConversationTurnConflictException) when (!http.Response.HasStarted)
+ {
+ await AgUiRefusal.WriteAsync(http, input, 409, ResponsesTurnConflict.Code, ResponsesTurnConflict.ConflictMessage).ConfigureAwait(false);
+ }
+ catch (InvalidOperationException e) when (!http.Response.HasStarted)
+ {
+ await AgUiRefusal.WriteAsync(http, input, 409, "turn_refused", e.Message).ConfigureAwait(false);
+ }
+ finally
+ {
+ await conversation.Busy.ExitRequestAsync().ConfigureAwait(false);
+ }
+ }
+ ///
+ /// Puts the stored user row's files back in place of the body's bytes, when
+ /// names a stored user row of this conversation and the body carries the same count of media parts.
+ /// Anything else (a new message id, an edit that swaps files) keeps the body's bytes for the normal
+ /// file path. Text still comes from the body; only the file parts stand in.
+ ///
+ private static async ValueTask StandInStoredFilesAsync(
+ ChatMessage words, string named, string threadId, IConversations conversations, CancellationToken cancellationToken)
+ {
+ StoredConversation? stored = await conversations.LoadWindowAsync(threadId, new TranscriptWindow(null, int.MaxValue), cancellationToken).ConfigureAwait(false);
+ if (stored?.Messages.FirstOrDefault(r => r.MessageId == named && r.Content.Role == ChatRole.User) is not { } row)
+ {
+ return words;
+ }
+
+ List files = [.. row.Content.Contents.OfType()];
+ if (files.Count == 0 || files.Count != words.Contents.Count(c => c is DataContent))
+ {
+ return words;
+ }
+
+ int file = 0;
+ List contents = [];
+ foreach (AIContent content in words.Contents)
+ {
+ contents.Add(content is DataContent ? files[file++] : content);
+ }
+
+ return new ChatMessage(words.Role, contents)
+ {
+ AuthorName = words.AuthorName,
+ MessageId = words.MessageId,
+ CreatedAt = words.CreatedAt,
+ AdditionalProperties = words.AdditionalProperties,
+ RawRepresentation = words.RawRepresentation,
+ };
+ }
+
+
+ private static JsonSerializerOptions BuildJson()
+ {
+ JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { WriteIndented = false };
+ options.TypeInfoResolverChain.Add(AGUIJsonSerializerContext.Default);
+ options.MakeReadOnly();
+ return options;
+ }
+
+ private static readonly JsonSerializerOptions Json = BuildJson();
+
+ private static readonly ConditionalWeakTable ThreadRoutesMapped = new();
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiEventStream.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiEventStream.cs
new file mode 100644
index 0000000..5656a02
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiEventStream.cs
@@ -0,0 +1,221 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using AGUI.Abstractions;
+using AGUI.Server;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Runtime.Session;
+using AgentCore.Application.Runtime.Turn;
+using AgentCore.Application.Tools;
+using AgentCore.Application.Transcript;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ ///
+ /// The AG-UI events of one turn: AgentCore's own contents leave as CUSTOM events
+ /// beside AGUI.Server's converted stream, every event ships without its raw twin, and the
+ /// run opens and closes on a snapshot of the stage it speaks in.
+ ///
+ internal static class AgUiEventStream
+ {
+ ///
+ /// Converts one turn's reply into AG-UI events: the split stream through AGUI.Server,
+ /// stamped with this run's ids, framed by stage snapshots, with a mid-stream turn
+ /// conflict renamed to the code every other transport answers.
+ /// names the tool call ids the client's messages already show, or
+ /// outside a resume run: a resumed approval re-runs a call the client already shows, and
+ /// announcing it again makes assistant-ui file the result under a second copy it then drops,
+ /// so those announcements are dropped while their results still stream onto the asking turn's part.
+ /// carries the content rules of this run: sources held until the
+ /// words start, compaction as an in-place activity, the failure set, and the strip of an
+ /// update's AdditionalProperties before the converter sees it.
+ /// mints the absolute file-door reference for one stored blob
+ /// name, or off the AG-UI route: the turn's published files leave as
+ /// agentcore_file CUSTOM events at run finish, carrying ref, never a presigned URL.
+ ///
+ internal static async IAsyncEnumerable EventsAsync(
+ ConversationSession conversation,
+ IAsyncEnumerable updates,
+ AgUiTurnIds.MessageIds ids,
+ ChatRequestContext context,
+ IReadOnlySet? shownCalls,
+ AgUiContent content,
+ Func? fileRefOf,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(conversation);
+ ArgumentNullException.ThrowIfNull(updates);
+ ArgumentNullException.ThrowIfNull(ids);
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(content);
+
+ Exception? failure = null;
+ List heldSources = [];
+ TurnStreamFiles files = new();
+
+ await foreach (BaseEvent evt in Split(updates, files, content, e => failure = e, cancellationToken)
+ .AsAGUIEventStreamAsync(context, cancellationToken).ConfigureAwait(false))
+ {
+ evt.RawEvent = null;
+
+ if (shownCalls is not null
+ && (evt is ToolCallStartEvent { ToolCallId: var started } && shownCalls.Contains(started)
+ || evt is ToolCallArgsEvent { ToolCallId: var argued } && shownCalls.Contains(argued)
+ || evt is ToolCallEndEvent { ToolCallId: var ended } && shownCalls.Contains(ended)))
+ {
+ continue;
+ }
+
+ ids.Stamp(evt);
+
+ // A source held back until the model's words start (or the run ends): a data part between
+ // two tool calls splits assistant-ui's tool group, so live would draw two groups where a
+ // reload draws one. The parts land after the turn's tool calls, the same order as history.
+ if (evt is CustomEvent { Name: TurnStreamPart.Source })
+ {
+ heldSources.Add(evt);
+ continue;
+ }
+
+ if (evt is TextMessageStartEvent or RunFinishedEvent or RunErrorEvent && heldSources.Count > 0)
+ {
+ foreach (BaseEvent held in heldSources)
+ {
+ yield return held;
+ }
+
+ heldSources.Clear();
+ }
+
+ if (evt is RunFinishedEvent)
+ {
+ if (fileRefOf is not null)
+ {
+ foreach (TurnStreamPart part in files.Resolve())
+ {
+ FilePayload payload = (FilePayload)part.Payload;
+ yield return new CustomEvent
+ {
+ Name = part.Member,
+ Value = JsonSerializer.SerializeToElement(payload with { Ref = fileRefOf(payload.Name) }, ResponsesJson.Options),
+ };
+ }
+ }
+
+ yield return Stage(conversation, after: true);
+ }
+
+ if (evt is RunErrorEvent error && failure is ConversationTurnConflictException)
+ {
+ error.Code = ResponsesTurnConflict.Code;
+ error.Message = ResponsesTurnConflict.ConflictMessage;
+ }
+
+ yield return evt;
+
+ if (evt is RunStartedEvent)
+ {
+ yield return Stage(conversation, after: false);
+ }
+ }
+ }
+
+ ///
+ /// Takes AgentCore's own contents off each update before AGUI.Server sees them: the converter
+ /// serializes every update into rawEvent first, and a content type it does not know ends the
+ /// run with RUN_ERROR. Sources and the commit leave as CUSTOM events, compaction as an
+ /// in-place activity snapshot, and an update's AdditionalProperties are stripped first
+ /// (see ): the fallback's disposition holds the model's thrown
+ /// exception, which the converter cannot serialize.
+ ///
+ private static async IAsyncEnumerable Split(
+ IAsyncEnumerable updates,
+ TurnStreamFiles files,
+ AgUiContent content,
+ Action onFailure,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ToolCallNames names = new();
+ await using IAsyncEnumerator reader = updates.GetAsyncEnumerator(cancellationToken);
+ while (true)
+ {
+ try
+ {
+ if (!await reader.MoveNextAsync().ConfigureAwait(false))
+ {
+ yield break;
+ }
+ }
+ catch (Exception e) when (e is not OperationCanceledException)
+ {
+ onFailure(e);
+ throw;
+ }
+ ChatResponseUpdate update = AgUiContent.Prepare(reader.Current);
+ bool notice = update.Contents.Any(c => c is NoticeContent or TurnCommittedContent);
+ if (!notice)
+ {
+ files.Note(update);
+ }
+ foreach (TurnStreamPart part in TurnStreamParts.From(update, names))
+ {
+ content.Note(part);
+
+ if (part.Member is TurnStreamPart.Compaction)
+ {
+ yield return new ChatResponseUpdate { RawRepresentation = content.ActivityFor(part) };
+ }
+ else if (part.Member is TurnStreamPart.Source or TurnStreamPart.MessageCommitted)
+ {
+ yield return new ChatResponseUpdate
+ {
+ RawRepresentation = new CustomEvent
+ {
+ Name = part.Member,
+ Value = JsonSerializer.SerializeToElement(part.Payload, part.Payload.GetType(), ResponsesJson.Options),
+ },
+ };
+ }
+ }
+
+ if (notice)
+ {
+ continue;
+ }
+
+ List rest = [.. update.Contents.Where(c => c is not (SourceContent or FileContent))];
+ if (rest.Count == update.Contents.Count)
+ {
+ yield return update;
+ }
+ else if (rest.Count > 0)
+ {
+ yield return new ChatResponseUpdate(update.Role, rest)
+ {
+ MessageId = update.MessageId,
+ ResponseId = update.ResponseId,
+ AuthorName = update.AuthorName,
+ CreatedAt = update.CreatedAt,
+ FinishReason = update.FinishReason,
+ ModelId = update.ModelId,
+ };
+ }
+ }
+ }
+
+ private static StateSnapshotEvent Stage(ConversationSession conversation, bool after)
+ {
+ JsonObject state = new()
+ {
+ ["stage"] = after ? conversation.LastTurn?.StageAfter ?? conversation.Stage : conversation.Stage,
+ ["phase"] = after ? "after" : "before",
+ };
+ if (after)
+ {
+ state["isTerminal"] = conversation.LastTurn?.IsTerminal ?? conversation.IsComplete;
+ }
+
+ return new StateSnapshotEvent { Snapshot = JsonSerializer.SerializeToElement(state) };
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiFileRefs.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiFileRefs.cs
new file mode 100644
index 0000000..812dc7d
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiFileRefs.cs
@@ -0,0 +1,263 @@
+using AgentCore.Application.Blobs;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Transcript;
+using AgentCore.AspNetCore.DependencyInjection;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AgentCore.AspNetCore.Endpoints;
+
+///
+/// The one reference the browser holds for a stored file: the absolute URL of the host's file
+/// door, {origin}{pathBase}/agui/threads/{threadId}/files/{blobName}. It carries no
+/// secret and never expires. The browser swaps it for a fresh link when it draws the file
+/// (GET {reference}/link), and sends it back as it is on a regenerate or an edit, where
+/// turns it into the stored file again.
+///
+internal static class AgUiFileRefs
+{
+ /// The reference names a file of another conversation.
+ public const string NotOwned = "attachment_not_owned";
+
+ /// The reference is shaped right but names no file this conversation kept.
+ public const string NotFound = "attachment_not_found";
+
+ /// The reference's file name is not a plain blob name (a path, .., a control character).
+ public const string Invalid = "attachment_ref_invalid";
+
+ public static string PathOf(string threadId, string name)
+ => $"/agui/threads/{Uri.EscapeDataString(threadId)}/files/{Uri.EscapeDataString(name)}";
+
+ /// The reference to one file, absolute on the origin the request came in on.
+ public static string RefOf(HttpRequest request, string threadId, string name)
+ => $"{request.Scheme}://{request.Host}{request.PathBase}{PathOf(threadId, name)}";
+
+ ///
+ /// Whether a URL is shaped like a reference: an http(s) or root-relative URL whose path is exactly
+ /// {pathBase}/agui/threads/{threadId}/files/{name}. The origin is not compared: the reference is only ever
+ /// looked up in this host's own store, so a foreign origin can name nothing but this host's files.
+ ///
+ public static bool TryRead(Uri uri, PathString pathBase, out string threadId, out string name)
+ {
+ threadId = name = string.Empty;
+ string path;
+ if (uri.IsAbsoluteUri)
+ {
+ if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
+ {
+ return false;
+ }
+
+ path = uri.AbsolutePath;
+ }
+ else
+ {
+ path = uri.OriginalString.Split('?', '#')[0];
+ }
+
+ if (pathBase.HasValue)
+ {
+ if (!path.StartsWith(pathBase.Value!, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ path = path[pathBase.Value!.Length..];
+ }
+
+ string[] segments = path.Split('/');
+ if (segments is not ["", "agui", "threads", { Length: > 0 } thread, "files", { Length: > 0 } file])
+ {
+ return false;
+ }
+
+ threadId = Uri.UnescapeDataString(thread);
+ name = Uri.UnescapeDataString(file);
+ return true;
+ }
+
+ ///
+ /// Puts the stored file back in place of each reference the message carries. A reference is honoured only when it
+ /// names this conversation, a plain blob name, and a file one of this conversation's rows kept; anything else
+ /// refuses the run before a turn starts. A link that is not a reference is left alone for the attachment check.
+ ///
+ public static async Task<(ChatMessage Message, (int Status, string Code, string Message)? Refused)> ResolveAsync(
+ ChatMessage words, string threadId, PathString pathBase, IConversations conversations, CancellationToken ct)
+ {
+ if (!words.Contents.Any(c => c is UriContent))
+ {
+ return (words, null);
+ }
+
+ StoredConversation? stored = null;
+ List contents = [];
+ bool changed = false;
+ foreach (AIContent content in words.Contents)
+ {
+ if (content is not UriContent uri || !TryRead(uri.Uri, pathBase, out string owner, out string name))
+ {
+ contents.Add(content);
+ continue;
+ }
+
+ if (!string.Equals(owner, threadId, StringComparison.Ordinal))
+ {
+ return (words, (403, NotOwned, "the attachment names a file of another conversation."));
+ }
+
+ if (!BlobName.IsSafe(name))
+ {
+ return (words, (400, Invalid, "the attachment's file name is not a plain name."));
+ }
+
+ stored ??= await conversations.LoadWindowAsync(threadId, new TranscriptWindow(null, int.MaxValue), ct).ConfigureAwait(false);
+ if (KeptFile(stored, name) is not { } file)
+ {
+ return (words, (404, NotFound, $"this conversation kept no file named '{name}'."));
+ }
+
+ contents.Add(InboundFiles.Is(file)
+ ? file
+ : new FileContent
+ {
+ Name = file.Name,
+ FileId = InboundFiles.FileIdPrefix + file.Name,
+ Title = file.Name,
+ MediaType = file.MediaType,
+ Length = file.Length,
+ Kept = true,
+ });
+ changed = true;
+ }
+
+ return changed
+ ? (new ChatMessage(words.Role, contents)
+ {
+ AuthorName = words.AuthorName,
+ MessageId = words.MessageId,
+ CreatedAt = words.CreatedAt,
+ AdditionalProperties = words.AdditionalProperties,
+ RawRepresentation = words.RawRepresentation,
+ }, null)
+ : (words, null);
+ }
+
+ /// The newest kept file a row of the conversation names, or null.
+ private static FileContent? KeptFile(StoredConversation? stored, string name)
+ => stored?.Messages.SelectMany(r => r.Content.Contents.OfType())
+ .LastOrDefault(f => f.Kept && string.Equals(f.Name, name, StringComparison.Ordinal));
+
+ /// The name a download is saved under: the caller's own file name for an upload, the blob name otherwise.
+ private static async Task DownloadNameAsync(IConversations conversations, string threadId, string name, CancellationToken ct)
+ {
+ StoredConversation? stored = await conversations.LoadWindowAsync(threadId, new TranscriptWindow(null, int.MaxValue), ct).ConfigureAwait(false);
+ return KeptFile(stored, name) is { } file && InboundFiles.Is(file) && !string.IsNullOrWhiteSpace(file.Title) ? file.Title : name;
+ }
+
+ /// How long the links the link route mints live.
+ public static TimeSpan LinkLifetime { get; } = BlobLink.Lifetime;
+
+ ///
+ /// GET /agui/threads/{threadId}/files/{name}/link: a fresh link to one stored file, and when it stops working.
+ /// A store with no web door answers the reference itself, which the door below serves.
+ /// Behind the G-7 ownership hook, like every other thread route. A host that declares two or more entries must supply a BeforeEntry hook that picks the entry, or these routes answer not found.
+ ///
+ public static IEndpointConventionBuilder MapAgUiFileLink(this IEndpointRouteBuilder endpoints)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+
+ // The door is global, not per entry: the reference names no entry, and entry resolution runs per
+ // request inside the handler (a BeforeEntry hook still picks the entry for the ownership gate).
+ // No AgUi route metadata: the startup entry check only walks tagged routes.
+ return endpoints.MapGet("/agui/threads/{threadId}/files/{name}/link", async (
+ HttpContext http, string threadId, string name, IConversations conversations, CancellationToken ct) =>
+ {
+ if (!await OwnedAsync(http, threadId, ct).ConfigureAwait(false))
+ {
+ return Results.NotFound();
+ }
+
+ if (http.RequestServices.GetService() is not { } blobs || !BlobOwner.IsSafe(threadId) || !BlobName.IsSafe(name)
+ || await blobs.StatAsync(threadId, name, ct).ConfigureAwait(false) is not { } blob)
+ {
+ return Results.NotFound();
+ }
+
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ Uri? url = await blobs.LinkAsync(blob with { DownloadName = await DownloadNameAsync(conversations, threadId, name, ct).ConfigureAwait(false) }, LinkLifetime, ct).ConfigureAwait(false);
+ http.Response.Headers.CacheControl = "no-store";
+ return Results.Json(url is null
+ ? new { url = RefOf(http.Request, threadId, name), expiresAt = (long?)null }
+ : new { url = url.ToString(), expiresAt = (long?)(now + LinkLifetime).ToUnixTimeMilliseconds() });
+ });
+ }
+
+ ///
+ /// GET /agui/threads/{threadId}/files/{name}: the reference itself, so a client that draws it without a swap
+ /// still gets the file. A store with a web door redirects to a fresh link; one without streams the bytes.
+ /// Either way the download keeps the caller's file name. Behind the G-7 ownership hook. A host that declares two or more entries must supply a BeforeEntry hook that picks the entry, or these routes answer not found.
+ ///
+ public static IEndpointConventionBuilder MapAgUiFileDoor(this IEndpointRouteBuilder endpoints)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+
+ // Global like the link route above: the reference names no entry.
+ return endpoints.MapGet("/agui/threads/{threadId}/files/{name}", async (
+ HttpContext http, string threadId, string name, IConversations conversations, CancellationToken ct) =>
+ {
+ if (!await OwnedAsync(http, threadId, ct).ConfigureAwait(false))
+ {
+ return Results.NotFound();
+ }
+
+ if (http.RequestServices.GetService() is not { } blobs || !BlobOwner.IsSafe(threadId) || !BlobName.IsSafe(name)
+ || await blobs.StatAsync(threadId, name, ct).ConfigureAwait(false) is not { } blob)
+ {
+ return Results.NotFound();
+ }
+
+ string download = await DownloadNameAsync(conversations, threadId, name, ct).ConfigureAwait(false);
+ if (await blobs.LinkAsync(blob with { DownloadName = download }, LinkLifetime, ct).ConfigureAwait(false) is { } url)
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ return Results.Redirect(url.ToString());
+ }
+
+ if (await blobs.OpenReadAsync(threadId, name, ct).ConfigureAwait(false) is not { } read)
+ {
+ return Results.NotFound();
+ }
+ http.Response.Headers.ContentDisposition = BlobLink.DispositionOf(read.MediaType, download);
+ return Results.Stream(read.Content, read.MediaType);
+ });
+ }
+
+ ///
+ /// Runs the G-7 ownership hook for one thread-scoped request: unknown or refused reads as not found. The entry is
+ /// the BeforeEntry hook's pick, else the one declared entry; with neither, the request reads as not found.
+ ///
+ internal static async ValueTask OwnedAsync(HttpContext http, string threadId, CancellationToken ct)
+ {
+ string? entry = await AgentCoreEntries.ResolveAsync(http, ct).ConfigureAwait(false);
+ if (entry is null && http.RequestServices.GetRequiredService().Entries.Entries is { Count: 1 } declared)
+ {
+ entry = declared.First();
+ }
+
+ if (entry is null)
+ {
+ return false;
+ }
+
+ if (await http.RequestServices.GetRequiredService().GetAsync(threadId, ct).ConfigureAwait(false) is null)
+ {
+ return false;
+ }
+
+ return await AgentCoreConversationGate.AllowAsync(http, entry, threadId, resuming: false, ct).ConfigureAwait(false);
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiInput.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiInput.cs
new file mode 100644
index 0000000..d06b748
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiInput.cs
@@ -0,0 +1,63 @@
+using AgentCore.Application.Transcript;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ ///
+ /// The AG-UI route's input rules: only the newest user message with words or a file
+ /// is read — history comes from the store — and what reaches the turn is words plus
+ /// the caller's files (filed to the blob store downstream, re-resolved downstream).
+ ///
+ internal static class AgUiInput
+ {
+ /// Bytes of text one message may carry. Below Kestrel's 30 MB (spec rule 7).
+ public const long MaxMessageChars = 5 * 1024 * 1024;
+
+ public const string TooLargeCode = "message_too_large";
+
+ /// Checks the picked message against the per-message size cap.
+ /// The refusal, or null when the message may run.
+ public static (int Status, string Code, string Message)? CheckSize(ChatMessage message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ long size = 0;
+ foreach (AIContent content in message.Contents)
+ {
+ switch (content)
+ {
+ case TextContent text:
+ size += text.Text.Length;
+ break;
+ case DataContent data:
+ size += data.Data.Length;
+ break;
+ }
+ }
+
+ return size > MaxMessageChars
+ ? (400, TooLargeCode, $"a message carries at most {MaxMessageChars} characters of text and bytes of attachments; this one carries {size}.")
+ : null;
+ }
+
+ ///
+ /// Sanitizes the picked message for the turn: clears the author name and keeps text plus the
+ /// caller's files (data parts are filed downstream; URL parts are resolved or refused downstream;
+ /// file parts are the stored files resolve and the regenerate dedupe already restored).
+ ///
+ public static ChatMessage Sanitize(ChatMessage message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ List kept = [.. message.Contents.Where(content => content is TextContent or DataContent or UriContent or FileContent)];
+ ChatMessage sanitized = new(message.Role, kept)
+ {
+ MessageId = message.MessageId,
+ CreatedAt = message.CreatedAt,
+ AdditionalProperties = message.AdditionalProperties,
+ RawRepresentation = message.RawRepresentation,
+ };
+ return sanitized;
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiRefusal.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiRefusal.cs
new file mode 100644
index 0000000..ea843cd
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiRefusal.cs
@@ -0,0 +1,45 @@
+using AGUI.Abstractions;
+using AGUI.Formatting;
+using Microsoft.AspNetCore.Http;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ ///
+ /// A run refused before it started. By default it is a 200 stream whose only event is RUN_ERROR with the code,
+ /// because HttpAgent hands a non-2xx answer to the app as an Error with the raw body and no code.
+ /// ?refusals=status answers with the HTTP status and a JSON body instead, for comparison.
+ ///
+ internal static class AgUiRefusal
+ {
+ public static async Task WriteAsync(HttpContext http, RunAgentInput? input, int status, string code, string message)
+ {
+ if (http.Request.Query["refusals"] == "status")
+ {
+ http.Response.StatusCode = status;
+ await http.Response.WriteAsJsonAsync(new { error = new { code, message } });
+ return;
+ }
+
+ List events = [];
+ if (http.Request.Query["refusals"] == "started")
+ {
+ events.Add(new RunStartedEvent { ThreadId = input?.ThreadId ?? string.Empty, RunId = input?.RunId ?? string.Empty });
+ }
+
+ events.Add(new RunErrorEvent { Code = code, Message = message });
+
+ http.Response.StatusCode = StatusCodes.Status200OK;
+ http.Response.ContentType = SseEventStreamFormatter.ServerSentEventsMediaType;
+ await new SseEventStreamFormatter().WriteAsync(Each(events), http.Response.Body, CancellationToken.None);
+ }
+
+ private static async IAsyncEnumerable Each(IEnumerable events)
+ {
+ foreach (BaseEvent evt in events)
+ {
+ await Task.Yield();
+ yield return evt;
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiTurnHistory.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiTurnHistory.cs
new file mode 100644
index 0000000..b209c7c
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiTurnHistory.cs
@@ -0,0 +1,276 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using AgentCore.Application.Blobs;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Runtime.Harness;
+using AgentCore.Application.Transcript;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.AspNetCore.Endpoints;
+
+///
+/// GET /agui/threads/{threadId}/turns: the stored conversation as assistant-ui messages
+/// (ExportedMessageRepository.fromArray input), one assistant message per turn, named turn-{k} as the
+/// live stream names it. Parts keep row order (text, tool-call, text); a tool result is matched to its call by call
+/// id across turns, because an approved call's result is written by the turn after the one that asked.
+/// An approval splits the reply in two, as the live stream does: the asking turn stores the request (shown as the
+/// tool call), and the resuming turn re-stores the same call, which is skipped there.
+/// One read answers the newest limit turns before the before cursor; nextCursor asks for the next older page.
+/// Behind the G-7 ownership hook. A host that declares two or more entries must supply a BeforeEntry hook that picks the entry, or these routes answer not found.
+///
+internal static class AgUiTurnHistory
+{
+ /// How many turns a page holds when the caller names no limit.
+ public const int DefaultTurns = 30;
+
+ /// The most turns one page holds, whatever the caller asked for.
+ public const int MaxTurns = 100;
+
+ public static IEndpointConventionBuilder MapAgUiTurnHistory(this IEndpointRouteBuilder endpoints)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+
+ // Global like the file doors: the reference names no entry, and entry resolution runs per
+ // request inside the ownership gate. No AgUi route metadata: the startup entry check only
+ // walks tagged routes.
+ return endpoints.MapGet("/agui/threads/{threadId}/turns", async (
+ HttpContext http, string threadId, string? before, int? limit, IConversations conversations, CancellationToken ct) =>
+ {
+ if (!await AgUiFileRefs.OwnedAsync(http, threadId, ct).ConfigureAwait(false))
+ {
+ return Results.NotFound();
+ }
+
+ int? beforeTurn = null;
+ if (before is not null)
+ {
+ if (!int.TryParse(before, NumberStyles.None, CultureInfo.InvariantCulture, out int turnIndex))
+ {
+ return Results.Problem(
+ $"'{before}' is not a cursor. Send back the nextCursor a page answered with, or nothing at all.",
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ beforeTurn = turnIndex;
+ }
+
+ // One turn past each edge of the page: an approval's asking turn and its resuming turn may straddle it.
+ int turns = Math.Clamp(limit ?? DefaultTurns, 1, MaxTurns);
+ TranscriptWindow window = new(beforeTurn is { } edge ? Math.Min(edge, int.MaxValue - 1) + 1 : null, turns + 2);
+ if (await conversations.LoadWindowAsync(threadId, window, ct).ConfigureAwait(false) is not { } stored)
+ {
+ return Results.NotFound();
+ }
+
+ HttpRequest request = http.Request;
+ List rows = [.. stored.Messages.OrderBy(r => r.Ordinal)];
+ List below = [.. rows.Select(r => r.TurnIndex).Distinct().Where(t => beforeTurn is not { } edge || t < edge).Order()];
+ HashSet page = [.. below.TakeLast(turns)];
+ Dictionary results = rows
+ .SelectMany(r => r.Content.Contents.OfType())
+ .GroupBy(r => r.CallId)
+ .ToDictionary(g => g.Key, g => g.Last().Result);
+
+ Dictionary pending = PendingByCallId(stored.Conversation.State);
+ HashSet asked = [];
+ JsonArray messages = [];
+ foreach (IGrouping turn in rows.GroupBy(r => r.TurnIndex))
+ {
+ bool shown = page.Contains(turn.Key);
+ JsonArray parts = [];
+ string? userId = null;
+ foreach (ConversationMessage row in turn)
+ {
+ ChatMessage message = row.Content;
+ if (message.Role == ChatRole.User)
+ {
+ userId ??= row.MessageId;
+ JsonArray said = [.. message.Contents.OfType().Where(t => t.Text.Length > 0)
+ .Select(t => (JsonNode)new JsonObject { ["type"] = "text", ["text"] = t.Text })];
+ JsonArray attachments = Attachments(message, request, threadId);
+ if (shown && (said.Count > 0 || attachments.Count > 0))
+ {
+ messages.Add(new JsonObject
+ {
+ ["id"] = row.MessageId,
+ ["role"] = "user",
+ ["content"] = said,
+ ["attachments"] = attachments,
+ });
+ }
+
+ continue;
+ }
+
+ // A file the agent published is kept on the tool row of the call that published it.
+ foreach (FileContent made in message.Contents.OfType().Where(f => f.Kept))
+ {
+ parts.Add(new JsonObject
+ {
+ ["type"] = "file",
+ ["data"] = AgUiFileRefs.RefOf(request, threadId, made.Name),
+ ["mimeType"] = made.MediaType,
+ ["filename"] = made.Name,
+ });
+ }
+
+ if (message.Role != ChatRole.Assistant)
+ {
+ continue;
+ }
+
+ if (message.Text is { Length: > 0 } text)
+ {
+ parts.Add(new JsonObject { ["type"] = "text", ["text"] = text });
+ }
+
+ IEnumerable calls = message.Contents
+ .Select(c => c switch
+ {
+ ToolApprovalRequestContent { ToolCall: FunctionCallContent request } => asked.Add(request.CallId) ? request : null,
+ FunctionCallContent call when !asked.Contains(call.CallId) => call,
+ _ => null,
+ })
+ .OfType();
+ foreach (FunctionCallContent call in calls)
+ {
+ string args = JsonSerializer.Serialize(call.Arguments ?? new Dictionary(), AIJsonUtilities.DefaultOptions);
+ JsonObject part = new()
+ {
+ ["type"] = "tool-call",
+ ["toolCallId"] = call.CallId,
+ ["toolName"] = call.Name,
+ ["args"] = JsonNode.Parse(args),
+ ["argsText"] = args,
+ };
+ if (results.TryGetValue(call.CallId, out object? result))
+ {
+ part["result"] = result is string s ? s : JsonSerializer.SerializeToNode(result, AIJsonUtilities.DefaultOptions);
+ }
+ else if (pending.TryGetValue(call.CallId, out string? requestId))
+ {
+ part["approval"] = new JsonObject { ["id"] = requestId };
+ }
+
+ parts.Add(part);
+ }
+ }
+
+ if (!shown || parts.Count == 0)
+ {
+ continue;
+ }
+
+ string replyId = turn.Last().MessageId;
+ parts.Add(new JsonObject
+ {
+ ["type"] = "data",
+ ["name"] = "agentcore_message_committed",
+ ["data"] = new JsonObject { ["user_message_id"] = userId, ["reply_message_id"] = replyId },
+ });
+ JsonObject reply = new()
+ {
+ ["id"] = AgUiTurnIds.TurnMessageId(turn.Key),
+ ["role"] = "assistant",
+ ["content"] = parts,
+ ["status"] = new JsonObject { ["type"] = "complete", ["reason"] = "unknown" },
+ };
+
+ // A turn that ended asking for approval shows as react-ag-ui keeps a live interrupt: requires-action,
+ // the open interrupts under metadata.custom.agui, and each gated call carrying approval.id.
+ JsonArray interrupts = [.. parts
+ .Where(p => p!["approval"] is not null)
+ .Select(p => (JsonNode)new JsonObject
+ {
+ ["id"] = p!["approval"]!["id"]!.GetValue(),
+ ["reason"] = "tool_call",
+ ["toolCallId"] = p["toolCallId"]!.GetValue(),
+ })];
+ if (interrupts.Count > 0)
+ {
+ reply["status"] = new JsonObject { ["type"] = "requires-action", ["reason"] = "interrupt" };
+ reply["metadata"] = new JsonObject { ["custom"] = new JsonObject { ["agui"] = new JsonObject { ["interrupts"] = interrupts } } };
+ }
+
+ messages.Add(reply);
+ }
+
+ return Results.Text(
+ new JsonObject { ["messages"] = messages, ["nextCursor"] = below.Count > turns ? page.Min().ToString(CultureInfo.InvariantCulture) : null }.ToJsonString(),
+ "application/json");
+ });
+ }
+
+ ///
+ /// A stored user file as the assistant-ui attachment the composer made live. A filed upload carries its reference,
+ /// never a link: the browser swaps it for a fresh link when it draws it, and sends it back as it is.
+ ///
+ private static JsonArray Attachments(ChatMessage message, HttpRequest request, string threadId)
+ {
+ JsonArray attachments = [];
+ int index = 0;
+ foreach (AIContent content in message.Contents)
+ {
+ (string? src, string? mediaType, string? name) = content switch
+ {
+ DataContent data => (data.Uri, data.MediaType, data.Name),
+ UriContent uri => (uri.Uri.ToString(), uri.MediaType, null),
+ FileContent file when InboundFiles.Is(file) => (AgUiFileRefs.RefOf(request, threadId, file.Name), file.MediaType, file.Title),
+ _ => ((string?)null, (string?)null, (string?)null),
+ };
+ if (src is null)
+ {
+ continue;
+ }
+
+ bool image = mediaType!.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
+ JsonObject part = image
+ ? new JsonObject { ["type"] = "image", ["image"] = src, ["filename"] = name }
+ : new JsonObject { ["type"] = "file", ["data"] = src, ["mimeType"] = mediaType, ["filename"] = name };
+ attachments.Add(new JsonObject
+ {
+ ["id"] = $"stored-{index++}",
+ ["type"] = image ? "image" : "document",
+ ["name"] = name ?? (image ? "image" : "file"),
+ ["contentType"] = mediaType,
+ ["status"] = new JsonObject { ["type"] = "complete" },
+ ["content"] = new JsonArray(part),
+ });
+ }
+
+ return attachments;
+ }
+
+ ///
+ /// The approvals the conversation still waits on, by tool call id: MAF's pending queue
+ /// (_pendingApprovalRequests), which the conversation files with its state after the asking turn.
+ ///
+ private static Dictionary PendingByCallId(ConversationSessionState? state)
+ {
+ Dictionary pending = new(StringComparer.Ordinal);
+ if (state?.Providers.TryGetValue(PendingApprovalQueue.PendingStateKey, out JsonElement queue) != true || queue.ValueKind != JsonValueKind.Array)
+ {
+ return pending;
+ }
+
+ HashSet held = state.Providers.TryGetValue(PendingApprovalQueue.HeldStateKey, out JsonElement answers) && answers.ValueKind == JsonValueKind.Array
+ ? [.. answers.EnumerateArray().Select(a => a.TryGetProperty("requestId", out JsonElement id) ? id.GetString() ?? string.Empty : string.Empty)]
+ : [];
+ foreach (JsonElement entry in queue.EnumerateArray())
+ {
+ if (entry.TryGetProperty("requestId", out JsonElement id) && id.GetString() is { } requestId && !held.Contains(requestId)
+ && entry.TryGetProperty("toolCall", out JsonElement call) && call.TryGetProperty("callId", out JsonElement callId)
+ && callId.GetString() is { } toolCallId)
+ {
+ pending[toolCallId] = requestId;
+ }
+ }
+
+ return pending;
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgUiTurnIds.cs b/src/AgentCore.AspNetCore/Endpoints/AgUiTurnIds.cs
new file mode 100644
index 0000000..1abd315
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgUiTurnIds.cs
@@ -0,0 +1,133 @@
+using System.Globalization;
+using System.Text.Json;
+using AGUI.Abstractions;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Runtime.Turn;
+using AgentCore.Application.Transcript;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ ///
+ /// The id rules of the AG-UI endpoint.
+ ///
+ /// - A turn shows as one AG-UI assistant message per text block, named turn-{turnIndex}.{block}. Block 0
+ /// is the turn's first text or tool call; a new block starts when text starts again after a tool call. The turn
+ /// index is known before the first update (TurnRun.TurnIndex) and every stored row carries it.
+ /// - A parent named turn-{k} (the history route's id) or turn-{k}.{n} (a live block) names the last
+ /// stored row of turn k.
+ /// - The user's message is stored under the client's own id (ConversationTurnOrigin.MessageId).
+ /// - forwardedProps.agentcore = { message_id, parent_id }. parent_id present (even null) means the
+ /// client names where the turn hangs: everything after the parent is withdrawn. A turn-{k} parent names the
+ /// last stored row of turn k.
+ ///
+ ///
+ internal static class AgUiTurnIds
+ {
+ public const string TurnPrefix = "turn-";
+
+ public static string TurnMessageId(int turnIndex) => TurnPrefix + turnIndex.ToString(CultureInfo.InvariantCulture);
+
+ public static string BlockMessageId(int turnIndex, int block)
+ => TurnMessageId(turnIndex) + "." + block.ToString(CultureInfo.InvariantCulture);
+
+ public static int? TurnIndexOf(string? messageId)
+ {
+ if (messageId is null || !messageId.StartsWith(TurnPrefix, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ ReadOnlySpan rest = messageId.AsSpan(TurnPrefix.Length);
+ int dot = rest.IndexOf('.');
+ if (dot >= 0 && !int.TryParse(rest[(dot + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out _))
+ {
+ return null;
+ }
+
+ return int.TryParse(dot >= 0 ? rest[..dot] : rest, NumberStyles.None, CultureInfo.InvariantCulture, out int index) ? index : null;
+ }
+
+ ///
+ /// Names the AG-UI messages of one run. Text and the tool calls after it share turn-{k}.{n}, and
+ /// text that starts after a tool call opens turn-{k}.{n+1}, so text after a tool lands below it. The model's
+ /// own message ids are not used: a provider may send none, and AGUI.Server then mints one per update.
+ ///
+ public sealed class MessageIds(int turnIndex)
+ {
+ private int _block;
+ private bool _toolInBlock;
+
+ public string Current => BlockMessageId(turnIndex, _block);
+
+ public void Stamp(BaseEvent evt)
+ {
+ switch (evt)
+ {
+ case TextMessageStartEvent start:
+ if (_toolInBlock)
+ {
+ _block++;
+ _toolInBlock = false;
+ }
+
+ start.MessageId = Current;
+ break;
+ case TextMessageContentEvent content: content.MessageId = Current; break;
+ case TextMessageEndEvent end: end.MessageId = Current; break;
+ case ToolCallStartEvent call:
+ call.ParentMessageId = Current;
+ _toolInBlock = true;
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ /// What the client said about where its turn hangs.
+ public sealed record Anchor(string? MessageId, string? ParentId, bool NamesParent);
+
+ public static Anchor Read(RunAgentInput input)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+
+ string? lastUser = input.Messages.LastOrDefault(m => m is AGUIUserMessage)?.Id;
+
+ if (input.ForwardedProperties is not { ValueKind: JsonValueKind.Object } forwarded
+ || !forwarded.TryGetProperty("agentcore", out JsonElement agentcore)
+ || agentcore.ValueKind != JsonValueKind.Object)
+ {
+ return new Anchor(lastUser, null, NamesParent: false);
+ }
+
+ string? messageId = agentcore.TryGetProperty("message_id", out JsonElement m) && m.ValueKind == JsonValueKind.String
+ ? m.GetString()
+ : lastUser;
+
+ bool namesParent = agentcore.TryGetProperty("parent_id", out JsonElement p);
+
+ string? parentId = namesParent && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
+
+ return new Anchor(messageId, parentId, namesParent);
+ }
+
+ /// Turns the client's anchor into the origin the turn loop reads, naming the parent by its stored row id.
+ public static async ValueTask OriginAsync(
+ Anchor anchor, string conversationId, IConversations conversations, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(anchor);
+ ArgumentException.ThrowIfNullOrEmpty(conversationId);
+ ArgumentNullException.ThrowIfNull(conversations);
+
+ string? parent = anchor.ParentId;
+ if (anchor.NamesParent && TurnIndexOf(parent) is { } turn)
+ {
+ StoredConversation? stored = await conversations.LoadWindowAsync(conversationId, new TranscriptWindow(turn + 1, 1), cancellationToken).ConfigureAwait(false);
+ parent = stored?.Messages.Where(r => r.TurnIndex == turn).OrderBy(r => r.Ordinal).LastOrDefault()?.MessageId ?? parent;
+ }
+
+ return new ConversationTurnOrigin(anchor.MessageId, parent) { NamesParent = anchor.NamesParent };
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgentCoreConversationGate.cs b/src/AgentCore.AspNetCore/Endpoints/AgentCoreConversationGate.cs
new file mode 100644
index 0000000..02f31be
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Endpoints/AgentCoreConversationGate.cs
@@ -0,0 +1,43 @@
+using System.Collections.Frozen;
+using AgentCore.Application.Hooks.Gates;
+using AgentCore.Application.Hooks.Layers;
+using AgentCore.AspNetCore.DependencyInjection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AgentCore.AspNetCore.Endpoints
+{
+ /// Asks the host's hooks whether the caller may touch the conversation a request names.
+ internal static class AgentCoreConversationGate
+ {
+ /// The code a request gets for a conversation that does not exist or that the caller may not touch.
+ public const string NotFoundCode = "thread_not_found";
+
+ /// Runs BeforeConversation for one request.
+ /// A request routing matched to an AgentCore endpoint.
+ /// The entry the request runs.
+ /// The conversation the request names.
+ /// Whether the request answers an approval.
+ /// Cancels the gate.
+ /// when the request may go on.
+ public static ValueTask AllowAsync(
+ HttpContext http, string entry, string conversationId, bool resuming, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(http);
+
+ Endpoint? endpoint = http.GetEndpoint();
+
+ ConversationOffer offer = new(
+ http.User.Identity?.IsAuthenticated == true ? http.User : null,
+ http.Request.Headers.ToFrozenDictionary(static header => header.Key, static header => string.Join(',', header.Value.ToArray()), StringComparer.OrdinalIgnoreCase),
+ (endpoint as RouteEndpoint)?.RoutePattern.RawText ?? endpoint?.DisplayName ?? string.Empty,
+ entry,
+ conversationId,
+ endpoint?.Metadata.GetMetadata()?.Transport ?? EntryTransport.Http,
+ resuming);
+
+ return ConversationGateChain.AllowAsync(http.RequestServices.GetRequiredService().Hooks, offer, cancellationToken);
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs b/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs
index 235b32b..4d5e686 100644
--- a/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs
+++ b/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs
@@ -16,6 +16,9 @@ private AgentCoreRouteMetadata(EntryTransport transport)
/// Gets the tag of a call route (the conversation socket).
public static AgentCoreRouteMetadata Call { get; } = new(EntryTransport.Call);
+ /// Gets the tag of an AG-UI route.
+ public static AgentCoreRouteMetadata AgUi { get; } = new(EntryTransport.AgUi);
+
/// Gets how requests reach the route.
public EntryTransport Transport { get; }
}
diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs
index 42d490b..6ed6a9b 100644
--- a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs
+++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs
@@ -230,6 +230,9 @@ internal sealed record FilePayload
/// Gets how many bytes it holds.
public required long Length { get; init; }
+
+ /// Gets the absolute file-door reference the browser holds, or off the AG-UI route.
+ public string? Ref { get; init; }
}
/// The ids one turn's commit wrote, as the browser reads it.
diff --git a/src/AgentCore.AspNetCore/Endpoints/TurnStreamFiles.cs b/src/AgentCore.AspNetCore/Endpoints/TurnStreamFiles.cs
index d25e6fb..74c7069 100644
--- a/src/AgentCore.AspNetCore/Endpoints/TurnStreamFiles.cs
+++ b/src/AgentCore.AspNetCore/Endpoints/TurnStreamFiles.cs
@@ -41,5 +41,40 @@ internal IEnumerable Resolve(Conversations conversations, string
});
}
}
+
+ /// Lists every noted kept file, in first-seen order, without asking the store.
+ /// One part for each noted file the store kept.
+ internal IEnumerable Resolve()
+ {
+ List kept = [];
+ foreach (FileContent file in _files)
+ {
+ if (!file.Kept || !BlobName.IsSafe(file.Name))
+ {
+ continue;
+ }
+
+ int at = kept.FindIndex(known => string.Equals(known.Name, file.Name, StringComparison.Ordinal));
+ if (at >= 0)
+ {
+ kept[at] = file;
+ }
+ else
+ {
+ kept.Add(file);
+ }
+ }
+
+ foreach (FileContent file in kept)
+ {
+ yield return new TurnStreamPart(TurnStreamPart.File, new FilePayload
+ {
+ Name = file.Name,
+ Title = file.Title,
+ MediaType = file.MediaType,
+ Length = file.Length,
+ });
+ }
+ }
}
}
diff --git a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt
index e77802d..c378c75 100644
--- a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt
+++ b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt
@@ -51,6 +51,7 @@ AgentCore.AspNetCore.DependencyInjection.AgentCoreStartup
AgentCore.AspNetCore.DependencyInjection.AgentCoreStartup.Configuration.get -> AgentCore.Application.Configuration.Schema.AgentCoreConfiguration!
AgentCore.AspNetCore.DependencyInjection.AgentCoreStartup.Secrets.get -> AgentCore.Application.Secrets.ResolvedSecrets!
AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions
+AgentCore.AspNetCore.Endpoints.AgUiEndpointRouteBuilderExtensions
AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore
AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.AgentCoreAgentSessionStore(AgentCore.Application.Ports.IConversationStore! conversations) -> void
AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.ContainsAsync(string! sessionStoreId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
@@ -71,6 +72,7 @@ const AgentCore.AspNetCore.Voice.Routing.ConversationEndpointRouteBuilderExtensi
const AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.MaxAttempts = 3 -> int
const AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.PipelineName = "agentcore-http" -> string!
const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.DefaultPattern = "/v1/{entry}/responses" -> string!
+const AgentCore.AspNetCore.Endpoints.AgUiEndpointRouteBuilderExtensions.DefaultPattern = "/agui/{entry}" -> string!
const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter = "entry" -> string!
const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.StageHeaderName = "X-AgentCore-Stage" -> string!
const AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.TelnyxRelayKind = "telnyx-relay" -> string!
@@ -82,6 +84,8 @@ static AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtens
static AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtensions.AddAgentCoreWebSockets(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
static AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.MapResponses(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
static AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.MapResponses(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
+static AgentCore.AspNetCore.Endpoints.AgUiEndpointRouteBuilderExtensions.MapAgUi(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
+static AgentCore.AspNetCore.Endpoints.AgUiEndpointRouteBuilderExtensions.MapAgUi(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
static readonly AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.ConnectionLifetime -> System.TimeSpan
static readonly AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.FirstRetryDelay -> System.TimeSpan
static readonly AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.RequestDeadline -> System.TimeSpan
diff --git a/src/AgentCore.Hosting/AgentCoreEndpoints.cs b/src/AgentCore.Hosting/AgentCoreEndpoints.cs
index 43174a1..b9ffd24 100644
--- a/src/AgentCore.Hosting/AgentCoreEndpoints.cs
+++ b/src/AgentCore.Hosting/AgentCoreEndpoints.cs
@@ -6,8 +6,10 @@ namespace AgentCore.Hosting
/// The liveness check.
/// The OpenAI-compatible Responses endpoint.
/// The conversation socket.
+ /// The AG-UI endpoint.
public sealed record AgentCoreEndpoints(
IEndpointConventionBuilder Health,
IEndpointConventionBuilder Responses,
- IEndpointConventionBuilder Call);
+ IEndpointConventionBuilder Call,
+ IEndpointConventionBuilder AgUi);
}
diff --git a/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs b/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs
index 579eb93..7989feb 100644
--- a/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs
+++ b/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs
@@ -13,7 +13,7 @@ public static class AgentCoreHostEndpointExtensions
/// The route the liveness check answers on.
public const string HealthPattern = "/health";
- /// Installs the WebSocket middleware and maps health, the Responses endpoint, and the conversation socket.
+ /// Installs the WebSocket middleware and maps health, the Responses endpoint, the conversation socket, and the AG-UI endpoint.
/// The application to map on.
///
/// The route the OpenAI-compatible Responses endpoint answers on, with {entry} naming
@@ -25,10 +25,15 @@ public static class AgentCoreHostEndpointExtensions
/// BeforeEntryAsync picks it, or
/// for .
///
+ ///
+ /// The route the AG-UI endpoint answers on, with {entry} naming the entry unless a hook's
+ /// BeforeEntryAsync picks it, or for
+ /// .
+ ///
/// The mapped routes, so a host adds its own conventions to each.
/// is .
public static AgentCoreEndpoints MapAgentCoreHost(
- this WebApplication app, string? responsesPattern = null, string? conversationPattern = null)
+ this WebApplication app, string? responsesPattern = null, string? conversationPattern = null, string? agUiPattern = null)
{
ArgumentNullException.ThrowIfNull(app);
@@ -39,7 +44,8 @@ public static AgentCoreEndpoints MapAgentCoreHost(
return new AgentCoreEndpoints(
health,
app.MapResponses(responsesPattern ?? ResponsesEndpointRouteBuilderExtensions.DefaultPattern),
- app.MapCall(conversationPattern ?? ConversationEndpointRouteBuilderExtensions.DefaultPattern));
+ app.MapCall(conversationPattern ?? ConversationEndpointRouteBuilderExtensions.DefaultPattern),
+ app.MapAgUi(agUiPattern ?? AgUiEndpointRouteBuilderExtensions.DefaultPattern));
}
}
}
diff --git a/src/AgentCore.Hosting/PublicAPI.Unshipped.txt b/src/AgentCore.Hosting/PublicAPI.Unshipped.txt
index 0651435..f731544 100644
--- a/src/AgentCore.Hosting/PublicAPI.Unshipped.txt
+++ b/src/AgentCore.Hosting/PublicAPI.Unshipped.txt
@@ -8,9 +8,9 @@ const AgentCore.Hosting.AgentCoreHostBuilderExtensions.McpHttpClientName = "agen
const AgentCore.Hosting.AgentCoreHostEndpointExtensions.HealthPattern = "/health" -> string!
static AgentCore.Hosting.AgentCoreHostBuilderExtensions.AddAgentCoreHost(this Microsoft.AspNetCore.Builder.WebApplicationBuilder! builder, System.Action? configure = null) -> Microsoft.AspNetCore.Builder.WebApplicationBuilder!
AgentCore.Hosting.AgentCoreEndpoints.$() -> AgentCore.Hosting.AgentCoreEndpoints!
-AgentCore.Hosting.AgentCoreEndpoints.AgentCoreEndpoints(Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Health, Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Responses, Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Call) -> void
+AgentCore.Hosting.AgentCoreEndpoints.AgentCoreEndpoints(Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Health, Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Responses, Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Call, Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! AgUi) -> void
AgentCore.Hosting.AgentCoreEndpoints.Call.get -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
-AgentCore.Hosting.AgentCoreEndpoints.Deconstruct(out Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Health, out Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Responses, out Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Call) -> void
+AgentCore.Hosting.AgentCoreEndpoints.Deconstruct(out Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Health, out Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Responses, out Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! Call, out Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! AgUi) -> void
AgentCore.Hosting.AgentCoreEndpoints.Equals(AgentCore.Hosting.AgentCoreEndpoints? other) -> bool
AgentCore.Hosting.AgentCoreEndpoints.Health.get -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
AgentCore.Hosting.AgentCoreEndpoints.Responses.get -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
@@ -20,7 +20,9 @@ override AgentCore.Hosting.AgentCoreEndpoints.GetHashCode() -> int
override AgentCore.Hosting.AgentCoreEndpoints.ToString() -> string!
static AgentCore.Hosting.AgentCoreEndpoints.operator !=(AgentCore.Hosting.AgentCoreEndpoints? left, AgentCore.Hosting.AgentCoreEndpoints? right) -> bool
static AgentCore.Hosting.AgentCoreEndpoints.operator ==(AgentCore.Hosting.AgentCoreEndpoints? left, AgentCore.Hosting.AgentCoreEndpoints? right) -> bool
-static AgentCore.Hosting.AgentCoreHostEndpointExtensions.MapAgentCoreHost(this Microsoft.AspNetCore.Builder.WebApplication! app, string? responsesPattern = null, string? conversationPattern = null) -> AgentCore.Hosting.AgentCoreEndpoints!
+static AgentCore.Hosting.AgentCoreHostEndpointExtensions.MapAgentCoreHost(this Microsoft.AspNetCore.Builder.WebApplication! app, string? responsesPattern = null, string? conversationPattern = null, string? agUiPattern = null) -> AgentCore.Hosting.AgentCoreEndpoints!
AgentCore.Hosting.AgentCoreEndpoints
AgentCore.Hosting.AgentCoreEndpoints.Call.init -> void
AgentCore.Hosting.AgentCoreEndpoints.Health.init -> void
+AgentCore.Hosting.AgentCoreEndpoints.AgUi.get -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
+AgentCore.Hosting.AgentCoreEndpoints.AgUi.init -> void
diff --git a/src/AgentCore.Infrastructure/Blobs/S3/S3BlobStore.cs b/src/AgentCore.Infrastructure/Blobs/S3/S3BlobStore.cs
index d2f5717..873b6a5 100644
--- a/src/AgentCore.Infrastructure/Blobs/S3/S3BlobStore.cs
+++ b/src/AgentCore.Infrastructure/Blobs/S3/S3BlobStore.cs
@@ -114,7 +114,7 @@ public async ValueTask PutAsync(BlobWrite write, CancellationToken canc
ResponseHeaderOverrides =
{
ContentType = blob.MediaType,
- ContentDisposition = BlobLink.DispositionOf(blob.MediaType, blob.Name),
+ ContentDisposition = BlobLink.DispositionOf(blob.MediaType, blob.DownloadName ?? blob.Name),
},
};
diff --git a/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs b/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs
index 1ec7f14..9a65996 100644
--- a/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs
+++ b/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs
@@ -5,11 +5,12 @@ namespace AgentCore.Application.Tests.Hooks
{
public sealed class GatePointTests
{
- // Deadline and default failure F of each of the 13 gates.
+ // Deadline and default failure F of each of the 14 gates.
public static TheoryData Points => new()
{
{ GatePoint.BeforeCall, 5, HookFailure.Closed },
{ GatePoint.BeforeEntry, 2, HookFailure.Closed },
+ { GatePoint.BeforeConversation, 2, HookFailure.Closed },
{ GatePoint.BeforeTurn, 2, HookFailure.Open },
{ GatePoint.BeforeRun, 2, HookFailure.Open },
{ GatePoint.BeforeCompaction, 5, HookFailure.Open },
@@ -32,10 +33,10 @@ public void EachPointHasTheSpecsDeadlineAndDefault(GatePoint point, double secon
}
[Fact]
- public void TheSetHoldsThirteenPoints()
+ public void TheSetHoldsFourteenPoints()
{
- Assert.Equal(13, GatePoint.All.Count);
- Assert.Equal(13, GatePoint.All.Select(point => point.Name).Distinct(StringComparer.Ordinal).Count());
+ Assert.Equal(14, GatePoint.All.Count);
+ Assert.Equal(14, GatePoint.All.Select(point => point.Name).Distinct(StringComparer.Ordinal).Count());
}
[Fact]
diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCarryTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCarryTests.cs
new file mode 100644
index 0000000..f0ae509
--- /dev/null
+++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCarryTests.cs
@@ -0,0 +1,79 @@
+using AgentCore.Application.Tests.Fakes;
+using AgentCore.Application.Tests.Hooks;
+using AgentCore.Domain;
+using AgentCore.TestSupport;
+using Microsoft.Extensions.AI;
+using Xunit;
+using AgentCore.Application.Runtime.Session;
+using AgentCore.Application.Runtime.Turn;
+
+namespace AgentCore.Application.Tests.Runtime
+{
+ ///
+ /// A turn that withdraws a tool turn keeps its call and result only when it resends
+ /// the same user message (regenerate/retry). An edit — a new message id — re-runs tools.
+ ///
+ public sealed class ConversationSessionCarryTests
+ {
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static (ConversationSession Session, RequestCapturingChatClient Model) Create(StubToolBuilder tools)
+ {
+ RequestCapturingChatClient model = new(new ToolCallingChatClient("final"));
+ return (HookSessions.Create(HookSessions.ToolAgentYaml, model, tools: tools.Create), model);
+ }
+
+ [Fact]
+ public async Task Regenerate_SameMessageId_CarriesWithdrawnToolResult_ToolRunsOnce()
+ {
+ StubToolBuilder tools = new("{}");
+ (ConversationSession session, RequestCapturingChatClient model) = Create(tools);
+
+ _ = await session.RunTurnAtOriginAsync("how much?", new ConversationTurnOrigin("m-1", null) { NamesParent = true }, Ct);
+ Assert.Single(tools.Called);
+
+ TurnResult again = await session.RunTurnAtOriginAsync("how much?", new ConversationTurnOrigin("m-1", null) { NamesParent = true }, Ct);
+
+ Assert.Null(again.Failure);
+ Assert.Single(tools.Called);
+ Assert.Contains(model.Requests[2], HasResult);
+ }
+
+ [Fact]
+ public async Task Edit_NewMessageId_ReRunsTools_ToolRunsTwice()
+ {
+ StubToolBuilder tools = new("{}");
+ (ConversationSession session, RequestCapturingChatClient model) = Create(tools);
+
+ _ = await session.RunTurnAtOriginAsync("how much?", new ConversationTurnOrigin("m-1", null) { NamesParent = true }, Ct);
+ Assert.Single(tools.Called);
+
+ TurnResult edited = await session.RunTurnAtOriginAsync("how much, exactly?", new ConversationTurnOrigin("m-2", null) { NamesParent = true }, Ct);
+
+ Assert.Null(edited.Failure);
+ Assert.Equal(2, tools.Called.Count);
+ Assert.DoesNotContain(model.Requests[2], HasResult);
+ }
+
+ [Fact]
+ public async Task EditWithNoMessageId_ReRunsTools()
+ {
+ StubToolBuilder tools = new("{}");
+ (ConversationSession session, RequestCapturingChatClient model) = Create(tools);
+
+ _ = await session.RunTurnAtOriginAsync("how much?", new ConversationTurnOrigin("m-1", null) { NamesParent = true }, Ct);
+ Assert.Single(tools.Called);
+
+ TurnResult edited = await session.RunTurnAtOriginAsync("how much, exactly?", new ConversationTurnOrigin(null, null) { NamesParent = true }, Ct);
+
+ Assert.Null(edited.Failure);
+ Assert.Equal(2, tools.Called.Count);
+ Assert.DoesNotContain(model.Requests[2], HasResult);
+ }
+
+ private static bool HasResult(ChatMessage message)
+ {
+ return message.Contents.OfType().Any();
+ }
+ }
+}
diff --git a/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs b/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs
index f9e2773..d01c96e 100644
--- a/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs
+++ b/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs
@@ -58,7 +58,7 @@ public async Task ACorrectionWhileAToolRunsLetsItFinishOnceAndTheNewTurnReadsIts
tool.Release.SetResult();
await session.ToolRuns.WhenIdle();
- TurnResult newer = await session.RunTurnAtOriginAsync(Combined, new ConversationTurnOrigin("d2", anchor) { NamesParent = true }, Ct);
+ TurnResult newer = await session.RunTurnAtOriginAsync(Combined, new ConversationTurnOrigin("d2", anchor) { NamesParent = true, KeepsWithdrawnTools = true }, Ct);
await session.FlushNoticesAsync();
Assert.Equal((1, 1), (tool.Runs, tool.Finished));
diff --git a/tests/AgentCore.Application.Tests/Transcript/InboundFilesTests.cs b/tests/AgentCore.Application.Tests/Transcript/InboundFilesTests.cs
new file mode 100644
index 0000000..f09ad76
--- /dev/null
+++ b/tests/AgentCore.Application.Tests/Transcript/InboundFilesTests.cs
@@ -0,0 +1,145 @@
+using AgentCore.Application.Blobs;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Transcript;
+using Microsoft.Extensions.AI;
+using Xunit;
+
+namespace AgentCore.Application.Tests.Transcript;
+
+///
+/// Inbound caller files: filed to the blob store under the conversation, rehydrated for the model.
+///
+public sealed class InboundFilesTests
+{
+ private static readonly byte[] Png = [137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4];
+
+ private sealed class MemoryBlobStore : IBlobStore
+ {
+ public readonly Dictionary<(string Owner, string Name), (string MediaType, byte[] Bytes)> Blobs = [];
+
+ public ValueTask PutAsync(BlobWrite write, CancellationToken cancellationToken = default)
+ {
+ using MemoryStream copy = new();
+ write.Content.CopyTo(copy);
+ byte[] bytes = copy.ToArray();
+ lock (Blobs)
+ {
+ Blobs[(write.OwnerId, write.Name)] = (write.MediaType, bytes);
+ }
+
+ return ValueTask.FromResult(new BlobRef(write.OwnerId, write.Name, write.MediaType, write.Length));
+ }
+
+ public ValueTask OpenReadAsync(string ownerId, string name, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult(
+ Blobs.TryGetValue((ownerId, name), out (string MediaType, byte[] Bytes) blob)
+ ? new BlobRead(blob.MediaType, blob.Bytes.Length, new MemoryStream(blob.Bytes, writable: false))
+ : null);
+ }
+
+ public ValueTask StatAsync(string ownerId, string name, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult(
+ Blobs.TryGetValue((ownerId, name), out (string MediaType, byte[] Bytes) blob)
+ ? new BlobRef(ownerId, name, blob.MediaType, blob.Bytes.Length)
+ : null);
+ }
+
+ public ValueTask LinkAsync(BlobRef blob, TimeSpan lifetime, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult(new Uri($"https://blobs.test/{blob.OwnerId}/{blob.Name}"));
+ }
+
+ public ValueTask DeleteByOwnerAsync(string ownerId, CancellationToken cancellationToken = default)
+ {
+ lock (Blobs)
+ {
+ foreach ((string Owner, string Name) key in Blobs.Keys.Where(key => key.Owner == ownerId).ToList())
+ {
+ _ = Blobs.Remove(key);
+ }
+ }
+
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task FileAsync_WritesDataContentToTheStoreAndKeepsAReference()
+ {
+ MemoryBlobStore blobs = new();
+ ChatMessage message = new(
+ ChatRole.User,
+ [new TextContent("look"), new DataContent(Png, "image/png") { Name = "photo.png" }]);
+
+ ChatMessage filed = await InboundFiles.FileAsync(blobs, "conv-1", message, Ct);
+
+ (string Owner, string Name) key = Assert.Single(blobs.Blobs.Keys);
+ Assert.Equal("conv-1", key.Owner);
+ Assert.StartsWith("in-", key.Name, StringComparison.Ordinal);
+ Assert.EndsWith(".png", key.Name, StringComparison.Ordinal);
+ Assert.Equal(Png, blobs.Blobs[key].Bytes);
+ FileContent file = Assert.IsType(filed.Contents[1]);
+ Assert.Equal(key.Name, file.Name);
+ Assert.Equal("upload:photo.png", file.FileId);
+ Assert.Equal("photo.png", file.Title);
+ Assert.Equal("image/png", file.MediaType);
+ Assert.Equal(Png.Length, file.Length);
+ Assert.True(file.Kept);
+ Assert.DoesNotContain(filed.Contents, content => content is DataContent);
+ }
+
+ [Fact]
+ public async Task FileAsync_WithoutDataContent_ReturnsTheSameInstance()
+ {
+ MemoryBlobStore blobs = new();
+ ChatMessage message = new(ChatRole.User, "hello");
+
+ Assert.Same(message, await InboundFiles.FileAsync(blobs, "conv-1", message, Ct));
+ Assert.Empty(blobs.Blobs);
+ }
+
+ [Fact]
+ public async Task RehydrateAsync_ReturnsDataContentAndLeavesTheStoredMessageAlone()
+ {
+ MemoryBlobStore blobs = new();
+ ChatMessage message = new(
+ ChatRole.User,
+ [new TextContent("look"), new DataContent(Png, "image/png") { Name = "photo.png" }]);
+ ChatMessage filed = await InboundFiles.FileAsync(blobs, "conv-1", message, Ct);
+
+ List hydrated = await InboundFiles.RehydrateAsync(blobs, "conv-1", [filed], Ct);
+
+ ChatMessage back = Assert.Single(hydrated);
+ DataContent data = Assert.IsType(back.Contents[1]);
+ Assert.Equal(Png, data.Data.ToArray());
+ Assert.Equal("image/png", data.MediaType);
+ Assert.Equal("photo.png", data.Name);
+ Assert.IsType(filed.Contents[1]);
+ }
+
+ [Fact]
+ public async Task RehydrateAsync_MissingBlob_SaysSoInText()
+ {
+ MemoryBlobStore blobs = new();
+ ChatMessage filed = new(
+ ChatRole.User,
+ [new FileContent { Name = "in-gone", FileId = "upload:photo.png", Title = "photo.png", MediaType = "image/png", Length = 4, Kept = true }]);
+
+ ChatMessage back = Assert.Single(await InboundFiles.RehydrateAsync(blobs, "conv-1", [filed], Ct));
+
+ Assert.Equal("[the attachment 'photo.png' is no longer stored]", Assert.IsType(back.Contents[0]).Text);
+ }
+
+ [Fact]
+ public void Is_TrueOnlyForKeptUploadFiles()
+ {
+ Assert.True(InboundFiles.Is(new FileContent { Name = "in-a.png", FileId = "upload:photo.png", Kept = true }));
+ Assert.False(InboundFiles.Is(new FileContent { Name = "report.csv", FileId = "file.publish", Kept = true }));
+ Assert.False(InboundFiles.Is(new FileContent { Name = "in-a.png", FileId = "upload:photo.png", Kept = false }));
+ Assert.Equal("upload:", InboundFiles.FileIdPrefix);
+ }
+}
diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiApprovalTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiApprovalTests.cs
new file mode 100644
index 0000000..f212941
--- /dev/null
+++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiApprovalTests.cs
@@ -0,0 +1,768 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Tools.Registry;
+using AgentCore.Application.Transcript;
+using AgentCore.AspNetCore.Endpoints;
+using AgentCore.AspNetCore.Tests.Fakes;
+using AgentCore.TestSupport;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace AgentCore.AspNetCore.Tests.Endpoints
+{
+ ///
+ /// The AG-UI route's approval rules: one resume array answers every open interrupt, a replay with
+ /// nothing open is an empty successful run, refusals are one RUN_ERROR with the model never running,
+ /// and host history reads pending approvals off the stored state.
+ ///
+ public sealed class AgUiApprovalTests
+ {
+ private const string TwoToolsYaml =
+ """
+ apiVersion: agentcore/v1
+ agents:
+ defaults:
+ model: { ref: reply }
+ items:
+ - { id: greeter, instructions: "send both", tools: [ send_email, send_sms ] }
+ providers:
+ conversation: { kind: telnyx-relay }
+ speech:
+ stt: { kind: telnyx-relay }
+ tts: { kind: telnyx-relay }
+ llm:
+ - { kind: openai, model: gpt-4.1-mini, as: reply }
+ entries:
+ main:
+ agent: greeter
+ """;
+
+ private const string LookupYaml =
+ """
+ apiVersion: agentcore/v1
+ agents:
+ defaults:
+ model: { ref: reply }
+ items:
+ - { id: greeter, instructions: "look things up", tools: [ look_it_up ] }
+ providers:
+ conversation: { kind: telnyx-relay }
+ speech:
+ stt: { kind: telnyx-relay }
+ tts: { kind: telnyx-relay }
+ llm:
+ - { kind: openai, model: gpt-4.1-mini, as: reply }
+ entries:
+ main:
+ agent: greeter
+ """;
+ private const string OneToolYaml =
+ """
+ apiVersion: agentcore/v1
+ agents:
+ defaults:
+ model: { ref: reply }
+ items:
+ - { id: greeter, instructions: "send the mail", tools: [ send_email ] }
+ providers:
+ conversation: { kind: telnyx-relay }
+ speech:
+ stt: { kind: telnyx-relay }
+ tts: { kind: telnyx-relay }
+ llm:
+ - { kind: openai, model: gpt-4.1-mini, as: reply }
+ entries:
+ main:
+ agent: greeter
+ """;
+
+ private const string Route = "/agui/main";
+
+ private const string Rejected = "Tool call invocation rejected.";
+
+ private const string MovedOn = "the user moved on without answering.";
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static JsonObject User(string id, string text) => new() { ["id"] = id, ["role"] = "user", ["content"] = text };
+
+ private static JsonObject AssistantCall(string id, string callId, string name, string args) => new()
+ {
+ ["id"] = id,
+ ["role"] = "assistant",
+ ["toolCalls"] = new JsonArray(new JsonObject
+ {
+ ["id"] = callId,
+ ["type"] = "function",
+ ["function"] = new JsonObject { ["name"] = name, ["arguments"] = args },
+ }),
+ };
+
+ private static string Run(string threadId, string runId, IEnumerable messages, params JsonObject[] resume)
+ {
+ JsonObject body = new()
+ {
+ ["threadId"] = threadId,
+ ["runId"] = runId,
+ ["messages"] = new JsonArray([.. messages.Select(m => (JsonNode)m.DeepClone())]),
+ ["tools"] = new JsonArray(),
+ ["context"] = new JsonArray(),
+ ["forwardedProps"] = new JsonObject(),
+ };
+ if (resume.Length > 0)
+ {
+ body["resume"] = new JsonArray([.. resume.Select(r => (JsonNode)r.DeepClone())]);
+ }
+
+ return body.ToJsonString();
+ }
+
+ private static JsonObject Approved(string interruptId)
+ {
+ return new JsonObject
+ {
+ ["interruptId"] = interruptId,
+ ["status"] = "resolved",
+ ["payload"] = new JsonObject { ["approved"] = true },
+ };
+ }
+
+ private static JsonObject Denied(string interruptId, string? reason = null)
+ {
+ JsonObject payload = new() { ["approved"] = false };
+ if (reason is not null)
+ {
+ payload["reason"] = reason;
+ }
+
+ return new JsonObject { ["interruptId"] = interruptId, ["status"] = "resolved", ["payload"] = payload };
+ }
+
+ private static JsonObject Cancelled(string interruptId) => new() { ["interruptId"] = interruptId, ["status"] = "cancelled" };
+
+ private static async Task OpenThreadAsync(IServiceProvider services, string threadId)
+ {
+ _ = await services.GetRequiredService().CreateAsync(threadId, Ct);
+ return threadId;
+ }
+
+ private static async Task PostAsync(HttpClient client, string body, string route = Route)
+ {
+ using HttpRequestMessage request = new(HttpMethod.Post, route)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json"),
+ };
+ request.Headers.Accept.ParseAdd("text/event-stream");
+ return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct);
+ }
+
+ private static async Task> ReadEventsAsync(HttpResponseMessage response)
+ {
+ List events = [];
+ await using Stream body = await response.Content.ReadAsStreamAsync(Ct);
+ using StreamReader reader = new(body, Encoding.UTF8);
+ while (await reader.ReadLineAsync(Ct) is { } line)
+ {
+ if (line.StartsWith("data: ", StringComparison.Ordinal))
+ {
+ events.Add(JsonDocument.Parse(line["data: ".Length..]).RootElement.Clone());
+ }
+ }
+
+ return events;
+ }
+
+ private static string Type(JsonElement evt) => evt.GetProperty("type").GetString()!;
+
+ private static string CodeOf(JsonElement evt) => evt.GetProperty("code").GetString()!;
+
+ private static string ClientText(List events)
+ {
+ return string.Concat(
+ events.Where(e => Type(e) == "TEXT_MESSAGE_CONTENT").Select(e => e.GetProperty("delta").GetString() ?? string.Empty));
+ }
+
+ private sealed record Asked(List Events, List<(string Id, string CallId)> Interrupts);
+
+ private static async Task AskAsync(ResponsesHost host, string thread, string words)
+ {
+ using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, "r1", [User("u1", words)]));
+ List events = await ReadEventsAsync(response);
+ JsonElement outcome = events[^1].GetProperty("outcome");
+ Assert.Equal("interrupt", outcome.GetProperty("type").GetString());
+ return new Asked(
+ events,
+ [.. outcome.GetProperty("interrupts").EnumerateArray()
+ .Select(i => (i.GetProperty("id").GetString()!, i.GetProperty("toolCallId").GetString()!))]);
+ }
+
+ private static Task StartTwoToolsAsync(ToolRuns runs, RecordingChatClient model)
+ {
+ return ResponsesHost.StartAsync(
+ TwoToolsYaml,
+ model,
+ configure: options => _ = options.AddToolSource(_ => new TwoGatedSource(runs)),
+ map: app => app.MapAgUi());
+ }
+
+ private static Task StartOneToolAsync(ToolRuns runs, RecordingChatClient model)
+ {
+ return ResponsesHost.StartAsync(
+ OneToolYaml,
+ model,
+ configure: options => _ = options.AddToolSource(_ => new OneGatedSource(runs)),
+ map: app => app.MapAgUi());
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task PartialResume_AnswersOneOfTwo_IsSingleResumeIncomplete_ModelRunsNoNewTurn()
+ {
+ // Arrange
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new TwoGatedCallsChatClient());
+ await using ResponsesHost host = await StartTwoToolsAsync(runs, model);
+ string thread = await OpenThreadAsync(host.Services, "t-partial");
+ Asked asked = await AskAsync(host, thread, "send both");
+ Assert.Equal(2, asked.Interrupts.Count);
+ int calls = model.Calls;
+
+ // Act
+ using HttpResponseMessage response = await PostAsync(
+ host.Client,
+ Run(thread, "r2",
+ [User("u1", "send both"), AssistantCall("a1", asked.Interrupts[0].CallId, "send_email", """{"to":"a@b.com"}""")],
+ Approved(asked.Interrupts[0].Id)));
+ List events = await ReadEventsAsync(response);
+
+ // Assert
+ JsonElement only = Assert.Single(events);
+ Assert.Equal("RUN_ERROR", Type(only));
+ Assert.Equal("resume_incomplete", CodeOf(only));
+ Assert.Equal(calls, model.Calls);
+ Assert.Empty(runs.All);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task UnknownInterruptId_IsSingleNoPendingApproval_ModelRunsNoNewTurn()
+ {
+ // Arrange
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new TwoGatedCallsChatClient());
+ await using ResponsesHost host = await StartTwoToolsAsync(runs, model);
+ string thread = await OpenThreadAsync(host.Services, "t-unknown");
+ using HttpResponseMessage first = await PostAsync(host.Client, Run(thread, "r1", [User("u1", "hello")]));
+ _ = await ReadEventsAsync(first);
+ int calls = model.Calls;
+
+ // Act
+ using HttpResponseMessage response = await PostAsync(
+ host.Client, Run(thread, "r2", [User("u1", "hello")], Approved("ficc_call_made_up")));
+ List events = await ReadEventsAsync(response);
+
+ // Assert
+ JsonElement only = Assert.Single(events);
+ Assert.Equal("RUN_ERROR", Type(only));
+ Assert.Equal("no_pending_approval", CodeOf(only));
+ Assert.Equal(calls, model.Calls);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task DuplicateInterruptId_IsSingleDuplicateResume_ModelRunsNoNewTurn()
+ {
+ // Arrange
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new TwoGatedCallsChatClient());
+ await using ResponsesHost host = await StartTwoToolsAsync(runs, model);
+ string thread = await OpenThreadAsync(host.Services, "t-duplicate");
+ Asked asked = await AskAsync(host, thread, "send both");
+ int calls = model.Calls;
+
+ // Act
+ using HttpResponseMessage response = await PostAsync(
+ host.Client,
+ Run(thread, "r2", [User("u1", "send both")], Approved(asked.Interrupts[0].Id), Denied(asked.Interrupts[0].Id)));
+ List events = await ReadEventsAsync(response);
+
+ // Assert
+ JsonElement only = Assert.Single(events);
+ Assert.Equal("RUN_ERROR", Type(only));
+ Assert.Equal("duplicate_resume", CodeOf(only));
+ Assert.Equal(calls, model.Calls);
+ Assert.Empty(runs.All);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task SameApprovalReplayed_IsRunStartedThenFinished_ModelRunsNoNewTurn_ToolRunsOnce()
+ {
+ // Arrange
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new SingleCallChatClient());
+ await using ResponsesHost host = await StartOneToolAsync(runs, model);
+ string thread = await OpenThreadAsync(host.Services, "t-replay");
+ Asked asked = await AskAsync(host, thread, "send it");
+ (string id, string callId) = Assert.Single(asked.Interrupts);
+ string body = Run(
+ thread, "r2",
+ [User("u1", "send it"), AssistantCall("a1", callId, "send_email", """{"to":"a@b.com"}""")],
+ Approved(id));
+
+ // Act
+ using HttpResponseMessage first = await PostAsync(host.Client, body);
+ List answered = await ReadEventsAsync(first);
+ int calls = model.Calls;
+ using HttpResponseMessage replayed = await PostAsync(host.Client, body);
+ List replay = await ReadEventsAsync(replayed);
+
+ // Assert
+ Assert.DoesNotContain(answered, e => Type(e) == "RUN_ERROR");
+ Assert.Equal(["RUN_STARTED", "RUN_FINISHED"], replay.Select(Type));
+ Assert.Equal(calls, model.Calls);
+ Assert.Equal([("send_email", "a@b.com")], runs.All);
+
+ // A replay with the other answer is no replay: the id is no longer open.
+ using HttpResponseMessage flipped = await PostAsync(
+ host.Client,
+ Run(thread, "r3",
+ [User("u1", "send it"), AssistantCall("a1", callId, "send_email", """{"to":"a@b.com"}""")],
+ Denied(id)));
+ List refused = await ReadEventsAsync(flipped);
+ JsonElement only = Assert.Single(refused);
+ Assert.Equal("RUN_ERROR", Type(only));
+ Assert.Equal("no_pending_approval", CodeOf(only));
+ Assert.Equal([("send_email", "a@b.com")], runs.All);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task ApproveResume_RunsTheToolOnceWithStoredArguments_ResultLandsOnAskingTurn()
+ {
+ // Arrange
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new SingleCallChatClient());
+ await using ResponsesHost host = await StartOneToolAsync(runs, model);
+ string thread = await OpenThreadAsync(host.Services, "t-approve");
+ Asked asked = await AskAsync(host, thread, "send it");
+ (string id, string callId) = Assert.Single(asked.Interrupts);
+ List announced = [.. asked.Events.Where(e => Type(e) == "TOOL_CALL_START").Select(e => e.GetProperty("toolCallId").GetString()!)];
+ Assert.Equal([callId], announced);
+
+ // Act: the client's tool-call echo forges other arguments; the tool must run with the stored ones.
+ using HttpResponseMessage response = await PostAsync(
+ host.Client,
+ Run(thread, "r2",
+ [User("u1", "send it"), AssistantCall("a1", callId, "send_email", """{"to":"evil@x.com"}""")],
+ Approved(id)));
+ List answered = await ReadEventsAsync(response);
+
+ // Assert
+ Assert.DoesNotContain(answered, e => Type(e) == "RUN_ERROR");
+ Assert.DoesNotContain(answered, e => Type(e) == "TOOL_CALL_START");
+ Assert.Equal(
+ [callId],
+ answered.Where(e => Type(e) == "TOOL_CALL_RESULT").Select(e => e.GetProperty("toolCallId").GetString()));
+ Assert.Equal([("send_email", "a@b.com")], runs.All);
+ Assert.Equal("success", answered[^1].GetProperty("outcome").GetProperty("type").GetString());
+ }
+ [Fact(Timeout = 60_000)]
+ public async Task DenyResumeWithReason_ReasonReachesTheModel_ToolNeverRuns()
+ {
+ // Arrange
+ const string Reason = "wrong recipient, use the billing address";
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new SingleCallChatClient());
+ await using ResponsesHost host = await StartOneToolAsync(runs, model);
+ string thread = await OpenThreadAsync(host.Services, "t-deny");
+ Asked asked = await AskAsync(host, thread, "send it");
+ (string id, string callId) = Assert.Single(asked.Interrupts);
+
+ // Act
+ using HttpResponseMessage response = await PostAsync(
+ host.Client,
+ Run(thread, "r2",
+ [User("u1", "send it"), AssistantCall("a1", callId, "send_email", """{"to":"a@b.com"}""")],
+ Denied(id, Reason)));
+ List answered = await ReadEventsAsync(response);
+
+ // Assert
+ Assert.DoesNotContain(answered, e => Type(e) == "RUN_ERROR");
+ Assert.Empty(runs.All);
+ FunctionResultContent result = Assert.Single(
+ model.Requests.SelectMany(request => request.SelectMany(message => message.Contents.OfType())));
+ string text = Assert.IsType(result.Result);
+ Assert.Contains(Rejected, text, StringComparison.Ordinal);
+ Assert.Contains(Reason, text, StringComparison.Ordinal);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task CancelledResume_ReasonReadsAsDismissed_ToolNeverRuns()
+ {
+ // Arrange
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new SingleCallChatClient());
+ await using ResponsesHost host = await StartOneToolAsync(runs, model);
+ string thread = await OpenThreadAsync(host.Services, "t-cancelled");
+ Asked asked = await AskAsync(host, thread, "send it");
+ (string id, string callId) = Assert.Single(asked.Interrupts);
+
+ // Act
+ using HttpResponseMessage response = await PostAsync(
+ host.Client,
+ Run(thread, "r2",
+ [User("u1", "send it"), AssistantCall("a1", callId, "send_email", """{"to":"a@b.com"}""")],
+ Cancelled(id)));
+ List answered = await ReadEventsAsync(response);
+
+ // Assert
+ Assert.DoesNotContain(answered, e => Type(e) == "RUN_ERROR");
+ Assert.Empty(runs.All);
+ FunctionResultContent result = Assert.Single(
+ model.Requests.SelectMany(request => request.SelectMany(message => message.Contents.OfType())));
+ string text = Assert.IsType(result.Result);
+ Assert.Contains(AgUiApprovals.CancelledReason, text, StringComparison.Ordinal);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task AllCancelledWithNewWords_WordsRunAlone_CallRefusedAsMovedOn()
+ {
+ // Arrange
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new SingleCallChatClient());
+ await using ResponsesHost host = await StartOneToolAsync(runs, model);
+ string thread = await OpenThreadAsync(host.Services, "t-moved-on");
+ Asked asked = await AskAsync(host, thread, "send it");
+ (string id, string callId) = Assert.Single(asked.Interrupts);
+
+ // Act
+ using HttpResponseMessage response = await PostAsync(
+ host.Client,
+ Run(thread, "r2",
+ [User("u1", "send it"), AssistantCall("a1", callId, "send_email", """{"to":"a@b.com"}"""), User("u2", "hello instead")],
+ Cancelled(id)));
+ List moved = await ReadEventsAsync(response);
+
+ // Assert
+ Assert.DoesNotContain(moved, e => Type(e) == "RUN_ERROR");
+ Assert.Empty(runs.All);
+ Assert.Contains(MovedOn, ClientText(moved), StringComparison.Ordinal);
+ List results = [.. model.Requests
+ .SelectMany(request => request.SelectMany(message => message.Contents.OfType()))];
+ Assert.Contains(results, r => r.Result is string text && text.Contains(MovedOn, StringComparison.Ordinal));
+ Assert.Contains(
+ model.Requests.SelectMany(request => request.Where(message => message.Role == ChatRole.User)),
+ message => message.Text == "hello instead");
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task ReloadPendingApproval_PublicReadShowsOpenRequest_AnswerRunsTheToolOnce()
+ {
+ // Arrange
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new SingleCallChatClient());
+ await using ResponsesHost host = await StartOneToolAsync(runs, model);
+ string thread = await OpenThreadAsync(host.Services, "t-reload");
+ Asked asked = await AskAsync(host, thread, "send it");
+ (string id, string callId) = Assert.Single(asked.Interrupts);
+ IConversations conversations = host.Services.GetRequiredService();
+
+ // Act: the stored state names the open request.
+ StoredConversation? stored = await conversations.LoadWindowAsync(thread, new TranscriptWindow(null, 100), Ct);
+ Assert.NotNull(stored);
+ var pending = PendingApprovals.Read(stored);
+
+ // Assert: it lists the one open request, and the answer runs the tool once.
+ var open = Assert.Single(pending);
+ Assert.Equal(id, open.RequestId);
+ using HttpResponseMessage response = await PostAsync(
+ host.Client,
+ Run(thread, "r2",
+ [User("u1", "send it"), AssistantCall("a1", callId, "send_email", """{"to":"a@b.com"}""")],
+ Approved(id)));
+ List answered = await ReadEventsAsync(response);
+ Assert.DoesNotContain(answered, e => Type(e) == "RUN_ERROR");
+ Assert.Equal([("send_email", "a@b.com")], runs.All);
+ StoredConversation? after = await conversations.LoadWindowAsync(thread, new TranscriptWindow(null, 100), Ct);
+ Assert.NotNull(after);
+ Assert.Empty(PendingApprovals.Read(after));
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task RegenerateOfAnApprovedToolTurn_AsksNoNewApproval_ToolRunsOnce()
+ {
+ // Arrange
+ ToolRuns runs = new();
+ RecordingChatClient model = new(new CarryChatClient());
+ await using ResponsesHost host = await ResponsesHost.StartAsync(
+ LookupYaml,
+ model,
+ configure: options => _ = options.AddToolSource(_ => new LookupSource(runs)),
+ map: app => app.MapAgUi());
+ string thread = await OpenThreadAsync(host.Services, "t-regen");
+ Asked asked = await AskAsync(host, thread, "look up revenue");
+ (string id, string callId) = Assert.Single(asked.Interrupts);
+ using HttpResponseMessage answered = await PostAsync(
+ host.Client,
+ Run(thread, "r2",
+ [User("u1", "look up revenue"), AssistantCall("a1", callId, "look_it_up", "{}")],
+ Approved(id)));
+ _ = await ReadEventsAsync(answered);
+ Assert.Equal([("look_it_up", "")], runs.All);
+
+ // Act: regenerate the user row; the carried call and result ride along, no new ask.
+ JsonObject body = JsonNode.Parse(Run(thread, "r3", [User("u1", "look up revenue")]))!.AsObject();
+ body["forwardedProps"] = new JsonObject { ["agentcore"] = new JsonObject { ["message_id"] = "u1", ["parent_id"] = JsonValue.Create((string?)null) } };
+ using HttpResponseMessage regenerated = await PostAsync(host.Client, body.ToJsonString());
+ List events = await ReadEventsAsync(regenerated);
+
+ // Assert
+ Assert.DoesNotContain(events, e => Type(e) == "RUN_ERROR");
+ Assert.Equal("success", events[^1].GetProperty("outcome").GetProperty("type").GetString());
+ Assert.Equal([("look_it_up", "")], runs.All);
+ Assert.Contains(
+ model.Requests.SelectMany(request => request.SelectMany(message => message.Contents.OfType())),
+ result => result.CallId == callId);
+ }
+
+ /// Every tool run the test host saw, as (tool, to) pairs in run order.
+ private sealed class ToolRuns
+ {
+ private readonly Lock _gate = new();
+ private readonly List<(string Tool, string To)> _all = [];
+
+ public IReadOnlyList<(string Tool, string To)> All
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return [.. _all];
+ }
+ }
+ }
+
+ public void Add(string tool, string to)
+ {
+ lock (_gate)
+ {
+ _all.Add((tool, to));
+ }
+ }
+ }
+
+ private sealed class TwoGatedSource(ToolRuns runs) : IToolSource
+ {
+ public ValueTask> ProvideAsync(
+ ToolSourceContext context, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult>(
+ [Gated("send_email", "Send an email."), Gated("send_sms", "Send a text message.")]);
+ }
+
+ private ToolRegistration Gated(string name, string description)
+ {
+ return new ToolRegistration(name, description, () => new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
+ (string to) =>
+ {
+ runs.Add(name, to);
+ return $"sent:{name}";
+ },
+ name,
+ description)));
+ }
+ }
+
+ private sealed class OneGatedSource(ToolRuns runs) : IToolSource
+ {
+ public ValueTask> ProvideAsync(
+ ToolSourceContext context, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult>(
+ [new ToolRegistration("send_email", "Send an email.", () => new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
+ (string to) =>
+ {
+ runs.Add("send_email", to);
+ return "sent:send_email";
+ },
+ "send_email",
+ "Send an email.")))]);
+ }
+ }
+
+ /// Serves one approval-required tool that takes no arguments, for the carry test's model.
+ private sealed class LookupSource(ToolRuns runs) : IToolSource
+ {
+ public ValueTask> ProvideAsync(
+ ToolSourceContext context, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult>(
+ [new ToolRegistration("look_it_up", "Look something up.", () => new ApprovalRequiredAIFunction(AIFunctionFactory.Create(
+ () =>
+ {
+ runs.Add("look_it_up", string.Empty);
+ return "42";
+ },
+ "look_it_up",
+ "Look something up.")))]);
+ }
+ }
+
+ /// Counts the model requests that reached the wrapped client, keeping every one.
+ private sealed class RecordingChatClient(IChatClient inner) : IChatClient
+ {
+ private readonly Lock _gate = new();
+ private readonly List> _requests = [];
+ private int _calls;
+
+ public int Calls => Volatile.Read(ref _calls);
+
+ public IReadOnlyList> Requests
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return [.. _requests];
+ }
+ }
+ }
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ List seen = [.. messages];
+ lock (_gate)
+ {
+ _requests.Add(seen);
+ }
+
+ _ = Interlocked.Increment(ref _calls);
+ await foreach (ChatResponseUpdate update in inner.GetStreamingResponseAsync(seen, options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ public async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ List updates = [];
+ await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
+ {
+ updates.Add(update);
+ }
+
+ return updates.ToChatResponse();
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ /// Calls the first tool it is offered once, then answers with each call's result.
+ private sealed class SingleCallChatClient : IChatClient
+ {
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.Yield();
+ List results = [.. messages.SelectMany(m => m.Contents.OfType())];
+ if (results.Count == 0 && options?.Tools?.OfType().FirstOrDefault() is { } tool)
+ {
+ yield return new ChatResponseUpdate(
+ ChatRole.Assistant,
+ [new FunctionCallContent("call_single", tool.Name, new Dictionary(StringComparer.Ordinal) { ["to"] = "a@b.com" })]);
+ yield break;
+ }
+
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "tool said: " + string.Join('|', results.Select(r => r.Result)));
+ }
+
+ public async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ List updates = [];
+ await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
+ {
+ updates.Add(update);
+ }
+
+ return updates.ToChatResponse();
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ /// Calls the first tool it is offered until a result is already in the request, then answers in words.
+ private sealed class CarryChatClient : IChatClient
+ {
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.Yield();
+ bool answered = messages.Any(message => message.Contents.OfType().Any());
+ if (!answered && options?.Tools?.OfType().FirstOrDefault() is { } tool)
+ {
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "let me check.");
+ yield return new ChatResponseUpdate(
+ ChatRole.Assistant,
+ [new FunctionCallContent(
+ "call_" + Guid.NewGuid().ToString("N"),
+ tool.Name,
+ new Dictionary(StringComparer.Ordinal))]);
+ yield break;
+ }
+
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "sorted.");
+ }
+
+ public async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ List updates = [];
+ await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
+ {
+ updates.Add(update);
+ }
+
+ return updates.ToChatResponse();
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+ }
+}
diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiAttachmentTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiAttachmentTests.cs
new file mode 100644
index 0000000..c8a95cc
--- /dev/null
+++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiAttachmentTests.cs
@@ -0,0 +1,345 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using AgentCore.Application.Blobs;
+using AgentCore.Application.Conversation;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Transcript;
+using AgentCore.AspNetCore.DependencyInjection;
+using AgentCore.AspNetCore.Endpoints;
+using AgentCore.AspNetCore.Tests.Fakes;
+using AgentCore.TestSupport;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace AgentCore.AspNetCore.Tests.Endpoints;
+
+///
+/// The AG-UI endpoint's attachment pipeline: an image alone runs a turn, data parts survive
+/// sanitize, disallowed types and oversized bodies are refused before the model runs, a filed
+/// turn stores a file reference while the model gets the bytes, and deleting the conversation
+/// sweeps the filed blobs.
+///
+public sealed class AgUiAttachmentTests
+{
+ private const string Yaml =
+ """
+ apiVersion: agentcore/v1
+ agents:
+ defaults:
+ model: { ref: reply }
+ items:
+ - { id: greeter, instructions: "greet the caller" }
+ providers:
+ conversation: { kind: telnyx-relay }
+ speech:
+ stt: { kind: telnyx-relay }
+ tts: { kind: telnyx-relay }
+ llm:
+ - { kind: openai, model: gpt-4.1-mini, as: reply }
+ blobs: { kind: fake }
+ entries:
+ main:
+ agent: greeter
+ """;
+
+ private const string Route = "/agui/main";
+
+ private const string PngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+
+ private static readonly byte[] Png = Convert.FromBase64String(PngBase64);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static JsonObject TextPart(string text) => new() { ["type"] = "text", ["text"] = text };
+
+ private static JsonObject ImagePart(string base64, string mimeType = "image/png") => new()
+ {
+ ["type"] = "image",
+ ["source"] = new JsonObject { ["type"] = "data", ["value"] = base64, ["mimeType"] = mimeType },
+ };
+
+ private static JsonObject UrlImagePart(string url, string mimeType = "image/png") => new()
+ {
+ ["type"] = "image",
+ ["source"] = new JsonObject { ["type"] = "url", ["value"] = url, ["mimeType"] = mimeType },
+ };
+
+ private static JsonObject UserParts(string id, params JsonObject[] parts)
+ => new() { ["id"] = id, ["role"] = "user", ["content"] = new JsonArray([.. parts]) };
+
+ private static JsonObject UserText(string id, string text)
+ => new() { ["id"] = id, ["role"] = "user", ["content"] = text };
+
+ private static string Run(string threadId, IEnumerable messages)
+ {
+ JsonObject body = new()
+ {
+ ["threadId"] = threadId,
+ ["runId"] = "r1",
+ ["messages"] = new JsonArray([.. messages.Select(m => (JsonNode)m.DeepClone())]),
+ ["tools"] = new JsonArray(),
+ ["context"] = new JsonArray(),
+ ["forwardedProps"] = new JsonObject(),
+ };
+ return body.ToJsonString();
+ }
+
+ private static Task StartAsync(IChatClient reply, FakeBlobStoreAdapter blobs)
+ {
+ return ResponsesHost.StartAsync(
+ Yaml,
+ reply,
+ options => options.UseBlobStores(blobs),
+ map: app => app.MapAgUi());
+ }
+
+ private static Task StartAsync(IChatClient reply)
+ {
+ return ResponsesHost.StartAsync(
+ Yaml,
+ reply,
+ map: app => app.MapAgUi());
+ }
+
+ private static async Task OpenThreadAsync(ResponsesHost host, string threadId)
+ {
+ _ = await host.Services.GetRequiredService().CreateAsync(threadId, Ct);
+ return threadId;
+ }
+
+ private static async Task PostAsync(HttpClient client, string body, string route = Route)
+ {
+ using HttpRequestMessage request = new(HttpMethod.Post, route)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json"),
+ };
+ request.Headers.Accept.ParseAdd("text/event-stream");
+ return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct);
+ }
+
+ private static async Task> ReadEventsAsync(HttpResponseMessage response)
+ {
+ List events = [];
+ await using Stream body = await response.Content.ReadAsStreamAsync(Ct);
+ using StreamReader reader = new(body, Encoding.UTF8);
+ while (await reader.ReadLineAsync(Ct) is { } line)
+ {
+ if (line.StartsWith("data: ", StringComparison.Ordinal))
+ {
+ events.Add(JsonDocument.Parse(line["data: ".Length..]).RootElement.Clone());
+ }
+ }
+
+ return events;
+ }
+
+ [Fact]
+ public void SizeCheck_Counts_DecodedBytes()
+ {
+ ChatMessage message = new(ChatRole.User, [new DataContent(new byte[(int)AgUiInput.MaxMessageChars + 1], "image/png")]);
+
+ Assert.Equal(AgUiInput.TooLargeCode, AgUiInput.CheckSize(message)?.Code);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task ImageAlone_RunsATurn()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("seen");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-image-alone");
+
+ using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u1", ImagePart(PngBase64))]));
+ List events = await ReadEventsAsync(response);
+
+ Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR");
+ Assert.NotNull(reply.LastRequest);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task DisallowedType_Refused_BeforeTheModel()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("unused");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-bad-type");
+
+ using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(Convert.ToBase64String([1, 2, 3]), "text/plain"))]));
+ JsonElement only = Assert.Single(await ReadEventsAsync(response));
+
+ Assert.Equal("RUN_ERROR", only.GetProperty("type").GetString());
+ Assert.Equal("attachment_type_not_allowed", only.GetProperty("code").GetString());
+ Assert.Null(reply.LastRequest);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task OversizedPart_Refused_BeforeTheModel()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("unused");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-too-large");
+ byte[] big = new byte[(5 * 1024 * 1024) + 1];
+
+ using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(Convert.ToBase64String(big)))]));
+ JsonElement only = Assert.Single(await ReadEventsAsync(response));
+
+ Assert.Equal("RUN_ERROR", only.GetProperty("type").GetString());
+ Assert.Equal("attachment_too_large", only.GetProperty("code").GetString());
+ Assert.Null(reply.LastRequest);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task TooManyParts_Refused_BeforeTheModel()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("unused");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-too-many");
+
+ using HttpResponseMessage response = await PostAsync(
+ host.Client,
+ Run(thread, [UserParts("u1", ImagePart(PngBase64), ImagePart(PngBase64), ImagePart(PngBase64), ImagePart(PngBase64), ImagePart(PngBase64))]));
+ JsonElement only = Assert.Single(await ReadEventsAsync(response));
+
+ Assert.Equal("RUN_ERROR", only.GetProperty("type").GetString());
+ Assert.Equal("attachment_too_many", only.GetProperty("code").GetString());
+ Assert.Null(reply.LastRequest);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task UrlSource_Refused_BeforeTheModel()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("unused");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-url");
+
+ using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), UrlImagePart("https://attacker.test/a.png"))]));
+ JsonElement only = Assert.Single(await ReadEventsAsync(response));
+
+ Assert.Equal("RUN_ERROR", only.GetProperty("type").GetString());
+ Assert.Equal("attachment_url_not_allowed", only.GetProperty("code").GetString());
+ Assert.Null(reply.LastRequest);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task FiledTurn_StoresFileContent_AndTheModelGetsDataContent()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("seen it");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-filed");
+
+ using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(PngBase64))]));
+ List events = await ReadEventsAsync(response);
+
+ Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR");
+ List rows = await StoredRows.SettleAsync(host.Services, thread, 2);
+ FileContent file = Assert.IsType(rows[0].Content.Contents.OfType().Single());
+ Assert.True(InboundFiles.Is(file));
+ DataContent model = Assert.IsType(reply.LastRequest!.SelectMany(m => m.Contents).OfType().First());
+ Assert.Equal(Png, model.Data.ToArray());
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task ResolveRefusals_AreByteIdentical()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("seen it");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-resolve");
+ using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(PngBase64))])))
+ {
+ _ = await ReadEventsAsync(first);
+ }
+
+ List before = await StoredRows.SettleAsync(host.Services, thread, 2);
+ int calls = reply.Calls;
+ string beforeJson = JsonSerializer.Serialize(before.Select(r => r.Content), TranscriptJson.Options);
+
+ JsonObject forged = new()
+ {
+ ["type"] = "image",
+ ["source"] = new JsonObject { ["type"] = "url", ["value"] = $"https://other.test/agui/threads/other/files/in-evil", ["mimeType"] = "image/png" },
+ };
+ using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u2", TextPart("again"), forged)]));
+ JsonElement only = Assert.Single(await ReadEventsAsync(response));
+
+ Assert.Equal("RUN_ERROR", only.GetProperty("type").GetString());
+ Assert.Equal("attachment_not_owned", only.GetProperty("code").GetString());
+ Assert.Equal(calls, reply.Calls);
+ List after = await StoredRows.SettleAsync(host.Services, thread, 2);
+ Assert.Equal(beforeJson, JsonSerializer.Serialize(after.Select(r => r.Content), TranscriptJson.Options));
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task Delete_SweepsFiledBlobs()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("seen it");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-sweep");
+ using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(PngBase64))])))
+ {
+ _ = await ReadEventsAsync(first);
+ }
+
+ _ = await StoredRows.SettleAsync(host.Services, thread, 2);
+ Assert.NotEmpty(blobs.Store.Blobs);
+
+ await host.Services.GetRequiredService().DeleteAsync(thread, Ct);
+
+ Assert.Empty(blobs.Store.Blobs);
+ }
+
+ private static async Task KeepAgentFilesAsync(ResponsesHost host, FakeBlobStoreAdapter blobs, string thread, string mediaType, params string[] names)
+ {
+ List files = [];
+ foreach (string name in names)
+ {
+ await blobs.Store.PutAsync(new BlobWrite(thread, name, mediaType, new MemoryStream(Png), Png.Length), Ct);
+ files.Add(new FileContent { Name = name, FileId = "cfile_" + name, MediaType = mediaType, Length = Png.Length, Kept = true });
+ }
+
+ _ = await host.Services.GetRequiredService().AppendAsync(thread, [new ConversationMessageDraft(0, new ChatMessage(ChatRole.Assistant, files), "m1")], null, Ct);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task ReferenceToAnAgentFileOfARefusedType_Is415_ModelNeverRuns()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("seen it");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-ref-type");
+ await KeepAgentFilesAsync(host, blobs, thread, "text/csv", "report.csv");
+
+ using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u2", TextPart("read it"), UrlImagePart($"https://host.test/agui/threads/{thread}/files/report.csv"))]));
+ JsonElement only = Assert.Single(await ReadEventsAsync(response));
+
+ Assert.Equal(AgUiAttachments.TypeNotAllowed, only.GetProperty("code").GetString());
+ Assert.Equal(0, reply.Calls);
+ }
+
+ [Fact(Timeout = 60_000)]
+ public async Task MoreReferencesThanTheCap_Is400TooMany_ModelNeverRuns()
+ {
+ FakeBlobStoreAdapter blobs = new();
+ using FragmentingChatClient reply = new("seen it");
+ await using ResponsesHost host = await StartAsync(reply, blobs);
+ string thread = await OpenThreadAsync(host, "t-ref-many");
+ string[] names = ["a.png", "b.png", "c.png", "d.png", "e.png"];
+ await KeepAgentFilesAsync(host, blobs, thread, "image/png", names);
+
+ JsonObject[] parts = [TextPart("all of them"), .. names.Select(n => UrlImagePart($"https://host.test/agui/threads/{thread}/files/{n}"))];
+ using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u2", parts)]));
+ JsonElement only = Assert.Single(await ReadEventsAsync(response));
+
+ Assert.Equal(AgUiAttachments.TooMany, only.GetProperty("code").GetString());
+ Assert.Equal(0, reply.Calls);
+ }
+}
diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiContentTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiContentTests.cs
new file mode 100644
index 0000000..4252980
--- /dev/null
+++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiContentTests.cs
@@ -0,0 +1,395 @@
+using System.Net;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Transcript;
+using AgentCore.Application.Runtime.Turn;
+using AgentCore.AspNetCore.DependencyInjection;
+using AgentCore.AspNetCore.Endpoints;
+using AgentCore.AspNetCore.Tests.Fakes;
+using AgentCore.Domain.Sources;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace AgentCore.AspNetCore.Tests.Endpoints
+{
+ ///
+ /// The AG-UI route's content mapping: sources wait for the words so the tool group stays one,
+ /// a compaction pass is one activity replaced in place, a failed tool result carries
+ /// isError plus _meta, reasoning streams as reasoning events, and a model fault
+ /// mid-turn still streams the fallback as text.
+ ///
+ public sealed class AgUiContentTests
+ {
+ private const string SourceYaml =
+ """
+ apiVersion: agentcore/v1
+ tools:
+ - id: look_it_up
+ kind: binding
+ binds: LookItUp
+ description: Look something up for the caller.
+ agents:
+ defaults:
+ model: { ref: reply }
+ items:
+ - { id: greeter, instructions: "greet the caller", tools: [ look_it_up ] }
+ providers:
+ conversation: { kind: telnyx-relay }
+ speech:
+ stt: { kind: telnyx-relay }
+ tts: { kind: telnyx-relay }
+ llm:
+ - { kind: openai, model: gpt-4.1-mini, as: reply }
+ entries:
+ main:
+ agent: greeter
+ """;
+
+ private const string Fallback = "Sorry, the order service did not answer.";
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static string Run(string threadId, IEnumerable messages)
+ {
+ JsonObject body = new()
+ {
+ ["threadId"] = threadId,
+ ["runId"] = "r1",
+ ["messages"] = new JsonArray([.. messages.Select(m => (JsonNode)m.DeepClone())]),
+ ["tools"] = new JsonArray(),
+ ["context"] = new JsonArray(),
+ ["forwardedProps"] = new JsonObject(),
+ };
+ return body.ToJsonString();
+ }
+
+ private static JsonObject User(string id, string text) => new() { ["id"] = id, ["role"] = "user", ["content"] = text };
+
+ private static async Task StartAsync(IChatClient reply, Action? configure = null)
+ {
+ return await ResponsesHost.StartAsync(
+ TwoStepChatClient.Yaml,
+ reply,
+ options =>
+ {
+ TwoStepChatClient.BindTool(options);
+ configure?.Invoke(options);
+ },
+ map: app => app.MapAgUi());
+ }
+
+ private static async Task OpenThreadAsync(ResponsesHost host, string threadId)
+ {
+ _ = await host.Services.GetRequiredService().CreateAsync(threadId, Ct);
+ return threadId;
+ }
+
+ private static async Task PostAsync(HttpClient client, string body, string route = "/agui/main")
+ {
+ using HttpRequestMessage request = new(HttpMethod.Post, route)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json"),
+ };
+ request.Headers.Accept.ParseAdd("text/event-stream");
+ return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct);
+ }
+
+ private static async Task> ReadEventsAsync(HttpResponseMessage response)
+ {
+ List events = [];
+ await using Stream body = await response.Content.ReadAsStreamAsync(Ct);
+ using StreamReader reader = new(body, Encoding.UTF8);
+ while (await reader.ReadLineAsync(Ct) is { } line)
+ {
+ if (line.StartsWith("data: ", StringComparison.Ordinal))
+ {
+ events.Add(JsonDocument.Parse(line["data: ".Length..]).RootElement.Clone());
+ }
+ }
+
+ return events;
+ }
+
+ private static List OfType(List events, string type)
+ {
+ return events.Where(e => e.GetProperty("type").GetString() == type).ToList();
+ }
+
+ private static string ClientText(List events)
+ {
+ return string.Concat(
+ OfType(events, "TEXT_MESSAGE_CONTENT").Select(e => e.GetProperty("delta").GetString() ?? string.Empty));
+ }
+
+ private static List Custom(List events, string name)
+ {
+ return OfType(events, "CUSTOM").Where(e => e.GetProperty("name").GetString() == name).ToList();
+ }
+ [Fact(Timeout = 60_000)]
+ public async Task Sources_WaitForTheWords_SoTheToolGroupStaysOne_AndStreamOnce()
+ {
+ TwoStepChatClient model = new([TwoStepChatClient.Call], ["it is an overcurrent trip."]);
+ await using ResponsesHost host = await ResponsesHost.StartAsync(
+ SourceYaml,
+ model,
+ options => options.Bind(
+ TwoStepChatClient.ToolBinding,
+ (TurnInvocation? turn) =>
+ {
+ turn?.Sources?.Publish(
+ new SourceReference
+ {
+ SourceId = "card-42",
+ Kind = SourceKind.Document,
+ Title = "Spirit CT900 owner's manual",
+ Origin = "knowledge",
+ Locator = "p.27",
+ },
+ turn.OuterCallId);
+ return ValueTask.FromResult