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("E03 is an overcurrent trip."); + }), + map: app => app.MapAgUi()); + string thread = await OpenThreadAsync(host, "t-sources"); + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [User("u1", "what is E03")])); + List events = await ReadEventsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + + JsonElement source = Assert.Single(Custom(events, "agentcore_source")); + Assert.Equal("card-42", source.GetProperty("value").GetProperty("id").GetString()); + + int sourceAt = events.FindIndex(e => e.GetProperty("type").GetString() == "CUSTOM" + && e.GetProperty("name").GetString() == "agentcore_source"); + int lastTool = events.FindLastIndex(e => (e.GetProperty("type").GetString() ?? string.Empty).StartsWith("TOOL_CALL", StringComparison.Ordinal)); + int firstWords = events.FindIndex(e => e.GetProperty("type").GetString() == "TEXT_MESSAGE_START"); + Assert.True(sourceAt > lastTool); + Assert.True(firstWords >= 0 && sourceAt < firstWords); + } + + [Fact(Timeout = 60_000)] + public async Task CompactionStartAndEnd_ReplaceTheSameActivityInPlace() + { + await using ResponsesHost host = await StartAsync(new CompactionEmittingChatClient()); + string thread = await OpenThreadAsync(host, "t-compaction"); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [User("u1", "hello")])); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + + List activities = OfType(events, "ACTIVITY_SNAPSHOT"); + Assert.Equal(2, activities.Count); + Assert.Single(activities.Select(a => a.GetProperty("messageId").GetString()).Distinct()); + Assert.Equal("turn-0.compaction", activities[0].GetProperty("messageId").GetString()); + Assert.All(activities, a => Assert.Equal("compaction", a.GetProperty("activityType").GetString())); + Assert.All(activities, a => Assert.True(a.GetProperty("replace").GetBoolean())); + Assert.Equal("start", activities[0].GetProperty("content").GetProperty("phase").GetString()); + Assert.Equal("end", activities[1].GetProperty("content").GetProperty("phase").GetString()); + Assert.Empty(Custom(events, "agentcore_compaction")); + } + + [Fact(Timeout = 60_000)] + public async Task FailedToolResult_CarriesIsErrorAndMeta_WhileTheSuccessfulOneCarriesNeither() + { + TwoStepChatClient model = new(["Let me check.", TwoStepChatClient.Call], [TwoStepChatClient.Call]); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoStepChatClient.YamlWithFallback(Fallback), + model, + TwoStepChatClient.BindToolThatFailsAfterItsFirstCall, + map: app => app.MapAgUi()); + string thread = await OpenThreadAsync(host, "t-failed"); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [User("u1", "check the order")])); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + + List results = OfType(events, "TOOL_CALL_RESULT"); + Assert.True(results.Count >= 2, $"expected a success and a failure, saw {results.Count}"); + + JsonElement success = Assert.Single(results, r => r.GetProperty("content").GetString() == "42"); + Assert.False(success.TryGetProperty("isError", out _)); + + List failed = results.Where(r => r.GetProperty("content").GetString() != "42").ToList(); + Assert.NotEmpty(failed); + Assert.All(failed, r => Assert.True(r.GetProperty("isError").GetBoolean())); + Assert.All(failed, r => Assert.True( + r.GetProperty("_meta").GetProperty("agentcore").GetProperty("failed").GetBoolean())); + } + + [Fact(Timeout = 60_000)] + public async Task ReasoningContent_StreamsReasoningEvents_BeforeTheWords() + { + await using ResponsesHost host = await StartAsync(new ReasoningChatClient()); + string thread = await OpenThreadAsync(host, "t-reasoning"); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [User("u1", "think about it")])); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + + string reasoning = string.Concat(OfType(events, "REASONING_MESSAGE_CONTENT") + .Select(e => e.GetProperty("delta").GetString() ?? string.Empty)); + Assert.Equal("Let me think about E03. It is the overcurrent code.", reasoning); + Assert.Equal("it is an overcurrent trip.", ClientText(events)); + Assert.True(events.FindIndex(e => e.GetProperty("type").GetString() == "REASONING_START") >= 0); + Assert.True( + events.FindIndex(e => e.GetProperty("type").GetString() == "REASONING_END") + < events.FindIndex(e => e.GetProperty("type").GetString() == "TEXT_MESSAGE_START")); + } + + [Fact(Timeout = 60_000)] + public async Task ModelFaultMidTurn_StreamsTheFallbackAsText_AndKeepsTwoStoredRows() + { + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoStepChatClient.YamlWithFallback(Fallback), + new FlakyChatClient(), + TwoStepChatClient.BindTool, + map: app => app.MapAgUi()); + string thread = await OpenThreadAsync(host, "t-fallback"); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [User("u1", "flaky one")])); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.Equal( + "success", + OfType(events, "RUN_FINISHED").Single().GetProperty("outcome").GetProperty("type").GetString()); + Assert.Equal("partial " + Fallback, ClientText(events)); + + List rows = await StoredRows.SettleAsync(host.Services, thread, 3); + List spoken = rows + .Where(r => r.Content.Role == ChatRole.Assistant) + .Select(r => r.Content.Text.Trim()) + .ToList(); + Assert.Equal(["partial", Fallback], spoken); + } + + /// Notices a compaction pass around one line of words, the way the turn loop delivers notices. + private sealed class CompactionEmittingChatClient : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new AgentCore.Application.Runtime.Compaction.CompactionContent("start")]); + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new AgentCore.Application.Runtime.Compaction.CompactionContent("end", "compacted")]); + yield return new ChatResponseUpdate(ChatRole.Assistant, "tidy words."); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + 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() + { + } + } + + /// Thinks twice, then answers in words. + private sealed class ReasoningChatClient : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new TextReasoningContent("Let me think about E03. ")]); + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new TextReasoningContent("It is the overcurrent code.")]); + yield return new ChatResponseUpdate(ChatRole.Assistant, "it is an overcurrent trip."); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + 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() + { + } + } + + /// Speaks half a line, then fails the way a vendor outage does. + private sealed class FlakyChatClient : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "partial "); + throw new InvalidOperationException("the scripted model failed on purpose"); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + 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/AgUiFileRefTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiFileRefTests.cs new file mode 100644 index 0000000..02cda37 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiFileRefTests.cs @@ -0,0 +1,695 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AgentCore.Application.Blobs; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation; +using AgentCore.Application.Hooks; +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.AspNetCore.Http; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints; + +/// +/// The AG-UI endpoint's stable file references: the absolute door URL a browser holds, the link +/// route that mints a fresh URL, the door that redirects or streams, the live agentcore_file +/// CUSTOM part carrying ref not url, and byte-identical rows after every refusal. +/// +public sealed class AgUiFileRefTests +{ + 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 PublishYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: publish, kind: builtin, uses: file.publish } + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller", tools: [ publish ] } + 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, params AgentHook[] hooks) + { + return ResponsesHost.StartAsync( + Yaml, + reply, + options => + { + _ = options.UseBlobStores(blobs); + if (hooks.Length > 0) + { + _ = options.UseHooks(hooks); + } + }, + 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; + } + + private static string RowsJson(List rows) + => JsonSerializer.Serialize(rows.Select(r => r.Content), TranscriptJson.Options); + + [Fact] + public void RefOf_IsAbsoluteHttpShape() + { + DefaultHttpContext http = new(); + http.Request.Scheme = "https"; + http.Request.Host = new HostString("client.test"); + http.Request.PathBase = new PathString("/base"); + + string reference = AgUiFileRefs.RefOf(http.Request, "t1", "in-abc.png"); + + Assert.Equal("https://client.test/base/agui/threads/t1/files/in-abc.png", reference); + } + + [Fact] + public void PathOf_EscapesThreadAndName() + { + Assert.Equal("/agui/threads/t%201/files/a%20b.png", AgUiFileRefs.PathOf("t 1", "a b.png")); + } + + [Theory] + [InlineData("https://host.test/agui/threads/t1/files/in-a.png", "t1", "in-a.png")] + [InlineData("http://host.test/base/agui/threads/t1/files/in-a.png", "t1", "in-a.png")] + [InlineData("/agui/threads/t1/files/in-a.png", "t1", "in-a.png")] + public void TryRead_AcceptsHttpAndRootRelative(string value, string thread, string name) + { + PathString pathBase = value.Contains("/base/", StringComparison.Ordinal) ? new PathString("/base") : default; + + Assert.True(AgUiFileRefs.TryRead(new Uri(value, UriKind.RelativeOrAbsolute), pathBase, out string foundThread, out string foundName)); + Assert.Equal((thread, name), (foundThread, foundName)); + } + + [Theory] + [InlineData("data:image/png;base64,aaaa")] + [InlineData("ftp://host.test/agui/threads/t1/files/in-a.png")] + [InlineData("https://host.test/other/threads/t1/files/in-a.png")] + [InlineData("https://host.test/agui/threads/t1/files/")] + public void TryRead_RejectsForeignShapes(string value) + { + Assert.False(AgUiFileRefs.TryRead(new Uri(value, UriKind.RelativeOrAbsolute), default, out _, out _)); + } + + [Fact(Timeout = 60_000)] + public async Task OwnReference_ResolvesToStoredFile_BeforeTheModel() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-own"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(first); + } + + List rows = await StoredRows.SettleAsync(host.Services, thread, 2); + string stored = Assert.IsType(rows[0].Content.Contents.OfType().Single()).Name; + string reference = $"{host.Client.BaseAddress}agui/threads/{thread}/files/{stored}".Replace("http://", "https://", StringComparison.Ordinal); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u2", TextPart("again"), UrlImagePart(reference))])); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.Contains(reply.LastRequest!.SelectMany(m => m.Contents).OfType(), data => data.Data.ToArray().SequenceEqual(Png)); + } + + [Fact(Timeout = 60_000)] + public async Task OtherConversationReference_Is403_ModelNeverRuns_RowsIdentical() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-mine"); + string other = await OpenThreadAsync(host, "t-other"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(other, [UserParts("u1", TextPart("look"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(first); + } + + List otherRows = await StoredRows.SettleAsync(host.Services, other, 2); + string stored = Assert.IsType(otherRows[0].Content.Contents.OfType().Single()).Name; + IConversationStore store = host.Services.GetRequiredService(); + string before = JsonSerializer.Serialize(await store.ReadForSessionAsync(thread, Ct), TranscriptJson.Options); + int calls = reply.Calls; + + string reference = $"https://foreign.test/agui/threads/{other}/files/{stored}"; + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u2", TextPart("again"), UrlImagePart(reference))])); + 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); + Assert.Equal(before, JsonSerializer.Serialize(await store.ReadForSessionAsync(thread, Ct), TranscriptJson.Options)); + } + + [Fact(Timeout = 60_000)] + public async Task UnknownName_Is404_RowsIdentical() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-unknown"); + + 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); + string beforeJson = RowsJson(before); + + string reference = $"https://host.test/agui/threads/{thread}/files/in-deadbeefdeadbeef.png"; + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u2", TextPart("again"), UrlImagePart(reference))])); + JsonElement only = Assert.Single(await ReadEventsAsync(response)); + + Assert.Equal("RUN_ERROR", only.GetProperty("type").GetString()); + Assert.Equal("attachment_not_found", only.GetProperty("code").GetString()); + Assert.Equal(beforeJson, RowsJson(await StoredRows.SettleAsync(host.Services, thread, 2))); + } + + [Fact(Timeout = 60_000)] + public async Task ReferenceToAFileTheStoreDidNotKeep_Is404_RowsIdentical() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-unkept"); + await blobs.Store.PutAsync(new BlobWrite(thread, "dropped.png", "image/png", new MemoryStream(Png), Png.Length), Ct); + await host.Services.GetRequiredService().AppendAsync(thread, [new ConversationMessageDraft(0, new ChatMessage(ChatRole.Assistant, [new FileContent { Name = "dropped.png", FileId = "cfile_1", MediaType = "image/png", Length = Png.Length }]), "m1")], null, Ct); + string beforeJson = RowsJson(await StoredRows.SettleAsync(host.Services, thread, 1)); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u2", TextPart("again"), UrlImagePart($"https://host.test/agui/threads/{thread}/files/dropped.png"))])); + JsonElement only = Assert.Single(await ReadEventsAsync(response)); + + Assert.Equal("attachment_not_found", only.GetProperty("code").GetString()); + Assert.Equal(0, reply.Calls); + Assert.Equal(beforeJson, RowsJson(await StoredRows.SettleAsync(host.Services, thread, 1))); + } + + [Fact(Timeout = 60_000)] + public async Task ReferenceToAnAgentFile_ReachesTheModelAsBytes() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-agent-file"); + await blobs.Store.PutAsync(new BlobWrite(thread, "chart.png", "image/png", new MemoryStream(Png), Png.Length), Ct); + await host.Services.GetRequiredService().AppendAsync(thread, [new ConversationMessageDraft(0, new ChatMessage(ChatRole.Assistant, [new FileContent { Name = "chart.png", FileId = "cfile_1", MediaType = "image/png", Length = Png.Length, Kept = true }]), "m1")], null, Ct); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u2", TextPart("describe it"), UrlImagePart($"https://host.test/agui/threads/{thread}/files/chart.png"))])); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.Contains(reply.LastRequest!.SelectMany(m => m.Contents).OfType(), data => data.Data.ToArray().SequenceEqual(Png)); + } + + [Fact(Timeout = 60_000)] + public async Task TraversalName_Is400_RowsIdentical() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-traversal"); + + 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); + string beforeJson = RowsJson(before); + + string reference = $"https://host.test/agui/threads/{thread}/files/%2E%2E%2Fevil.png"; + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [UserParts("u2", TextPart("again"), UrlImagePart(reference))])); + JsonElement only = Assert.Single(await ReadEventsAsync(response)); + + Assert.Equal("RUN_ERROR", only.GetProperty("type").GetString()); + Assert.Equal("attachment_ref_invalid", only.GetProperty("code").GetString()); + Assert.Equal(beforeJson, RowsJson(await StoredRows.SettleAsync(host.Services, thread, 2))); + } + + [Fact(Timeout = 60_000)] + public async Task LinkRoute_MintsFreshUrlAndExpiry_WithNoStore() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-link"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(first); + } + + List rows = await StoredRows.SettleAsync(host.Services, thread, 2); + string stored = Assert.IsType(rows[0].Content.Contents.OfType().Single()).Name; + + using HttpResponseMessage link = await host.Client.GetAsync($"/agui/threads/{thread}/files/{stored}/link", Ct); + + Assert.Equal(HttpStatusCode.OK, link.StatusCode); + Assert.True(link.Headers.CacheControl?.NoStore == true); + JsonNode body = await ResponsesHost.ReadJsonAsync(link); + Assert.StartsWith("https://blobs.test/", body["url"]!.GetValue(), StringComparison.Ordinal); + Assert.True(body["expiresAt"]!.GetValue() > DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + + [Fact(Timeout = 60_000)] + public async Task LinkRoute_WithNoDoor_AnswersTheReferenceItself() + { + NoDoorBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + reply, + options => _ = options.UseBlobStores(blobs), + map: app => + { + _ = app.MapAgUi(); + }); + string thread = await OpenThreadAsync(host, "t-nodoor"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(first); + } + + List rows = await StoredRows.SettleAsync(host.Services, thread, 2); + string stored = Assert.IsType(rows[0].Content.Contents.OfType().Single()).Name; + + using HttpResponseMessage link = await host.Client.GetAsync($"/agui/threads/{thread}/files/{stored}/link", Ct); + + JsonNode body = await ResponsesHost.ReadJsonAsync(link); + Assert.EndsWith($"/agui/threads/{thread}/files/{stored}", body["url"]!.GetValue(), StringComparison.Ordinal); + Assert.Null(body["expiresAt"]); + } + + [Fact(Timeout = 60_000)] + public async Task Door_WithWebDoor_RedirectsUnderCallerName() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-door-redir"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(first); + } + + List rows = await StoredRows.SettleAsync(host.Services, thread, 2); + string stored = Assert.IsType(rows[0].Content.Contents.OfType().Single()).Name; + + using HttpClientHandler noFollow = new() { AllowAutoRedirect = false }; + using HttpClient direct = new(noFollow) { BaseAddress = host.Client.BaseAddress }; + using HttpResponseMessage door = await direct.GetAsync($"/agui/threads/{thread}/files/{stored}", Ct); + + Assert.Equal(HttpStatusCode.Found, door.StatusCode); + Assert.StartsWith("https://blobs.test/", door.Headers.Location!.ToString(), StringComparison.Ordinal); + } + + [Fact(Timeout = 60_000)] + public async Task Door_WithNoDoor_StreamsBytesUnderCallerName() + { + NoDoorBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + reply, + options => _ = options.UseBlobStores(blobs), + map: app => + { + _ = app.MapAgUi(); + }); + string thread = await OpenThreadAsync(host, "t-door-stream"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("look"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(first); + } + + List rows = await StoredRows.SettleAsync(host.Services, thread, 2); + FileContent file = Assert.IsType(rows[0].Content.Contents.OfType().Single()); + + using HttpResponseMessage door = await host.Client.GetAsync($"/agui/threads/{thread}/files/{file.Name}", Ct); + + Assert.Equal(HttpStatusCode.OK, door.StatusCode); + Assert.Equal("image/png", door.Content.Headers.ContentType?.MediaType); + Assert.Contains(Uri.EscapeDataString(file.Name), door.Content.Headers.ContentDisposition?.ToString(), StringComparison.Ordinal); + Assert.Equal(Png, await door.Content.ReadAsByteArrayAsync(Ct)); + } + + [Fact(Timeout = 60_000)] + public async Task Door_WithNonAsciiName_StreamsBytesUnderEncodedName() + { + NoDoorBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + reply, + options => _ = options.UseBlobStores(blobs), + map: app => + { + _ = app.MapAgUi(); + }); + string thread = await OpenThreadAsync(host, "t-door-utf8"); + IConversationStore store = host.Services.GetRequiredService(); + + await blobs.Store.PutAsync(new BlobWrite(thread, "in-cafe.png", "image/png", new MemoryStream(Png), Png.Length), Ct); + await store.AppendAsync(thread, [new ConversationMessageDraft(0, new ChatMessage(ChatRole.User, [new FileContent { Name = "in-cafe.png", FileId = "upload:café.png", Title = "café.png", MediaType = "image/png", Length = Png.Length, Kept = true }]), "m1")], null, Ct); + + using HttpResponseMessage door = await host.Client.GetAsync($"/agui/threads/{thread}/files/in-cafe.png", Ct); + + Assert.Equal(HttpStatusCode.OK, door.StatusCode); + Assert.Contains("caf%C3%A9.png", door.Content.Headers.ContentDisposition?.ToString(), StringComparison.Ordinal); + Assert.Equal(Png, await door.Content.ReadAsByteArrayAsync(Ct)); + } + + [Theory(Timeout = 60_000)] + [InlineData("", "a..b.png")] + [InlineData("", "%01evil.png")] + [InlineData("/link", "a..b.png")] + [InlineData("/link", "%01evil.png")] + public async Task UnsafeName_AtADoor_Is404_StoreNeverAsked(string suffix, string name) + { + NoDoorBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + reply, + options => _ = options.UseBlobStores(blobs), + map: app => + { + _ = app.MapAgUi(); + }); + string thread = await OpenThreadAsync(host, "t-door-unsafe"); + + using HttpResponseMessage door = await host.Client.GetAsync($"/agui/threads/{thread}/files/{name}{suffix}", Ct); + + Assert.Equal(HttpStatusCode.NotFound, door.StatusCode); + Assert.Empty(blobs.Store.Asked); + } + + [Fact(Timeout = 60_000)] + public async Task Door_BehindOwnershipHook_RefusedCallerSeesNothing() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs, new RefuseEveryConversation()); + string thread = await OpenThreadAsync(host, "t-door-owned"); + + await blobs.Store.PutAsync(new BlobWrite(thread, "in-owned.png", "image/png", new MemoryStream(Png), Png.Length), Ct); + + using HttpResponseMessage door = await host.Client.GetAsync($"/agui/threads/{thread}/files/in-owned.png", Ct); + + Assert.Equal(HttpStatusCode.NotFound, door.StatusCode); + } + + [Fact(Timeout = 60_000)] + public async Task LivePublish_CarriesRefNotUrl() + { + string root = Path.Combine(Path.GetTempPath(), "agentcore-fileref-" + Guid.NewGuid().ToString("N")); + string conversation = "conversation-pub-ref"; + _ = Directory.CreateDirectory(Path.Combine(root, conversation)); + try + { + File.WriteAllText(Path.Combine(root, conversation, "report.csv"), "month,sales\njan,10\n"); + FakeBlobStoreAdapter blobs = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + PublishYaml, + new PublishingChatClient(), + configure: options => options.UseWorkspace(root).UseBlobStores(blobs), + map: app => + { + _ = app.MapAgUi(); + }); + _ = await host.Services.GetRequiredService().CreateAsync(conversation, Ct); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(conversation, [UserText("u1", "send me the report")])); + List events = await ReadEventsAsync(response); + + JsonElement file = events + .Where(e => e.GetProperty("type").GetString() == "CUSTOM" && e.GetProperty("name").GetString() == "agentcore_file") + .Select(e => e.GetProperty("value")) + .Single(); + Assert.Equal("report.csv", file.GetProperty("name").GetString()); + string reference = file.GetProperty("ref").GetString()!; + Assert.EndsWith($"/agui/threads/{conversation}/files/report.csv", reference, StringComparison.Ordinal); + Assert.False(file.TryGetProperty("url", out _)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private sealed class RefuseEveryConversation : AgentHook + { + public override ValueTask BeforeConversationAsync(global::AgentCore.Application.Hooks.Gates.ConversationGate gate, CancellationToken cancellationToken) + { + gate.Refuse(); + return default; + } + } + + private sealed class NoDoorBlobStoreAdapter(string kind = "fake") : IBlobStoreAdapter + { + public string Kind => kind; + + public NoDoorBlobStore Store { get; } = new(); + + public ValueTask OpenAsync( + BlobProviderConfiguration entry, + ISecretResolverPort? secrets, + CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(Store); + } + } + + private sealed class NoDoorBlobStore : IBlobStore + { + private readonly Dictionary<(string Owner, string Name), (string MediaType, byte[] Bytes)> _blobs = []; + + public List Asked { get; } = []; + + public ValueTask PutAsync(BlobWrite write, CancellationToken cancellationToken = default) + { + using MemoryStream copy = new(); + write.Content.CopyTo(copy); + lock (_blobs) + { + _blobs[(write.OwnerId, write.Name)] = (write.MediaType, copy.ToArray()); + } + + return ValueTask.FromResult(new BlobRef(write.OwnerId, write.Name, write.MediaType, write.Length)); + } + + public ValueTask OpenReadAsync(string ownerId, string name, CancellationToken cancellationToken = default) + { + Asked.Add(name); + return ValueTask.FromResult(_blobs.TryGetValue((ownerId, name), out (string MediaType, byte[] Bytes) blob) + ? new BlobRead(blob.MediaType, blob.Bytes.Length, new MemoryStream(blob.Bytes)) + : null); + } + + public ValueTask StatAsync(string ownerId, string name, CancellationToken cancellationToken = default) + { + Asked.Add(name); + 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(null); + } + + 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; + } + } + + /// Calls the publish tool it is offered, once, on the report, then answers in words. + private sealed class PublishingChatClient : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + bool alreadyPublished = messages.Any(message => message.Contents.OfType().Any()); + + if (!alreadyPublished && options?.Tools?.OfType().FirstOrDefault(tool => tool.Name == "publish") is { } publish) + { + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new FunctionCallContent( + "conversation_1", + publish.Name, + new Dictionary(StringComparer.Ordinal) + { + ["path"] = "report.csv", + ["title"] = "Quarterly report", + })]); + yield break; + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, "here is the report."); + } + + 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/AgUiInputTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiInputTests.cs new file mode 100644 index 0000000..0a6e585 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiInputTests.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using AGUI.Abstractions; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Endpoints; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// The AG-UI route's input rules: the newest user message with text is the turn's words, + /// extra forwarded properties are dropped, URL parts never reach the model, the author + /// name is cleared, and an oversized message is refused before a turn starts. + /// + public sealed class AgUiInputTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static RunAgentInput Input(params AGUIMessage[] messages) + { + RunAgentInput input = new() { ThreadId = "t", RunId = "r" }; + foreach (AGUIMessage message in messages) + { + input.Messages.Add(message); + } + + return input; + } + + private static RunAgentInput WithForwarded(string json) + { + using JsonDocument forwarded = JsonDocument.Parse(json); + RunAgentInput input = Input(new AGUIUserMessage { Id = "u1", Content = "hi" }); + input.ForwardedProperties = forwarded.RootElement.Clone(); + return input; + } + + private static Conversations Door(InMemoryConversationStore store) + { + return new Conversations(store, blobs: null); + } + + private static async Task SeedTurnsAsync(string conversationId) + { + InMemoryConversationStore store = new(); + _ = await store.CreateAsync(conversationId, Ct); + _ = await store.AppendAsync( + conversationId, + [ + new ConversationMessageDraft(0, new ChatMessage(ChatRole.User, "first"), "u-a"), + new ConversationMessageDraft(0, new ChatMessage(ChatRole.Assistant, "answer one"), "a-a"), + new ConversationMessageDraft(1, new ChatMessage(ChatRole.User, "second"), "u-b"), + new ConversationMessageDraft(1, new ChatMessage(ChatRole.Assistant, "answer two"), "a-b"), + ], + state: null, + cancellationToken: Ct); + return store; + } + + [Fact] + public void TurnIds_NameBlocks_TurnKnResolvesTurnK() + { + Assert.Equal("turn-3", AgUiTurnIds.TurnMessageId(3)); + Assert.Equal("turn-3.1", AgUiTurnIds.BlockMessageId(3, 1)); + Assert.Equal(3, AgUiTurnIds.TurnIndexOf("turn-3.1")); + Assert.Equal(3, AgUiTurnIds.TurnIndexOf("turn-3")); + Assert.Null(AgUiTurnIds.TurnIndexOf("u-1")); + Assert.Null(AgUiTurnIds.TurnIndexOf("turn-x")); + Assert.Null(AgUiTurnIds.TurnIndexOf(null)); + } + + [Fact] + public void NewestUserText_Picked_OthersIgnored() + { + List messages = + [ + new(ChatRole.System, "be brief"), + new(ChatRole.User, "old"), + new(ChatRole.Assistant, "old answer"), + new(ChatRole.User, "new"), + new(ChatRole.User, string.Empty), + ]; + + Assert.Equal("new", AgUiAttachments.LastUserInput(messages)?.Text); + Assert.Null(AgUiAttachments.LastUserInput([new(ChatRole.System, "be brief"), new(ChatRole.Assistant, "hi")])); + Assert.Null(AgUiAttachments.LastUserInput([new(ChatRole.User, string.Empty)])); + } + + [Fact] + public void ForgedToolsSystemContextStateIgnored_OnlyAnchorRead() + { + RunAgentInput input = WithForwarded( + """{"agentcore":{"message_id":"m-1","parent_id":"p-1"},"runConfig":{"model":"evil"},"temperature":0.9}"""); + + AgUiTurnIds.Anchor anchor = AgUiTurnIds.Read(input); + + Assert.Equal(("m-1", "p-1", true), (anchor.MessageId, anchor.ParentId, anchor.NamesParent)); + } + + [Fact] + public void UrlImagePart_Kept_ForResolveOrRefuseDownstream() + { + ChatMessage message = new(ChatRole.User, [new TextContent("see this"), new UriContent("https://x/y.png", "image/png")]); + + ChatMessage sanitized = AgUiInput.Sanitize(message); + + Assert.Contains(sanitized.Contents, content => content is UriContent); + Assert.Contains(sanitized.Contents, content => content is TextContent text && text.Text == "see this"); + } + + [Fact] + public void UserName_Cleared() + { + ChatMessage message = new(ChatRole.User, "hi") { AuthorName = "Mallory" }; + + ChatMessage sanitized = AgUiInput.Sanitize(message); + + Assert.Null(sanitized.AuthorName); + } + + [Fact] + public void OversizedMessage_RefusedBelow30MB() + { + ChatMessage message = new(ChatRole.User, new string('x', (5 * 1024 * 1024) + 1)); + + (int Status, string Code, string Message)? refusal = AgUiInput.CheckSize(message); + + Assert.NotNull(refusal); + Assert.Equal((400, "message_too_large"), (refusal.Value.Status, refusal.Value.Code)); + } + + [Fact] + public async Task ParentTurnK_ResolvesToLastStoredRowOfTurnK() + { + InMemoryConversationStore store = await SeedTurnsAsync("c1"); + + ConversationTurnOrigin origin = await AgUiTurnIds.OriginAsync( + new AgUiTurnIds.Anchor("u-new", AgUiTurnIds.TurnMessageId(0), true), "c1", Door(store), Ct); + + Assert.Equal("a-a", origin.ParentMessageId); + } + + [Fact] + public async Task ParentTurnKn_ResolvesToSameRow() + { + InMemoryConversationStore store = await SeedTurnsAsync("c1"); + + ConversationTurnOrigin origin = await AgUiTurnIds.OriginAsync( + new AgUiTurnIds.Anchor("u-new", AgUiTurnIds.BlockMessageId(0, 1), true), "c1", Door(store), Ct); + + Assert.Equal("a-a", origin.ParentMessageId); + } + + [Fact] + public async Task ParentNull_WithdrawsWholeConversation() + { + InMemoryConversationStore store = await SeedTurnsAsync("c1"); + + ConversationTurnOrigin origin = await AgUiTurnIds.OriginAsync( + new AgUiTurnIds.Anchor("u-new", null, true), "c1", Door(store), Ct); + + Assert.Equal((null, true), (origin.ParentMessageId, origin.NamesParent)); + } + + [Fact] + public async Task UnknownParent_RunsAsPlainNewTurn() + { + InMemoryConversationStore store = await SeedTurnsAsync("c1"); + + ConversationTurnOrigin origin = await AgUiTurnIds.OriginAsync( + new AgUiTurnIds.Anchor("u-new", "no-such-row", true), "c1", Door(store), Ct); + + Assert.Equal("no-such-row", origin.ParentMessageId); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiRefusalTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiRefusalTests.cs new file mode 100644 index 0000000..5c5e9b5 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiRefusalTests.cs @@ -0,0 +1,208 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Ports; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// The AG-UI route's refusals: every refusal before a turn starts is one RUN_ERROR + /// in a 200 stream, or the status form under ?refusals=status. + /// + public sealed class AgUiRefusalTests + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: front, instructions: "answer visitors", model: { ref: front } } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: front } + entries: + main: + agent: front + """; + + private const string Route = "/agui/main"; + + 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 Task StartAsync(FragmentingChatClient reply, params AgentHook[] hooks) + { + return ResponsesHost.StartAsync( + Yaml, + reply, + options => _ = options.UseHooks(hooks), + map: app => app.MapAgUi()); + } + + 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(Timeout = 60_000)] + public async Task UnknownThreadId_IsSingleRunErrorThreadNotFound_ModelNeverRuns_RowsUnchanged() + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartAsync(reply); + + using HttpResponseMessage response = await PostAsync(host.Client, Run("no-such", [User("u1", "hi")])); + List events = await ReadEventsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement only = Assert.Single(events); + Assert.Equal("RUN_ERROR", only.GetProperty("type").GetString()); + Assert.Equal("thread_not_found", only.GetProperty("code").GetString()); + Assert.Null(reply.LastRequest); + IConversations conversations = host.Services.GetRequiredService(); + Assert.Null(await conversations.GetAsync("no-such", Ct)); + } + + [Fact(Timeout = 60_000)] + public async Task HookRefusal_IsIdenticalCodeAndTextToUnknownThread() + { + using FragmentingChatClient refusingReply = new("unused"); + await using ResponsesHost refusing = await StartAsync(refusingReply, new RefuseEveryConversation()); + const string ThreadId = "t1"; + _ = await refusing.Services.GetRequiredService().CreateAsync(ThreadId, Ct); + + JsonElement refused; + using (HttpResponseMessage response = await PostAsync(refusing.Client, Run(ThreadId, [User("u1", "hi")]))) + { + refused = Assert.Single(await ReadEventsAsync(response)); + } + + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartAsync(reply); + + JsonElement missing; + using (HttpResponseMessage response = await PostAsync(host.Client, Run(ThreadId, [User("u1", "hi")]))) + { + missing = Assert.Single(await ReadEventsAsync(response)); + } + + Assert.Equal("thread_not_found", refused.GetProperty("code").GetString()); + Assert.Equal(missing.GetProperty("code").GetString(), refused.GetProperty("code").GetString()); + Assert.Equal(missing.GetProperty("message").GetString(), refused.GetProperty("message").GetString()); + Assert.Null(refusingReply.LastRequest); + } + + [Fact(Timeout = 60_000)] + public async Task MissingThreadId_IsSingleRunErrorMissingThread() + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartAsync(reply); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(string.Empty, [User("u1", "hi")])); + List events = await ReadEventsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("missing_thread", Assert.Single(events).GetProperty("code").GetString()); + Assert.Null(reply.LastRequest); + } + + [Fact(Timeout = 60_000)] + public async Task MalformedBody_IsSingleRunErrorMalformedBody() + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartAsync(reply); + + using HttpResponseMessage response = await PostAsync(host.Client, "{not json"); + List events = await ReadEventsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("malformed_body", Assert.Single(events).GetProperty("code").GetString()); + Assert.Null(reply.LastRequest); + } + + [Fact(Timeout = 60_000)] + public async Task UnknownEntry_IsSingleRunErrorUnknownEntry() + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartAsync(reply); + + using HttpResponseMessage response = await PostAsync(host.Client, Run("whatever", [User("u1", "hi")]), "/agui/nobody"); + List events = await ReadEventsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("unknown_entry", Assert.Single(events).GetProperty("code").GetString()); + Assert.Null(reply.LastRequest); + } + + [Fact(Timeout = 60_000)] + public async Task RefusalsAsStatus_UnknownThread_Is404WithCodeInBody() + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartAsync(reply); + + using HttpResponseMessage response = await PostAsync(host.Client, Run("no-such", [User("u1", "hi")]), Route + "?refusals=status"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + JsonNode body = await ResponsesHost.ReadJsonAsync(response); + Assert.Equal("thread_not_found", body["error"]!["code"]!.GetValue()); + Assert.Null(reply.LastRequest); + } + + private sealed class RefuseEveryConversation : AgentHook + { + public override ValueTask BeforeConversationAsync(ConversationGate gate, CancellationToken cancellationToken) + { + gate.Refuse(); + return default; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiThreadOwnerTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiThreadOwnerTests.cs new file mode 100644 index 0000000..6afb408 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiThreadOwnerTests.cs @@ -0,0 +1,170 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using AgentCore.Application.Hooks; +using AgentCore.Application.Hooks.Gates; +using AgentCore.Application.Ports; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints; + +/// +/// The thread routes (file door, link, turns) name no entry. On a host with several entries, its +/// BeforeEntry hook picks the entry and the ownership gate runs as that entry, so the conversation's +/// owner opens them and anyone else sees nothing. +/// +public sealed class AgUiThreadOwnerTests +{ + 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 + other: + agent: greeter + """; + + private const string PngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + + private const string UserHeader = "x-user"; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static async Task SendAsync(HttpClient client, HttpMethod method, string path, string user, string? body = null) + { + using HttpRequestMessage request = new(method, path); + request.Headers.Add(UserHeader, user); + if (body is not null) + { + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + } + + return await client.SendAsync(request, Ct); + } + + private static string UploadRun(string thread) + { + JsonObject image = new() + { + ["type"] = "image", + ["source"] = new JsonObject { ["type"] = "data", ["value"] = PngBase64, ["mimeType"] = "image/png" }, + }; + return new JsonObject + { + ["threadId"] = thread, + ["runId"] = "r1", + ["messages"] = new JsonArray(new JsonObject + { + ["id"] = "u1", + ["role"] = "user", + ["content"] = new JsonArray(new JsonObject { ["type"] = "text", ["text"] = "look" }, image), + }), + ["tools"] = new JsonArray(), + ["context"] = new JsonArray(), + ["forwardedProps"] = new JsonObject(), + }.ToJsonString(); + } + + private static async Task<(ResponsesHost Host, OwnerOnly Gate, string Reference)> StartWithOwnedUploadAsync(FragmentingChatClient reply, string thread) + { + OwnerOnly gate = new("alice"); + ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + reply, + options => _ = options.UseBlobStores(new FakeBlobStoreAdapter()).UseHooks(gate), + map: app => app.MapAgUi()); + _ = await host.Services.GetRequiredService().CreateAsync(thread, Ct); + using (HttpResponseMessage run = await SendAsync(host.Client, HttpMethod.Post, "/agui/main", "alice", UploadRun(thread))) + { + Assert.Equal(HttpStatusCode.OK, run.StatusCode); + _ = await run.Content.ReadAsStringAsync(Ct); + } + + _ = await StoredRows.SettleAsync(host.Services, thread, 2); + using HttpResponseMessage turns = await SendAsync(host.Client, HttpMethod.Get, $"/agui/threads/{thread}/turns?limit=1", "alice"); + Assert.Equal(HttpStatusCode.OK, turns.StatusCode); + JsonNode page = JsonNode.Parse(await turns.Content.ReadAsStringAsync(Ct))!; + JsonNode user = page["messages"]!.AsArray().Single(m => m!["role"]!.GetValue() == "user")!; + string reference = new Uri(user["attachments"]![0]!["content"]![0]!["image"]!.GetValue(), UriKind.Absolute).AbsolutePath; + return (host, gate, reference); + } + + [Fact(Timeout = 60_000)] + public async Task Owner_OpensDoorLinkAndTurns_GatedAsTheHookPickedEntry() + { + using FragmentingChatClient reply = new("seen it"); + (ResponsesHost host, OwnerOnly gate, string reference) = await StartWithOwnedUploadAsync(reply, "t-owner"); + await using ResponsesHost owned = host; + gate.Entries.Clear(); + + using HttpClientHandler noFollow = new() { AllowAutoRedirect = false }; + using HttpClient direct = new(noFollow) { BaseAddress = host.Client.BaseAddress }; + using HttpResponseMessage door = await SendAsync(direct, HttpMethod.Get, reference, "alice"); + using HttpResponseMessage link = await SendAsync(host.Client, HttpMethod.Get, reference + "/link", "alice"); + using HttpResponseMessage turns = await SendAsync(host.Client, HttpMethod.Get, "/agui/threads/t-owner/turns?limit=1", "alice"); + + Assert.Equal(HttpStatusCode.Found, door.StatusCode); + Assert.Equal(HttpStatusCode.OK, link.StatusCode); + Assert.Equal(HttpStatusCode.OK, turns.StatusCode); + Assert.Equal(["main", "main", "main"], gate.Entries); + } + + [Fact(Timeout = 60_000)] + public async Task AnotherUser_IsRefusedDoorLinkAndTurns() + { + using FragmentingChatClient reply = new("seen it"); + (ResponsesHost host, _, string reference) = await StartWithOwnedUploadAsync(reply, "t-not-owner"); + await using ResponsesHost owned = host; + + using HttpResponseMessage door = await SendAsync(host.Client, HttpMethod.Get, reference, "bob"); + using HttpResponseMessage link = await SendAsync(host.Client, HttpMethod.Get, reference + "/link", "bob"); + using HttpResponseMessage turns = await SendAsync(host.Client, HttpMethod.Get, "/agui/threads/t-not-owner/turns", "bob"); + + Assert.Equal(HttpStatusCode.NotFound, door.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, link.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, turns.StatusCode); + } + + /// Picks the entry as a host would from its user, lets one user touch conversations, and notes the entry every gate ran as. + private sealed class OwnerOnly(string owner) : AgentHook + { + public ConcurrentQueue Entries { get; } = new(); + + public override ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken cancellationToken) + { + gate.Choose("main"); + return default; + } + + public override ValueTask BeforeConversationAsync(ConversationGate gate, CancellationToken cancellationToken) + { + Entries.Enqueue(gate.Entry); + if (!gate.Headers.TryGetValue(UserHeader, out string? user) || user != owner) + { + gate.Refuse(); + } + + return default; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiTurnHistoryTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiTurnHistoryTests.cs new file mode 100644 index 0000000..25a79bb --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiTurnHistoryTests.cs @@ -0,0 +1,830 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AgentCore.Application.Blobs; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Tools.Registry; +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 turns history route and the live regenerate dedupe: the history hands the +/// browser absolute file-door references (never a presigned URL), an interrupt turn reads +/// as requires-action, a regenerate reuses the stored user row's files without filing new +/// bytes, and a trimmed resume answers without re-announcing the call. +/// +public sealed class AgUiTurnHistoryTests +{ + 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 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 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 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, IEnumerable messages, JsonObject? forwarded = null, params JsonObject[] resume) + { + JsonObject body = new() + { + ["threadId"] = threadId, + ["runId"] = "r1", + ["messages"] = new JsonArray([.. messages.Select(m => (JsonNode)m.DeepClone())]), + ["tools"] = new JsonArray(), + ["context"] = new JsonArray(), + ["forwardedProps"] = forwarded?.DeepClone() ?? new JsonObject(), + }; + if (resume.Length > 0) + { + body["resume"] = new JsonArray([.. resume.Select(r => (JsonNode)r.DeepClone())]); + } + return body.ToJsonString(); + } + + private static JsonObject Anchor(string messageId, string? parentId, bool namesParent) + { + JsonObject agentcore = new() { ["message_id"] = messageId }; + if (namesParent) + { + agentcore["parent_id"] = parentId is null ? JsonValue.Create((string?)null) : parentId; + } + + return new JsonObject { ["agentcore"] = agentcore }; + } + + private static JsonObject Approved(string interruptId) + { + return new JsonObject + { + ["interruptId"] = interruptId, + ["status"] = "resolved", + ["payload"] = new JsonObject { ["approved"] = true }, + }; + } + + private static Task StartAsync(IChatClient reply, FakeBlobStoreAdapter blobs, Action? configure = null) + { + return ResponsesHost.StartAsync( + Yaml, + reply, + options => + { + _ = options.UseBlobStores(blobs); + 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 = 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 async Task TurnsAsync(HttpClient client, string thread) + { + using HttpResponseMessage response = await client.GetAsync($"/agui/threads/{thread}/turns", Ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return JsonNode.Parse(await response.Content.ReadAsStringAsync(Ct))!; + } + + [Fact(Timeout = 60_000)] + public async Task UploadTurn_ShowsAttachmentReference_NeverAPresignedUrl() + { + FakeBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-turns-upload"); + + 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); + JsonNode turns = await TurnsAsync(host.Client, thread); + + JsonNode user = turns["messages"]!.AsArray().Single(m => m!["role"]!.GetValue() == "user")!; + JsonNode attachment = user["attachments"]!.AsArray().Single()!; + Assert.Equal("image", attachment["type"]!.GetValue()); + string image = attachment["content"]![0]!["image"]!.GetValue()!; + Assert.StartsWith("http", image, StringComparison.Ordinal); + Assert.Contains($"/agui/threads/{thread}/files/", image, StringComparison.Ordinal); + Assert.DoesNotContain("blobs.test", image, StringComparison.Ordinal); + Assert.DoesNotContain("ttl=", image, StringComparison.Ordinal); + } + + [Fact(Timeout = 60_000)] + public async Task UploadTurn_DoorlessStore_ShowsReferenceThatStreams() + { + NoDoorBlobStoreAdapter blobs = new(); + using FragmentingChatClient reply = new("seen it"); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + reply, + options => _ = options.UseBlobStores(blobs), + map: app => app.MapAgUi()); + string thread = await OpenThreadAsync(host, "t-turns-nodoor"); + + 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); + JsonNode turns = await TurnsAsync(host.Client, thread); + + JsonNode user = turns["messages"]!.AsArray().Single(m => m!["role"]!.GetValue() == "user")!; + string image = user["attachments"]![0]!["content"]![0]!["image"]!.GetValue()!; + Assert.StartsWith("http", image, StringComparison.Ordinal); + Assert.DoesNotContain("blobs.test", image, StringComparison.Ordinal); + + using HttpResponseMessage door = await host.Client.GetAsync( + new Uri(image, UriKind.Absolute).AbsolutePath, Ct); + Assert.Equal(HttpStatusCode.OK, door.StatusCode); + Assert.Equal(Png, await door.Content.ReadAsByteArrayAsync(Ct)); + } + + [Fact(Timeout = 60_000)] + public async Task AgentFile_ShowsFileReference_NeverAPresignedUrl() + { + string root = Path.Combine(Path.GetTempPath(), "agentcore-turns-" + Guid.NewGuid().ToString("N")); + string conversation = "conversation-turns-file"; + _ = Directory.CreateDirectory(Path.Combine(root, conversation)); + try + { + File.WriteAllText(Path.Combine(root, conversation, "report.csv"), "month,sales\njan,10\n"); + FakeBlobStoreAdapter blobs = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + PublishYaml, + new PublishingChatClient(), + configure: options => options.UseWorkspace(root).UseBlobStores(blobs), + map: app => app.MapAgUi()); + _ = await host.Services.GetRequiredService().CreateAsync(conversation, Ct); + + using (HttpResponseMessage response = await PostAsync(host.Client, Run(conversation, [UserText("u1", "send me the report")]))) + { + _ = await ReadEventsAsync(response); + } + + _ = await StoredRows.SettleAsync(host.Services, conversation, 2); + JsonNode turns = await TurnsAsync(host.Client, conversation); + + JsonNode assistant = turns["messages"]!.AsArray().Single(m => m!["role"]!.GetValue() == "assistant")!; + JsonNode file = assistant["content"]!.AsArray().Single(p => p!["type"]!.GetValue() == "file")!; + string data = file["data"]!.GetValue()!; + Assert.StartsWith("http", data, StringComparison.Ordinal); + Assert.EndsWith($"/agui/threads/{conversation}/files/report.csv", data, StringComparison.Ordinal); + Assert.DoesNotContain("blobs.test", data, StringComparison.Ordinal); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact(Timeout = 60_000)] + public async Task InterruptTurn_ShowsRequiresAction_WithOpenInterrupts() + { + ToolRuns runs = new(); + RecordingChatClient model = new(new SingleCallChatClient()); + FakeBlobStoreAdapter blobs = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + OneToolYaml, + model, + configure: options => options.UseBlobStores(blobs).AddToolSource(_ => new OneGatedSource(runs)), + map: app => app.MapAgUi()); + string thread = await OpenThreadAsync(host, "t-turns-interrupt"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserText("u1", "send it")]))) + { + List events = await ReadEventsAsync(first); + Assert.Equal("interrupt", events[^1].GetProperty("outcome").GetProperty("type").GetString()); + } + + _ = await StoredRows.SettleAsync(host.Services, thread, 2); + JsonNode turns = await TurnsAsync(host.Client, thread); + + JsonNode assistant = turns["messages"]!.AsArray().Single(m => m!["role"]!.GetValue() == "assistant")!; + Assert.Equal("requires-action", assistant["status"]!["type"]!.GetValue()); + Assert.Equal("interrupt", assistant["status"]!["reason"]!.GetValue()); + JsonArray interrupts = assistant["metadata"]!["custom"]!["agui"]!["interrupts"]!.AsArray(); + (string id, string callId) = Assert.Single(interrupts.Select(i => (i!["id"]!.GetValue()!, i!["toolCallId"]!.GetValue()!))); + JsonNode call = assistant["content"]!.AsArray().Single(p => p!["type"]!.GetValue() == "tool-call")!; + Assert.Equal(callId, call["toolCallId"]!.GetValue()); + Assert.Equal(id, call["approval"]!["id"]!.GetValue()); + Assert.Empty(runs.All); + } + + [Fact(Timeout = 60_000)] + public async Task LiveRegenerate_ReusesStoredFile_BlobCountUnchanged_ModelGetsStoredBytes() + { + FakeBlobStoreAdapter blobs = new(); + using RecordingChatClient reply = new(new FragmentingChatClient("seen it")); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-regen-keep"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("what is this?"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(first); + } + + List rows = await StoredRows.SettleAsync(host.Services, thread, 2); + string userId = rows[0].MessageId; + FileContent stored = Assert.IsType(rows[0].Content.Contents.OfType().Single()); + int count = blobs.Store.Blobs.Count; + int calls = reply.Calls; + + using HttpResponseMessage response = await PostAsync( + host.Client, + Run(thread, [UserParts(userId, TextPart("what is this?"), ImagePart(PngBase64))], Anchor(userId, null, true))); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.Equal(count, blobs.Store.Blobs.Count); + Assert.Equal(calls + 1, reply.Calls); + DataContent data = Assert.IsType( + reply.Requests[^1].SelectMany(m => m.Contents).OfType().Single()); + Assert.Equal(Png, data.Data.ToArray()); + List after = await StoredRows.SettleAsync(host.Services, thread, 2); + Assert.Equal(stored.Name, Assert.IsType(after[0].Content.Contents.OfType().Single()).Name); + } + + [Fact(Timeout = 60_000)] + public async Task EditReplace_FilesOneNewBlob() + { + FakeBlobStoreAdapter blobs = new(); + using RecordingChatClient reply = new(new FragmentingChatClient("seen it")); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-regen-replace"); + + string firstBlock; + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserText("u0", "hello")]))) + { + firstBlock = (await ReadEventsAsync(first)) + .First(e => e.GetProperty("type").GetString() == "TEXT_MESSAGE_START") + .GetProperty("messageId").GetString()!; + } + + using (HttpResponseMessage second = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("what is this?"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(second); + } + + _ = await StoredRows.SettleAsync(host.Services, thread, 4); + int count = blobs.Store.Blobs.Count; + + // An edit hangs the new words off the previous turn's last block: turn 1's rows go, + // the replaced blob orphans until the conversation is deleted, and the new bytes file once. + using HttpResponseMessage response = await PostAsync( + host.Client, + Run(thread, [UserParts("u2", TextPart("and this?"), ImagePart(GifBase64))], Anchor("u2", firstBlock, true))); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.Equal(count + 1, blobs.Store.Blobs.Count); + List seen = [.. reply.Requests.SelectMany(m => m.SelectMany(n => n.Contents)).OfType()]; + Assert.Contains(seen, data => data.Data.ToArray().SequenceEqual(Gif)); + Assert.DoesNotContain(reply.Requests[^1].SelectMany(m => m.Contents).OfType(), data => data.Data.ToArray().SequenceEqual(Png)); + } + [Fact(Timeout = 60_000)] + public async Task RegenerateWithMoreFilesThanStoredRow_FilesNormally() + { + FakeBlobStoreAdapter blobs = new(); + using RecordingChatClient reply = new(new FragmentingChatClient("seen it")); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-regen-mismatch"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("what is this?"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(first); + } + + List rows = await StoredRows.SettleAsync(host.Services, thread, 2); + string storedId = rows[0].MessageId; + int count = blobs.Store.Blobs.Count; + + // The anchor still names the stored row, but the body carries two files where the row + // keeps one: the counts differ, so the body's bytes file like a new message. + using HttpResponseMessage response = await PostAsync( + host.Client, + Run( + thread, + [UserParts("u9", TextPart("what is this?"), ImagePart(PngBase64), ImagePart(GifBase64))], + Anchor(storedId, null, false))); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.Equal(count + 2, blobs.Store.Blobs.Count); + List seen = [.. reply.Requests[^1].SelectMany(m => m.Contents).OfType().Select(d => d.Data.ToArray())]; + Assert.Contains(seen, bytes => bytes.SequenceEqual(Png)); + Assert.Contains(seen, bytes => bytes.SequenceEqual(Gif)); + } + + [Fact(Timeout = 60_000)] + public async Task RegenerateWithRawUrl_RefusesUrlNotAllowed() + { + FakeBlobStoreAdapter blobs = new(); + using RecordingChatClient reply = new(new FragmentingChatClient("seen it")); + await using ResponsesHost host = await StartAsync(reply, blobs); + string thread = await OpenThreadAsync(host, "t-regen-raw-url"); + + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserParts("u1", TextPart("what is this?"), ImagePart(PngBase64))]))) + { + _ = await ReadEventsAsync(first); + } + + List rows = await StoredRows.SettleAsync(host.Services, thread, 2); + string userId = rows[0].MessageId; + int calls = reply.Calls; + + // After resolve the only UriContent left is a non-reference link: D25 must not stand + // stored bytes in for it — the run refuses before the model runs. + using HttpResponseMessage response = await PostAsync( + host.Client, + Run(thread, [UserParts(userId, TextPart("what is this?"), UrlImagePart("https://example.com/evil.png"))], Anchor(userId, null, true))); + List events = await ReadEventsAsync(response); + + JsonElement error = Assert.Single(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.Equal("attachment_url_not_allowed", error.GetProperty("code").GetString()); + Assert.Equal(calls, reply.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task TrimmedResume_DoesNotReannounceTheCall_AnswersMatchTrimOff() + { + ToolRuns runs = new(); + RecordingChatClient model = new(new SingleCallChatClient()); + FakeBlobStoreAdapter blobs = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + OneToolYaml, + model, + configure: options => options.UseBlobStores(blobs).AddToolSource(_ => new OneGatedSource(runs)), + map: app => app.MapAgUi()); + + List trimmed = await ResumeAsync(host, "t-trim-resume", trimmed: true); + List full = await ResumeAsync(host, "t-trim-full", trimmed: false); + + // The trimmed body carries only the asking assistant message, never the full thread. + // Either way the call is not re-announced, its result lands once, the tool runs with the + // stored arguments, and the outcome matches. + foreach (List answered in new[] { trimmed, full }) + { + Assert.DoesNotContain(answered, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.DoesNotContain(answered, e => e.GetProperty("type").GetString() == "TOOL_CALL_START"); + Assert.Equal("success", answered[^1].GetProperty("outcome").GetProperty("type").GetString()); + } + + Assert.Equal( + full.Where(e => e.GetProperty("type").GetString() == "TOOL_CALL_RESULT").Select(e => e.GetProperty("toolCallId").GetString()), + trimmed.Where(e => e.GetProperty("type").GetString() == "TOOL_CALL_RESULT").Select(e => e.GetProperty("toolCallId").GetString())); + Assert.Equal([("send_email", "a@b.com"), ("send_email", "a@b.com")], runs.All); + } + + [Fact(Timeout = 60_000)] + public async Task ResumedTurn_ShowsTheGatedCallOnce_WithItsResult() + { + ToolRuns runs = new(); + RecordingChatClient model = new(new SingleCallChatClient()); + FakeBlobStoreAdapter blobs = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + OneToolYaml, + model, + configure: options => options.UseBlobStores(blobs).AddToolSource(_ => new OneGatedSource(runs)), + map: app => app.MapAgUi()); + _ = await ResumeAsync(host, "t-turns-resumed", trimmed: false); + _ = await StoredRows.SettleAsync(host.Services, "t-turns-resumed", 4); + + JsonNode turns = await TurnsAsync(host.Client, "t-turns-resumed"); + + JsonNode call = Assert.Single( + turns["messages"]!.AsArray().SelectMany(m => m!["content"]!.AsArray()), + p => p!["type"]!.GetValue() == "tool-call")!; + Assert.NotNull(call["result"]); + Assert.Null(call["approval"]); + } + + private static async Task> ResumeAsync(ResponsesHost host, string thread, bool trimmed) + { + _ = await OpenThreadAsync(host, thread); + string callId; + string interruptId; + using (HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [UserText("u1", "send it")]))) + { + List events = await ReadEventsAsync(first); + interruptId = events[^1].GetProperty("outcome").GetProperty("interrupts")[0].GetProperty("id").GetString()!; + callId = events[^1].GetProperty("outcome").GetProperty("interrupts")[0].GetProperty("toolCallId").GetString()!; + } + + JsonObject[] messages = trimmed + ? [AssistantCall("a1", callId, "send_email", """{"to":"a@b.com"}""")] + : [UserText("u1", "send it"), AssistantCall("a1", callId, "send_email", """{"to":"a@b.com"}""")]; + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, messages, null, Approved(interruptId))); + return await ReadEventsAsync(response); + } + + private const string PublishYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: publish, kind: builtin, uses: file.publish } + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller", tools: [ publish ] } + 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 GifBase64 = "R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + + private static readonly byte[] Gif = Convert.FromBase64String(GifBase64); + + private sealed class NoDoorBlobStoreAdapter(string kind = "fake") : IBlobStoreAdapter + { + public string Kind => kind; + + public NoDoorBlobStore Store { get; } = new(); + + public ValueTask OpenAsync( + BlobProviderConfiguration entry, + ISecretResolverPort? secrets, + CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(Store); + } + } + + private sealed class NoDoorBlobStore : IBlobStore + { + private 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); + lock (_blobs) + { + _blobs[(write.OwnerId, write.Name)] = (write.MediaType, copy.ToArray()); + } + + 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)) + : 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(null); + } + + 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; + } + } + + /// Calls the publish tool it is offered, once, on the report, then answers in words. + private sealed class PublishingChatClient : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + bool alreadyPublished = messages.Any(message => message.Contents.OfType().Any()); + + if (!alreadyPublished && options?.Tools?.OfType().FirstOrDefault(tool => tool.Name == "publish") is { } publish) + { + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new FunctionCallContent( + "conversation_1", + publish.Name, + new Dictionary(StringComparer.Ordinal) + { + ["path"] = "report.csv", + ["title"] = "Quarterly report", + })]); + yield break; + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, "here is the report."); + } + + 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() + { + } + } + + private sealed class ToolRuns + { + private readonly Lock _gate = new(); + private readonly List<(string Name, string To)> _all = []; + + public IReadOnlyList<(string Name, string To)> All + { + get + { + lock (_gate) + { + return [.. _all]; + } + } + } + + public void Add(string name, string to) + { + lock (_gate) + { + _all.Add((name, to)); + } + } + } + + 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.")))]); + } + } + + /// 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() + { + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiTurnPagingTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiTurnPagingTests.cs new file mode 100644 index 0000000..0133c77 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiTurnPagingTests.cs @@ -0,0 +1,196 @@ +using System.Net; +using System.Text.Json.Nodes; +using AgentCore.Application.Ports; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints; + +/// +/// GET /agui/threads/{threadId}/turns pages: the newest limit turns before the before cursor, +/// a nextCursor for the next older page, and a 400 for a cursor the server never wrote. +/// +public sealed class AgUiTurnPagingTests +{ + 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 } + entries: + main: + agent: greeter + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static async Task StartWithTurnsAsync(IChatClient reply, string thread, int turns, Action? map = null) + { + ResponsesHost host = await ResponsesHost.StartAsync(Yaml, reply, map: map ?? (app => app.MapAgUi())); + _ = await host.Services.GetRequiredService().CreateAsync(thread, Ct); + IConversationStore store = host.Services.GetRequiredService(); + for (int k = 0; k < turns; k++) + { + _ = await store.AppendAsync( + thread, + [ + new ConversationMessageDraft(k, new ChatMessage(ChatRole.User, $"q{k}"), $"u{k}"), + new ConversationMessageDraft(k, new ChatMessage(ChatRole.Assistant, $"a{k}"), $"a{k}"), + ], + null, + Ct); + } + + return host; + } + + private static async Task PageAsync(HttpClient client, string thread, string query) + { + using HttpResponseMessage response = await client.GetAsync($"/agui/threads/{thread}/turns{query}", Ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return JsonNode.Parse(await response.Content.ReadAsStringAsync(Ct))!; + } + + private static string[] TurnIds(JsonNode page) + => [.. page["messages"]!.AsArray().Where(m => m!["role"]!.GetValue() == "assistant").Select(m => m!["id"]!.GetValue())]; + + [Fact(Timeout = 60_000)] + public async Task Pages_WalkBackThroughNextCursor_UntilNoneIsLeft() + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartWithTurnsAsync(reply, "t-pages", 5); + + JsonNode first = await PageAsync(host.Client, "t-pages", "?limit=2"); + Assert.Equal([AgUiTurnIds.TurnMessageId(3), AgUiTurnIds.TurnMessageId(4)], TurnIds(first)); + Assert.Equal("3", first["nextCursor"]!.GetValue()); + + JsonNode second = await PageAsync(host.Client, "t-pages", "?limit=2&before=3"); + Assert.Equal([AgUiTurnIds.TurnMessageId(1), AgUiTurnIds.TurnMessageId(2)], TurnIds(second)); + Assert.Equal("1", second["nextCursor"]!.GetValue()); + + JsonNode last = await PageAsync(host.Client, "t-pages", "?limit=2&before=1"); + Assert.Equal([AgUiTurnIds.TurnMessageId(0)], TurnIds(last)); + Assert.Null(last["nextCursor"]); + } + + [Fact(Timeout = 60_000)] + public async Task NoLimit_AnswersThirtyTurns() + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartWithTurnsAsync(reply, "t-default", 31); + + JsonNode page = await PageAsync(host.Client, "t-default", string.Empty); + + Assert.Equal(30, TurnIds(page).Length); + Assert.Equal("1", page["nextCursor"]!.GetValue()); + } + + [Theory(Timeout = 60_000)] + [InlineData("?limit=0", 1)] + [InlineData("?limit=1000", 100)] + public async Task Limit_IsClampedToOneThroughHundred(string query, int expected) + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartWithTurnsAsync(reply, "t-clamp", 101); + + JsonNode page = await PageAsync(host.Client, "t-clamp", query); + + Assert.Equal(expected, TurnIds(page).Length); + } + + [Theory(Timeout = 60_000)] + [InlineData("abc")] + [InlineData("-1")] + [InlineData("+3")] + public async Task UnreadableCursor_Is400_SaysToSendNextCursor(string before) + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartWithTurnsAsync(reply, "t-bad-cursor", 1); + + using HttpResponseMessage response = await host.Client.GetAsync($"/agui/threads/t-bad-cursor/turns?before={Uri.EscapeDataString(before)}", Ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("nextCursor", await response.Content.ReadAsStringAsync(Ct), StringComparison.Ordinal); + } + + [Fact(Timeout = 60_000)] + public async Task MapAgUiTwice_ThreadRoutesStillAnswer() + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartWithTurnsAsync(reply, "t-twice", 1, app => + { + _ = app.MapAgUi(); + _ = app.MapAgUi("/custom/{entry}"); + }); + + JsonNode page = await PageAsync(host.Client, "t-twice", string.Empty); + + Assert.Equal([AgUiTurnIds.TurnMessageId(0)], TurnIds(page)); + } + + [Theory(Timeout = 60_000)] + [InlineData(1)] + [InlineData(2)] + public async Task ApprovalPairStraddlingAPageEdge_ShowsTheCallOnce_WithItsResult(int askingTurn) + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartWithTurnsAsync(reply, "t-straddle", 0); + IConversationStore store = host.Services.GetRequiredService(); + FunctionCallContent call = new("c1", "send_email", new Dictionary { ["to"] = "a@b.c" }); + for (int k = 0; k < 4; k++) + { + List rows = [new(k, new ChatMessage(ChatRole.User, $"q{k}"), $"u{k}")]; + if (k == askingTurn) + { + rows.Add(new(k, new ChatMessage(ChatRole.Assistant, [new ToolApprovalRequestContent("req1", call)]), $"a{k}")); + } + else if (k == askingTurn + 1) + { + rows.Add(new(k, new ChatMessage(ChatRole.Assistant, [call]), $"c{k}")); + rows.Add(new(k, new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "sent")]), $"t{k}")); + rows.Add(new(k, new ChatMessage(ChatRole.Assistant, "done"), $"a{k}")); + } + else + { + rows.Add(new(k, new ChatMessage(ChatRole.Assistant, $"a{k}"), $"a{k}")); + } + + _ = await store.AppendAsync("t-straddle", rows, null, Ct); + } + + List calls = []; + string query = "?limit=1"; + while (true) + { + JsonNode page = await PageAsync(host.Client, "t-straddle", query); + calls.AddRange(page["messages"]!.AsArray() + .SelectMany(m => m!["content"] is JsonArray parts ? parts : []) + .Where(p => p!["type"]?.GetValue() == "tool-call")!); + if (page["nextCursor"]?.GetValue() is not { } cursor) + { + break; + } + + query = $"?limit=1&before={cursor}"; + } + + JsonNode only = Assert.Single(calls); + Assert.Equal("c1", only["toolCallId"]!.GetValue()); + Assert.Equal("sent", only["result"]!.GetValue()); + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiTurnTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiTurnTests.cs new file mode 100644 index 0000000..3936b07 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/AgUiTurnTests.cs @@ -0,0 +1,497 @@ +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.AspNetCore.DependencyInjection; +using AgentCore.Application.Runtime.Session; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Endpoints; +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 turn pipeline: words run through the same turn loop Responses runs, + /// and the reply streams back as AG-UI events with turn-k.n ids. + /// + public sealed class AgUiTurnTests + { + 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 } + entries: + main: + agent: greeter + """; + + private const string Route = "/agui/main"; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static string Run(string threadId, IEnumerable messages, JsonObject? forwarded = null) + { + JsonObject body = new() + { + ["threadId"] = threadId, + ["runId"] = "r1", + ["messages"] = new JsonArray([.. messages.Select(m => (JsonNode)m.DeepClone())]), + ["tools"] = new JsonArray(), + ["context"] = new JsonArray(), + ["forwardedProps"] = forwarded?.DeepClone() ?? new JsonObject(), + }; + return body.ToJsonString(); + } + + private static JsonObject User(string id, string text) => new() { ["id"] = id, ["role"] = "user", ["content"] = text }; + + private static JsonObject Anchor(string messageId, string? parentId, bool namesParent) + { + JsonObject agentcore = new() { ["message_id"] = messageId }; + if (namesParent) + { + agentcore["parent_id"] = parentId is null ? JsonValue.Create((string?)null) : parentId; + } + + return new JsonObject { ["agentcore"] = agentcore }; + } + + private static Task StartAsync(IChatClient reply, Action? configure = null) + { + return ResponsesHost.StartAsync( + Yaml, + reply, + 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 = 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 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 async Task> RowsAsync(ResponsesHost host, string threadId, int atLeast) + { + return await StoredRows.SettleAsync(host.Services, threadId, atLeast); + } + + [Fact(Timeout = 60_000)] + public async Task TextTurn_StreamsRunStartedSnapshotsTextBlocksCommittedAndFinished_IdsTurnKn() + { + TwoStepChatClient model = new(["first half", TwoStepChatClient.Call], ["second half"]); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoStepChatClient.Yaml, model, TwoStepChatClient.BindTool, map: app => app.MapAgUi()); + string thread = await OpenThreadAsync(host, "t-text"); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [User("u1", "explain")])); + List events = await ReadEventsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType); + Assert.Equal("no-cache", string.Join(",", response.Headers.CacheControl?.ToString() ?? string.Empty).Replace(" ", string.Empty)); + + JsonElement started = OfType(events, "RUN_STARTED").Single(); + Assert.Equal(thread, started.GetProperty("threadId").GetString()); + + List snapshots = OfType(events, "STATE_SNAPSHOT"); + Assert.Equal(2, snapshots.Count); + JsonElement before = snapshots[0]; + Assert.Equal(events.IndexOf(started) + 1, events.IndexOf(before)); + Assert.Equal("before", before.GetProperty("snapshot").GetProperty("phase").GetString()); + string stage = before.GetProperty("snapshot").GetProperty("stage").GetString()!; + Assert.Equal(stage, response.Headers.GetValues(ResponsesEndpointRouteBuilderExtensions.StageHeaderName).Single()); + JsonElement after = snapshots[1]; + Assert.Equal(events.IndexOf(OfType(events, "RUN_FINISHED").Single()) - 1, events.IndexOf(after)); + Assert.Equal("after", after.GetProperty("snapshot").GetProperty("phase").GetString()); + + List starts = OfType(events, "TEXT_MESSAGE_START"); + Assert.Equal(2, starts.Count); + Assert.Equal("turn-0.0", starts[0].GetProperty("messageId").GetString()); + Assert.Equal("turn-0.1", starts[1].GetProperty("messageId").GetString()); + + JsonElement toolStart = OfType(events, "TOOL_CALL_START").Single(); + Assert.Equal("turn-0.0", toolStart.GetProperty("parentMessageId").GetString()); + Assert.True( + events.IndexOf(starts[0]) < events.IndexOf(toolStart) + && events.IndexOf(toolStart) < events.IndexOf(starts[1])); + + JsonElement committed = OfType(events, "CUSTOM").Single(e => e.GetProperty("name").GetString() == "agentcore_message_committed"); + Assert.NotNull(committed.GetProperty("value").GetProperty("user_message_id").GetString()); + Assert.NotNull(committed.GetProperty("value").GetProperty("reply_message_id").GetString()); + + JsonElement finished = OfType(events, "RUN_FINISHED").Single(); + Assert.Equal(events.Count - 1, events.IndexOf(finished)); + Assert.True( + !finished.TryGetProperty("outcome", out JsonElement outcome) + || outcome.ValueKind == JsonValueKind.Null + || outcome.GetProperty("type").GetString() == "success"); + + foreach (JsonElement evt in events) + { + Assert.True( + !evt.TryGetProperty("rawEvent", out JsonElement raw) || raw.ValueKind == JsonValueKind.Null, + evt.GetProperty("type").GetString()); + } + } + + [Fact(Timeout = 60_000)] + public async Task SecondTurn_ResumesSameThread_ModelSeesBothTurns_NoDuplication() + { + using FragmentingChatClient reply = new("first answer", "second answer"); + await using ResponsesHost host = await StartAsync(reply); + string thread = await OpenThreadAsync(host, "t-two"); + + using HttpResponseMessage first = await PostAsync(host.Client, Run(thread, [User("u1", "first question")])); + _ = await ReadEventsAsync(first); + using HttpResponseMessage second = await PostAsync( + host.Client, Run(thread, [User("u1", "first question"), User("u2", "second question")])); + _ = await ReadEventsAsync(second); + + Assert.NotNull(reply.LastRequest); + List userTexts = [.. reply.LastRequest!.Where(m => m.Role == ChatRole.User).Select(m => m.Text)]; + Assert.Contains("first question", userTexts); + Assert.Contains("second question", userTexts); + Assert.Equal(2, userTexts.Count(text => text == "first question" || text == "second question")); + + List rows = await RowsAsync(host, thread, 4); + Assert.Equal(4, rows.Count); + } + + // The four client redo actions ride one anchor rule: the turn hangs off the parent, + // everything after it is withdrawn, and the line runs once. An edit names a new + // message; regenerate, retry, and continue resend the same user id with the parent + // before the redone turn, so the withdrawn span holds that user row. + [Theory(Timeout = 60_000)] + [InlineData("edit", new[] { "first question" }, "u2", null, "rewritten question", new[] { "first question", "first answer" })] + [InlineData("regenerate", new[] { "first question" }, "u1", null, "first question", new[] { "first answer" })] + [InlineData("retry", new[] { "first question", "second question" }, "u2", "turn-0", "second question", new[] { "second answer" })] + [InlineData("continue", new[] { "first question", "second question" }, "u2", "turn-0.0", "second question", new[] { "second answer" })] + public async Task Anchor_TruncatesViaMessageIdAndParent_ModelSeesHistoryToParentPlusLineOnce( + string _case, string[] seed, string messageId, string? parentId, string line, string[] withdrawn) + { + using FragmentingChatClient reply = new("first answer", "second answer", "third answer"); + await using ResponsesHost host = await StartAsync(reply); + string thread = await OpenThreadAsync(host, "t-anchor-" + _case); + + for (int index = 0; index < seed.Length; index++) + { + using HttpResponseMessage seeded = await PostAsync(host.Client, Run(thread, [User("u" + (index + 1), seed[index])])); + _ = await ReadEventsAsync(seeded); + } + + JsonObject anchor = Anchor(messageId, parentId, namesParent: true); + using HttpResponseMessage redone = await PostAsync( + host.Client, Run(thread, [User(messageId, line)], anchor)); + List events = await ReadEventsAsync(redone); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.NotNull(reply.LastRequest); + List userTexts = [.. reply.LastRequest!.Where(m => m.Role == ChatRole.User).Select(m => m.Text)]; + Assert.Equal(line, userTexts[^1]); + Assert.Single(userTexts, line); + foreach (string gone in withdrawn) + { + Assert.DoesNotContain(reply.LastRequest!, m => m.Text.Contains(gone, StringComparison.Ordinal)); + } + } + + [Fact(Timeout = 60_000)] + public async Task LiveAndReloadIdsMatch_TurnKn() + { + TwoStepChatClient model = new(["first half", TwoStepChatClient.Call], ["second half"]); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoStepChatClient.Yaml, model, TwoStepChatClient.BindTool, map: app => app.MapAgUi()); + string thread = await OpenThreadAsync(host, "t-reload"); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [User("u1", "explain")])); + List events = await ReadEventsAsync(response); + + List liveBlocks = OfType(events, "TEXT_MESSAGE_START") + .Select(e => e.GetProperty("messageId").GetString()!).ToList(); + Assert.Equal(["turn-0.0", "turn-0.1"], liveBlocks); + + List rows = await RowsAsync(host, thread, 4); + List storedTexts = [.. rows.Where(r => r.Content.Role == ChatRole.Assistant && r.Content.Text.Length > 0).Select(r => r.Content.Text)]; + Assert.Equal(["first half", "second half"], storedTexts); + + JsonElement committed = OfType(events, "CUSTOM").Single(e => e.GetProperty("name").GetString() == "agentcore_message_committed"); + Assert.Equal("u1", committed.GetProperty("value").GetProperty("user_message_id").GetString()); + Assert.Equal(rows[^1].MessageId, committed.GetProperty("value").GetProperty("reply_message_id").GetString()); + Assert.Equal(rows[0].MessageId, committed.GetProperty("value").GetProperty("user_message_id").GetString()); + } + [Fact(Timeout = 60_000)] + public async Task Carry_RegenerateReusesResult_EditReRuns() + { + int calls = 0; + CarryChatClient model = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoStepChatClient.Yaml, + model, + options => _ = options.Bind(TwoStepChatClient.ToolBinding, (_, _) => + { + _ = Interlocked.Increment(ref calls); + return ValueTask.FromResult("42"); + }), + map: app => app.MapAgUi()); + string regenerateThread = await OpenThreadAsync(host, "t-carry-regen"); + string editThread = await OpenThreadAsync(host, "t-carry-edit"); + + using HttpResponseMessage firstRegen = await PostAsync(host.Client, Run(regenerateThread, [User("u1", "look up revenue")])); + _ = await ReadEventsAsync(firstRegen); + Assert.Equal(1, Volatile.Read(ref calls)); + + // The same user id with the parent before the turn: the withdrawn turn held + // that user row, so its call and result ride along and the tool never re-runs. + using HttpResponseMessage regen = await PostAsync( + host.Client, Run(regenerateThread, [User("u1", "look up revenue")], Anchor("u1", null, true))); + List regenEvents = await ReadEventsAsync(regen); + Assert.DoesNotContain(regenEvents, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + Assert.Equal("sorted.", ClientText(regenEvents)); + Assert.Equal(1, Volatile.Read(ref calls)); + + using HttpResponseMessage firstEdit = await PostAsync(host.Client, Run(editThread, [User("u1", "look up revenue")])); + _ = await ReadEventsAsync(firstEdit); + Assert.Equal(2, Volatile.Read(ref calls)); + + // A new user id names a message the withdrawn span never held, so nothing + // rides along and the tool runs again for the new question. + using HttpResponseMessage edited = await PostAsync( + host.Client, Run(editThread, [User("u2", "look up costs")], Anchor("u2", "u1", true))); + _ = await ReadEventsAsync(edited); + Assert.Equal(3, Volatile.Read(ref calls)); + } + + [Fact(Timeout = 60_000)] + public async Task ModelErrorMidTurn_ClientTextEqualsStoredText_FinishIsSuccess() + { + FailAfterPartialChatClient model = new(); + await using ResponsesHost host = await StartAsync(model); + string thread = await OpenThreadAsync(host, "t-fault"); + + using HttpResponseMessage response = await PostAsync(host.Client, Run(thread, [User("u1", "go")])); + List events = await ReadEventsAsync(response); + + Assert.DoesNotContain(events, e => e.GetProperty("type").GetString() == "RUN_ERROR"); + JsonElement finished = OfType(events, "RUN_FINISHED").Single(); + Assert.True( + !finished.TryGetProperty("outcome", out JsonElement outcome) + || outcome.ValueKind == JsonValueKind.Null + || outcome.GetProperty("type").GetString() == "success"); + + string clientText = ClientText(events); + Assert.Contains("partial", clientText, StringComparison.Ordinal); + + List rows = await RowsAsync(host, thread, 3); + string storedText = string.Concat( + rows.Where(r => r.Content.Role == ChatRole.Assistant).Select(r => r.Content.Text)); + Assert.Equal(storedText, clientText); + } + + [Fact(Timeout = 120_000)] + public async Task BusyThread_SecondRunWaits15sThenTurnConflict() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + StallOnCueChatClient model = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + model, + options => + { + options.TimeProvider = time; + }, + map: app => app.MapAgUi()); + string thread = await OpenThreadAsync(host, "t-busy"); + + using HttpResponseMessage running = await PostAsync(host.Client, Run(thread, [User("u1", "stall now")])); + await ReadUntilAsync(running, StallOnCueChatClient.Piece); + + Task sending = PostAsync(host.Client, Run(thread, [User("u2", "second")])); + await Task.WhenAny(time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.WaitLimit, 1), sending); + time.Advance(ConversationBusyMark.WaitLimit); + using HttpResponseMessage refused = await sending; + + Assert.Equal(HttpStatusCode.OK, refused.StatusCode); + JsonElement only = Assert.Single(await ReadEventsAsync(refused)); + Assert.Equal("RUN_ERROR", only.GetProperty("type").GetString()); + Assert.Equal(ResponsesTurnConflict.Code, only.GetProperty("code").GetString()); + Assert.Equal(ResponsesTurnConflict.BusyMessage, only.GetProperty("message").GetString()); + } + + private static async Task ReadUntilAsync(HttpResponseMessage response, string text) + { + Stream body = await response.Content.ReadAsStreamAsync(Ct); + using StreamReader reader = new(body, Encoding.UTF8, leaveOpen: true); + while (await reader.ReadLineAsync(Ct) is { } line && !line.Contains(text, StringComparison.Ordinal)) + { + } + } + + [Fact(Timeout = 60_000)] + public async Task NoUserMessage_SingleRunError() + { + using FragmentingChatClient reply = new("unused"); + await using ResponsesHost host = await StartAsync(reply); + string thread = await OpenThreadAsync(host, "t-empty"); + + using HttpResponseMessage empty = await PostAsync(host.Client, Run(thread, [])); + JsonElement onlyEmpty = Assert.Single(await ReadEventsAsync(empty)); + Assert.Equal("RUN_ERROR", onlyEmpty.GetProperty("type").GetString()); + Assert.Equal("no_user_message", onlyEmpty.GetProperty("code").GetString()); + + JsonObject assistant = new() { ["id"] = "a1", ["role"] = "assistant", ["content"] = "old answer" }; + using HttpResponseMessage textless = await PostAsync(host.Client, Run(thread, [assistant])); + JsonElement onlyTextless = Assert.Single(await ReadEventsAsync(textless)); + Assert.Equal("RUN_ERROR", onlyTextless.GetProperty("type").GetString()); + Assert.Equal("no_user_message", onlyTextless.GetProperty("code").GetString()); + + Assert.Null(reply.LastRequest); + } + + /// 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, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + bool alreadyCalled = messages.Any(message => message.Contents.OfType().Any()); + + if (!alreadyCalled && 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() + { + } + } + + private sealed class FailAfterPartialChatClient : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "partial"); + await Task.Yield(); + throw new InvalidOperationException("the model endpoint failed mid-stream."); + } + + 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/TurnStreamFilesTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/TurnStreamFilesTests.cs index 28e740f..6499455 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/TurnStreamFilesTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/TurnStreamFilesTests.cs @@ -50,6 +50,26 @@ public void Resolve_NotedFileTheCaptureKept_YieldsOnePartWithTheFacts() Assert.Equal(48213, payload.Length); } + [Fact] + public void Resolve_WithoutTheStore_ListsKeptFilesWithPlainNames_LatestFactsWin() + { + // Arrange: a refused file, a path-like name, and the same file noted twice. + TurnStreamFiles files = new(); + FileContent later = Kept("chart.png", "image/png", 2); + later.Title = "Chart"; + files.Note(UpdateWith(Kept("chart.png", "image/png", 1), Kept("../escape.png", "image/png", 3), new FileContent { Name = "refused.png", FileId = "cfile_2" })); + files.Note(UpdateWith(later)); + + // Act + TurnStreamPart part = Assert.Single(files.Resolve()); + + // Assert + FilePayload payload = Assert.IsType(part.Payload); + Assert.Equal("chart.png", payload.Name); + Assert.Equal("Chart", payload.Title); + Assert.Equal(2, payload.Length); + } + [Fact] public void Resolve_NothingNoted_YieldsNothing() {