diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs index fea7b305..49ce7901 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs @@ -37,7 +37,7 @@ internal sealed class AgentCoreBoot : IAsyncDisposable, IDisposable /// Takes the options a host filled and the loggers the container holds. /// The options every Use* seam wrote into. /// The container's factory, used unless the options name another. - /// The container, read for the mapped routes the startup check walks. + /// The container, read for host-registered seams the boot honors. public AgentCoreBoot(IOptions options, ILoggerFactory loggers, IServiceProvider? services = null) { ArgumentNullException.ThrowIfNull(options); @@ -152,7 +152,7 @@ internal T Release(T resource) /// /// /// The document fails one of the eight checks, names a kind no registered adapter serves, - /// does not compile, or a mapped route names an entry it does not declare. + /// or does not compile. /// /// One ${secret:name} reference resolves to nothing. internal async ValueTask BootAsync(CancellationToken cancellationToken) diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs index c493794d..5d5c5aa5 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Sessions; using Microsoft.Agents.AI.Hosting; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.WebSockets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -42,6 +43,7 @@ public static IServiceCollection AddAgentCore( _ = services.AddSingleton(); _ = services.AddHostedService(); + services.TryAddEnumerable(ServiceDescriptor.Transient()); _ = services.AddSingleton(Boot(boot => boot.Configuration)); _ = services.AddSingleton(Boot(boot => boot.Secrets)); _ = services.AddSingleton(Boot(boot => boot.Bindings)); diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRouteCheck.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRouteCheck.cs new file mode 100644 index 00000000..393a71f5 --- /dev/null +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRouteCheck.cs @@ -0,0 +1,66 @@ +using AgentCore.Application.Configuration.Parsing; +using AgentCore.AspNetCore.Endpoints; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace AgentCore.AspNetCore.DependencyInjection.Startup +{ + /// + /// Fails startup when an AgentCore route has no way to learn its entry, or names a selector the host never + /// registered. + /// + internal sealed class EntryRouteCheck : IStartupFilter + { + /// + public Action Configure(Action next) + { + return app => + { + next(app); + Check(app.ApplicationServices); + }; + } + + /// Walks every mapped route AgentCore tagged. + /// The root provider. + /// A route names no entry, or its selector is not registered. + internal static void Check(IServiceProvider services) + { + if (services.GetService() is not { } routes) + { + return; + } + + IServiceProviderIsService registered = services.GetRequiredService(); + + foreach (RouteEndpoint route in routes.Endpoints.OfType()) + { + if (route.Metadata.GetMetadata() is null) + { + continue; + } + + string pattern = route.RoutePattern.RawText ?? route.DisplayName ?? "(unnamed route)"; + + if (route.Metadata.GetMetadata() is { } selector) + { + if (!registered.IsService(selector.SelectorType)) + { + throw new ConfigurationLoadException( + $"The route '{pattern}' picks its entry with {selector.SelectorType.FullName}, which the " + + "host never registered. Add it to the service collection."); + } + } + else if (route.RoutePattern.GetParameter(ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter) is null) + { + throw new ConfigurationLoadException( + $"The route '{pattern}' carries no {{{ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter}}} " + + "parameter and no entry selector, so nothing names its entry. Add {entry} to the pattern, " + + "or call SelectEntry on the route."); + } + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Endpoints/AgentCoreEntries.cs b/src/AgentCore.AspNetCore/Endpoints/AgentCoreEntries.cs new file mode 100644 index 00000000..210031e6 --- /dev/null +++ b/src/AgentCore.AspNetCore/Endpoints/AgentCoreEntries.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace AgentCore.AspNetCore.Endpoints +{ + /// Learns the entry a request on an AgentCore route runs. + public static class AgentCoreEntries + { + /// + /// Runs the endpoint's selector, or reads {entry} when it has none, once per request, and keeps the + /// answer as . + /// + /// A request that routing has already matched to an endpoint. + /// Cancels the selector. + /// + /// The entry key, or when the selector refused. A route with no selector and no + /// {entry} value answers the empty string, which no entry declares. + /// + /// Routing has not matched the request to an endpoint yet. + public static async ValueTask ResolveAsync(HttpContext http, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(http); + + if (http.Features.Get() is { } known) + { + return known.Entry; + } + + if (http.Features.Get() is not null) + { + return null; + } + + Endpoint endpoint = http.GetEndpoint() + ?? throw new InvalidOperationException( + "the request has no endpoint yet, so no route names its entry. Resolve the entry after " + + "app.UseRouting()."); + + string? entry = endpoint.Metadata.GetMetadata() is { } metadata + ? await ((IEntrySelector)http.RequestServices.GetRequiredService(metadata.SelectorType)) + .SelectAsync(http, cancellationToken) + .ConfigureAwait(false) + : http.Request.RouteValues[ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter] as string ?? string.Empty; + + if (entry is null) + { + http.Features.Set(EntryRefused.Instance); + } + else + { + http.Features.Set(new EntryFeature(entry)); + } + + return entry; + } + + private sealed class EntryFeature(string entry) : IEntryFeature + { + public string Entry { get; } = entry; + } + + private sealed class EntryRefused + { + public static EntryRefused Instance { get; } = new(); + } + } +} diff --git a/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs b/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs new file mode 100644 index 00000000..8bcb8011 --- /dev/null +++ b/src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs @@ -0,0 +1,15 @@ +namespace AgentCore.AspNetCore.Endpoints +{ + /// + /// Tags a route AgentCore mapped, so the startup check finds every route that must learn its entry. + /// + internal sealed class AgentCoreRouteMetadata + { + /// Gets the one tag every AgentCore route shares. + public static AgentCoreRouteMetadata Instance { get; } = new(); + + private AgentCoreRouteMetadata() + { + } + } +} diff --git a/src/AgentCore.AspNetCore/Endpoints/EntrySelectorEndpointConventionBuilderExtensions.cs b/src/AgentCore.AspNetCore/Endpoints/EntrySelectorEndpointConventionBuilderExtensions.cs new file mode 100644 index 00000000..18e4c668 --- /dev/null +++ b/src/AgentCore.AspNetCore/Endpoints/EntrySelectorEndpointConventionBuilderExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Builder; + +namespace AgentCore.AspNetCore.Endpoints +{ + /// Attaches an to a route AgentCore mapped. + public static class EntrySelectorEndpointConventionBuilderExtensions + { + /// Makes the route pick its entry with , in place of the URL. + /// The selector. The host registers it; startup fails when it does not. + /// The route, as a mapping call returned it. + /// The same route, so a host chains its conventions. + public static IEndpointConventionBuilder SelectEntry(this IEndpointConventionBuilder builder) + where TSelector : class, IEntrySelector + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithMetadata(new EntrySelectorMetadata(typeof(TSelector))); + } + } +} diff --git a/src/AgentCore.AspNetCore/Endpoints/EntrySelectorMetadata.cs b/src/AgentCore.AspNetCore/Endpoints/EntrySelectorMetadata.cs new file mode 100644 index 00000000..d7ee9016 --- /dev/null +++ b/src/AgentCore.AspNetCore/Endpoints/EntrySelectorMetadata.cs @@ -0,0 +1,6 @@ +namespace AgentCore.AspNetCore.Endpoints +{ + /// Endpoint metadata that names the a route picks its entry with. + /// The selector type, resolved from the request's services. + internal sealed record EntrySelectorMetadata(Type SelectorType); +} diff --git a/src/AgentCore.AspNetCore/Endpoints/IEntryFeature.cs b/src/AgentCore.AspNetCore/Endpoints/IEntryFeature.cs new file mode 100644 index 00000000..801e7a7c --- /dev/null +++ b/src/AgentCore.AspNetCore/Endpoints/IEntryFeature.cs @@ -0,0 +1,9 @@ +namespace AgentCore.AspNetCore.Endpoints +{ + /// The entry this request runs, once it is known. + public interface IEntryFeature + { + /// Gets the entry key. + string Entry { get; } + } +} diff --git a/src/AgentCore.AspNetCore/Endpoints/IEntrySelector.cs b/src/AgentCore.AspNetCore/Endpoints/IEntrySelector.cs new file mode 100644 index 00000000..76fd856d --- /dev/null +++ b/src/AgentCore.AspNetCore/Endpoints/IEntrySelector.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; + +namespace AgentCore.AspNetCore.Endpoints +{ + /// + /// Picks the entry for one request on a route that carries this selector, so the server chooses the + /// agent and the URL does not. + /// + public interface IEntrySelector + { + /// Picks the entry this request runs. + /// The request, already authenticated when the host runs authentication first. + /// Cancels the pick. + /// The entry key, or to refuse the request with 403. + ValueTask SelectAsync(HttpContext http, CancellationToken cancellationToken); + } +} diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs index 64444821..409204a8 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs @@ -44,33 +44,36 @@ public static IEndpointConventionBuilder MapResponses(this IEndpointRouteBuilder return endpoints.MapResponses(DefaultPattern); } - /// Maps every entry on one route, with the URL naming the entry. + /// Maps every entry on one route. /// The route builder of the host. - /// The route to answer on. It must carry the {entry} parameter. + /// + /// The route to answer on. It carries the {entry} parameter, or the host attaches an + /// with . + /// Startup fails when it has neither. + /// /// The mapped endpoint, so a host adds its own conventions. - /// carries no {entry}. public static IEndpointConventionBuilder MapResponses(this IEndpointRouteBuilder endpoints, string pattern) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentException.ThrowIfNullOrEmpty(pattern); - return !pattern.Contains("{" + EntryRouteParameter + "}", StringComparison.Ordinal) - ? throw new ArgumentException( - $"The pattern '{pattern}' carries no {{{EntryRouteParameter}}} parameter, so no URL can name " - + "an entry.", - nameof(pattern)) - : endpoints.MapPost(pattern, HandleAsync); + return endpoints.MapPost(pattern, HandleAsync).WithMetadata(AgentCoreRouteMetadata.Instance); } /// Runs one turn of one conversation, and files the session under the ids the answer carries. - /// - /// Every step answers its own refusal and returns , so this reads as - /// the happy path alone. - /// private static async Task HandleAsync(HttpContext http) { CancellationToken cancellationToken = http.RequestAborted; - string entry = http.Request.RouteValues[EntryRouteParameter] as string ?? string.Empty; + if (await AgentCoreEntries.ResolveAsync(http, cancellationToken).ConfigureAwait(false) is not { } entry) + { + await ResponsesRequestReader.WriteErrorAsync( + http, + StatusCodes.Status403Forbidden, + "this route runs no entry for this caller.", + "entry_refused", + cancellationToken).ConfigureAwait(false); + return; + } if (await ResponsesRequestReader.ReadBodyAsync(http, cancellationToken).ConfigureAwait(false) is not { } body) { diff --git a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt index 6025d79d..62090300 100644 --- a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt +++ b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt @@ -205,3 +205,11 @@ override sealed AgentCore.AspNetCore.Voice.ConversationInput.Barge.Equals(AgentC override sealed AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool override sealed AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool override sealed AgentCore.AspNetCore.Voice.ConversationInput.Started.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool +AgentCore.AspNetCore.Endpoints.AgentCoreEntries +AgentCore.AspNetCore.Endpoints.EntrySelectorEndpointConventionBuilderExtensions +AgentCore.AspNetCore.Endpoints.IEntryFeature +AgentCore.AspNetCore.Endpoints.IEntryFeature.Entry.get -> string! +AgentCore.AspNetCore.Endpoints.IEntrySelector +AgentCore.AspNetCore.Endpoints.IEntrySelector.SelectAsync(Microsoft.AspNetCore.Http.HttpContext! http, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +static AgentCore.AspNetCore.Endpoints.AgentCoreEntries.ResolveAsync(Microsoft.AspNetCore.Http.HttpContext! http, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static AgentCore.AspNetCore.Endpoints.EntrySelectorEndpointConventionBuilderExtensions.SelectEntry(this Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! builder) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! diff --git a/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs b/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs index dd035679..fe68da19 100644 --- a/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs +++ b/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs @@ -1,5 +1,6 @@ using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Endpoints; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -11,8 +12,7 @@ namespace AgentCore.AspNetCore.Voice { /// - /// Maps the inbound conversation route, with the URL naming the entry, onto whichever transport the - /// document names. + /// Maps the inbound conversation route onto whichever transport the document names. /// public static class ConversationEndpointRouteBuilderExtensions { @@ -30,55 +30,66 @@ public static IEndpointConventionBuilder MapCall(this IEndpointRouteBuilder endp return endpoints.MapCall(DefaultPattern); } - /// Maps every entry on one route, with the URL naming the entry. + /// Maps every entry on one route. /// The route builder of the host. - /// The route to answer on. It must carry the {entry} parameter. + /// + /// The route to answer on. It carries the {entry} parameter, or the host attaches an + /// with . + /// Startup fails when it has neither. + /// /// The mapped endpoint, so a host adds its own conventions. - /// carries no {entry}. public static IEndpointConventionBuilder MapCall(this IEndpointRouteBuilder endpoints, string pattern) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentException.ThrowIfNullOrEmpty(pattern); - if (!pattern.Contains("{" + EntryRouteParameter + "}", StringComparison.Ordinal)) - { - throw new ArgumentException( - $"The pattern '{pattern}' carries no {{{EntryRouteParameter}}} parameter, so no URL can name " - + "an entry.", - nameof(pattern)); - } - // Map, and not MapGet. An HTTP/2 WebSocket arrives as CONNECT rather than GET, and MapGet // would answer 405 to it. - return endpoints.Map(pattern, http => DispatchAsync(http, pattern)); + return endpoints.Map(pattern, http => DispatchAsync(http, pattern)).WithMetadata(AgentCoreRouteMetadata.Instance); } - /// Reads the entry the URL names, off the {entry} route parameter. + /// Reads the entry this request runs. /// The request on a conversation route. - /// The entry key, or the empty string when the route carries none. + /// + /// The entry kept, else the {entry} route value, else the + /// empty string. + /// public static string EntryOf(HttpContext http) { ArgumentNullException.ThrowIfNull(http); - return http.Request.RouteValues[EntryRouteParameter] as string ?? string.Empty; + return http.Features.Get()?.Entry + ?? http.Request.RouteValues[EntryRouteParameter] as string + ?? string.Empty; } - private static Task DispatchAsync(HttpContext http, string pattern) + private static async Task DispatchAsync(HttpContext http, string pattern) { if (http.RequestServices.GetService() is not { } boot) { - return NotRoutedAsync(http, pattern, "this host registered no AgentCore services"); + await NotRoutedAsync(http, pattern, "this host registered no AgentCore services").ConfigureAwait(false); + return; } if (boot.ConversationHandler is not { } handler) { - return NotRoutedAsync(http, pattern, boot.ConversationUnroutable ?? "this host routes no inbound conversation"); + await NotRoutedAsync(http, pattern, boot.ConversationUnroutable ?? "this host routes no inbound conversation") + .ConfigureAwait(false); + return; } - string entry = EntryOf(http); + // Before the handler, so a refused caller never gets a socket upgrade. + if (await AgentCoreEntries.ResolveAsync(http, http.RequestAborted).ConfigureAwait(false) is not { } entry) + { + await WriteProblemAsync( + http, StatusCodes.Status403Forbidden, "This route runs no entry for this caller.", + "entry_refused: the route's entry selector refused this caller.", http.Request.Path) + .ConfigureAwait(false); + return; + } - return boot.Entries.Entries.Contains(entry, StringComparer.Ordinal) + await (boot.Entries.Entries.Contains(entry, StringComparer.Ordinal) ? handler(http) - : UnknownEntryAsync(http, EntryRegistry.UnknownEntryMessage(entry, boot.Entries.Entries)); + : UnknownEntryAsync(http, EntryRegistry.UnknownEntryMessage(entry, boot.Entries.Entries))).ConfigureAwait(false); } private static async Task NotRoutedAsync(HttpContext http, string pattern, string reason) diff --git a/src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs b/src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs index c959a609..3e7000d3 100644 --- a/src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs +++ b/src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs @@ -11,7 +11,7 @@ public interface IConversationTransportAdapter : IConversationAdapter /// Builds the handler the conversation route answers every entry with. /// The providers.conversation block, including its limits. /// - /// The delegate the conversation route runs. The URL names the entry, and the route has already + /// The delegate the conversation route runs. The route has already picked the entry and /// refused one the document does not declare; the handler reads it with /// . /// diff --git a/src/AgentCore.Hosting/AgentCoreEndpoints.cs b/src/AgentCore.Hosting/AgentCoreEndpoints.cs new file mode 100644 index 00000000..43174a1f --- /dev/null +++ b/src/AgentCore.Hosting/AgentCoreEndpoints.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Builder; + +namespace AgentCore.Hosting +{ + /// The routes mapped, so a host adds its own conventions to each. + /// The liveness check. + /// The OpenAI-compatible Responses endpoint. + /// The conversation socket. + public sealed record AgentCoreEndpoints( + IEndpointConventionBuilder Health, + IEndpointConventionBuilder Responses, + IEndpointConventionBuilder Call); +} diff --git a/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs b/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs index 0b5fcbd6..089333ce 100644 --- a/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs +++ b/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs @@ -17,29 +17,29 @@ public static class AgentCoreHostEndpointExtensions /// The application to map on. /// /// The route the OpenAI-compatible Responses endpoint answers on, with {entry} naming - /// the entry, or for + /// the entry unless the host attaches an entry selector, or for /// . /// /// - /// The route the conversation socket answers on, with {entry} naming the entry, or + /// The route the conversation socket answers on, with {entry} naming the entry unless the + /// host attaches an entry selector, or /// for . /// - /// The same application, so a host chains its conversations. + /// The mapped routes, so a host adds its own conventions to each. /// is . - public static WebApplication MapAgentCoreHost( + public static AgentCoreEndpoints MapAgentCoreHost( this WebApplication app, string? responsesPattern = null, string? conversationPattern = null) { ArgumentNullException.ThrowIfNull(app); - _ = app.MapGet(HealthPattern, () => Results.Ok("ok")); + IEndpointConventionBuilder health = app.MapGet(HealthPattern, () => Results.Ok("ok")); _ = app.UseWebSockets(); - _ = app.MapResponses(responsesPattern ?? ResponsesEndpointRouteBuilderExtensions.DefaultPattern); - - _ = app.MapCall(conversationPattern ?? ConversationEndpointRouteBuilderExtensions.DefaultPattern); - - return app; + return new AgentCoreEndpoints( + health, + app.MapResponses(responsesPattern ?? ResponsesEndpointRouteBuilderExtensions.DefaultPattern), + app.MapCall(conversationPattern ?? ConversationEndpointRouteBuilderExtensions.DefaultPattern)); } } } diff --git a/src/AgentCore.Hosting/PublicAPI.Unshipped.txt b/src/AgentCore.Hosting/PublicAPI.Unshipped.txt index c37f0072..0651435c 100644 --- a/src/AgentCore.Hosting/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Hosting/PublicAPI.Unshipped.txt @@ -7,4 +7,20 @@ const AgentCore.Hosting.AgentCoreHostBuilderExtensions.DefaultConfigurationPath const AgentCore.Hosting.AgentCoreHostBuilderExtensions.McpHttpClientName = "agentcore.mcp" -> string! 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! -static AgentCore.Hosting.AgentCoreHostEndpointExtensions.MapAgentCoreHost(this Microsoft.AspNetCore.Builder.WebApplication! app, string? responsesPattern = null, string? conversationPattern = null) -> Microsoft.AspNetCore.Builder.WebApplication! +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.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.Equals(AgentCore.Hosting.AgentCoreEndpoints? other) -> bool +AgentCore.Hosting.AgentCoreEndpoints.Health.get -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! +AgentCore.Hosting.AgentCoreEndpoints.Responses.get -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! +AgentCore.Hosting.AgentCoreEndpoints.Responses.init -> void +override AgentCore.Hosting.AgentCoreEndpoints.Equals(object? obj) -> bool +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! +AgentCore.Hosting.AgentCoreEndpoints +AgentCore.Hosting.AgentCoreEndpoints.Call.init -> void +AgentCore.Hosting.AgentCoreEndpoints.Health.init -> void diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntrySelectorTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntrySelectorTests.cs new file mode 100644 index 00000000..cadc98e4 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntrySelectorTests.cs @@ -0,0 +1,220 @@ +using System.Net; +using System.Text; +using AgentCore.Application.Configuration.Schema; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// A route with an runs the entry the server picks, not one the URL names + /// (entry selector spec, section 4). + /// + public sealed class EntrySelectorTests + { + private const string Route = "/v1/chat/responses"; + + private const string TwoEntryYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: front, instructions: "answer visitors", model: { ref: front } } + - { id: desk, instructions: "answer staff", model: { ref: desk } } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: front } + - { kind: openai, model: gpt-4.1-mini, as: desk } + entries: + main: + agent: front + staff: + agent: desk + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 60_000)] + public async Task TheSelectorsEntryAnswers() + { + Models models = new(); + await using ResponsesHost host = await StartAsync(models, "staff"); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("desk reply", (await ResponsesHost.ReadJsonAsync(response)).OutputText(), StringComparison.Ordinal); + Assert.Equal(0, models.Front.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task ARefusingSelectorAnswers403AndRunsNoAgent() + { + Models models = new(); + await using ResponsesHost host = await StartAsync(models, entry: null); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal("entry_refused", (await ResponsesHost.ReadJsonAsync(response))["error"]!["code"]!.GetValue()); + Assert.Equal(0, models.Front.Calls + models.Desk.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task ASelectorThatNamesAnUndeclaredEntryAnswers404() + { + await using ResponsesHost host = await StartAsync(new Models(), "nobody"); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("unknown_entry", (await ResponsesHost.ReadJsonAsync(response))["error"]!["code"]!.GetValue()); + } + + [Fact(Timeout = 60_000)] + public async Task MiddlewareThatResolvesFirstLeavesTheSelectorRunOnce() + { + FixedSelector selector = new("staff"); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + new Models().Bind, + services => services.AddSingleton(selector), + app => + { + _ = app.Use(async (http, next) => + { + _ = await AgentCoreEntries.ResolveAsync(http, http.RequestAborted); + await next(http); + }); + _ = app.MapResponses(Route).SelectEntry(); + }); + + using HttpResponseMessage response = await PostAsync(host); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, selector.Calls); + } + + [Fact(Timeout = 60_000)] + public async Task TheConversationRouteRefusesBeforeItsHandlerRuns() + { + HandlerProbe transport = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + SocketYaml, + new FragmentingChatClient("unused"), + options => + { + _ = options.UseConversation(transport); + _ = options.UseSpeech(new SilentSpeech()); + }, + services => services.AddSingleton(new FixedSelector(null)), + app => app.MapCall("/v1/call").SelectEntry()); + + using HttpResponseMessage response = await host.Client.GetAsync(new Uri("/v1/call", UriKind.Relative), Ct); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.False(transport.Ran); + } + + private const string SocketYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: front, instructions: "answer visitors" } + providers: + conversation: { kind: probe } + speech: + stt: { kind: probe } + tts: { kind: probe } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + main: + agent: front + """; + + private static Task StartAsync(Models models, string? entry) + { + return ResponsesHost.StartAsync( + TwoEntryYaml, + new FragmentingChatClient("unused"), + models.Bind, + services => services.AddSingleton(new FixedSelector(entry)), + app => app.MapResponses(Route).SelectEntry()); + } + + private static Task PostAsync(ResponsesHost host) + { + HttpRequestMessage request = new(HttpMethod.Post, Route) + { + Content = new StringContent(/*lang=json,strict*/ """{ "stream": false, "input": "hi" }""", Encoding.UTF8, "application/json"), + }; + + return host.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct); + } + + /// One model per agent, so a test reads which agent answered. + private sealed class Models + { + public FragmentingChatClient Front { get; } = new("front reply"); + + public FragmentingChatClient Desk { get; } = new("desk reply"); + + public void Bind(AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions options) + { + _ = options.UseChatClients(_ => new RoutingChatClientFactory().Route("front", Front).Route("desk", Desk)); + } + } + + /// Answers one entry for every caller, and counts how often it was asked. + private sealed class FixedSelector(string? entry) : IEntrySelector + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public ValueTask SelectAsync(HttpContext http, CancellationToken cancellationToken) + { + _ = Interlocked.Increment(ref _calls); + return ValueTask.FromResult(entry); + } + } + + /// A transport whose handler records that it ran. The handler is where a socket upgrade happens. + private sealed class HandlerProbe : IConversationTransportAdapter + { + public string Kind => "probe"; + + public bool CarriesText => true; + + public bool Ran { get; private set; } + + public RequestDelegate CreateHandler(ConversationProviderConfiguration configuration) + { + return _ => + { + Ran = true; + return Task.CompletedTask; + }; + } + } + + /// The speech vendor the pairing rule reads, which builds nothing. + private sealed class SilentSpeech : ISpeechAdapter + { + public string Kind => "probe"; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/ResponsesHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/ResponsesHost.cs index b72aabf0..9cb5c333 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/ResponsesHost.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/ResponsesHost.cs @@ -44,9 +44,15 @@ private ResponsesHost(WebApplication app, HttpClient client) /// The document, as YAML. /// The model behind every name the routing factory does not hold. /// Anything else the test binds on the options. + /// Anything else the test registers, such as an entry selector. + /// Maps the routes, or for the default Responses route. /// The started host. public static async Task StartAsync( - string yaml, IChatClient reply, Action? configure = null) + string yaml, + IChatClient reply, + Action? configure = null, + Action? services = null, + Action? map = null) { WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); _ = builder.WebHost.UseUrls("http://127.0.0.1:0"); @@ -59,8 +65,18 @@ public static async Task StartAsync( configure?.Invoke(options); }); + services?.Invoke(builder.Services); + WebApplication app = builder.Build(); - _ = app.MapResponses(); + if (map is null) + { + _ = app.MapResponses(); + } + else + { + map(app); + } + await app.StartAsync(); string address = app.Services diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs index a1f22bd1..06fb95a9 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs @@ -101,17 +101,6 @@ public void TheDefaultPatternIsVendorNeutral() Assert.Equal("/v1/{entry}/call", ConversationEndpointRouteBuilderExtensions.DefaultPattern); } - [Fact] - public void ARouteWithNoEntryParameterIsRefused() - { - WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); - using WebApplication app = builder.Build(); - - ArgumentException failure = Assert.Throws(() => app.MapCall("/v1/conversation")); - - Assert.Contains("{entry}", failure.Message, StringComparison.Ordinal); - } - // --------------------------------------------------------------------------------------------- // Helpers. // --------------------------------------------------------------------------------------------- diff --git a/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs b/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs new file mode 100644 index 00000000..c9390306 --- /dev/null +++ b/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs @@ -0,0 +1,183 @@ +using AgentCore.Application.Configuration.Parsing; +using AgentCore.AspNetCore.Endpoints; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.Hosting.Tests +{ + /// + /// The routes maps. The registrations answer + /// nothing on their own. + /// + public sealed class AgentCoreHostRouteTests + { + [Fact] + public async Task HealthAnswersOnTheMappedRoute() + { + await using WebApplication app = await StartMappedAsync(); + using HttpClient client = new() { BaseAddress = Address(app) }; + + HttpResponseMessage response = await client.GetAsync( + AgentCoreHostEndpointExtensions.HealthPattern, + TestContext.Current.CancellationToken); + + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + /// The default Responses route, with the document's one entry filled in. + private const string MainResponses = "/v1/main/responses"; + + [Fact] + public async Task ResponsesAnswersOnTheDefaultRoute() + { + await using WebApplication app = await StartMappedAsync(); + using HttpClient client = new() { BaseAddress = Address(app) }; + + // No user message is a caller mistake this endpoint names, and naming it proves the route + // reached the endpoint rather than the 404 handler. + HttpResponseMessage response = await PostEmptyAsync(client, MainResponses); + + Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ResponsesRefusesAnEntryTheDocumentDoesNotDeclare() + { + await using WebApplication app = await StartMappedAsync(); + using HttpClient client = new() { BaseAddress = Address(app) }; + + HttpResponseMessage response = await PostEmptyAsync(client, "/v1/nobody/responses"); + + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("\"code\":\"unknown_entry\"", body); + Assert.Contains("Valid entries: main", body); + } + + [Fact] + public async Task ResponsesMovesWhenTheHostNamesAnotherRoute() + { + // A host that mounts a second Responses surface of its own needs this one out of the + // way, and it must actually leave the default route behind when it moves. + await using WebApplication app = await StartMappedAsync("/agentcore/v1/{entry}/responses"); + using HttpClient client = new() { BaseAddress = Address(app) }; + + Assert.Equal( + System.Net.HttpStatusCode.BadRequest, + (await PostEmptyAsync(client, "/agentcore/v1/main/responses")).StatusCode); + Assert.Equal( + System.Net.HttpStatusCode.NotFound, + (await PostEmptyAsync(client, MainResponses)).StatusCode); + } + + [Fact] + public async Task ConversationRefusesAnEntryTheDocumentDoesNotDeclare() + { + await using WebApplication app = await StartMappedAsync(); + using HttpClient client = new() { BaseAddress = Address(app) }; + + HttpResponseMessage response = await client.GetAsync("/v1/nobody/call", TestContext.Current.CancellationToken); + + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + Assert.Contains( + "Valid entries: main", + await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ASelectorPicksTheEntryOnARouteThatNamesNone() + { + await using WebApplication app = await HostingTestHost.BuildAsync( + services: services => services.AddSingleton()); + _ = app.MapAgentCoreHost("/v1/chat/responses").Responses.SelectEntry(); + await app.StartAsync(TestContext.Current.CancellationToken); + using HttpClient client = new() { BaseAddress = Address(app) }; + + // 400 names the missing user message, so the request reached the endpoint and ran "main". + Assert.Equal( + System.Net.HttpStatusCode.BadRequest, + (await PostEmptyAsync(client, "/v1/chat/responses")).StatusCode); + } + + [Fact] + public async Task ARouteWithNoEntryParameterAndNoSelectorFailsTheStart() + { + await using WebApplication app = await HostingTestHost.BuildAsync(); + _ = app.MapAgentCoreHost("/v1/chat/responses"); + + ConfigurationLoadException failure = await Assert.ThrowsAsync( + () => app.StartAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("/v1/chat/responses", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ASelectorTheHostNeverRegisteredFailsTheStart() + { + await using WebApplication app = await HostingTestHost.BuildAsync(); + _ = app.MapAgentCoreHost("/v1/chat/responses").Responses.SelectEntry(); + + ConfigurationLoadException failure = await Assert.ThrowsAsync( + () => app.StartAsync(TestContext.Current.CancellationToken)); + + Assert.Contains(nameof(UnregisteredSelector), failure.Message, StringComparison.Ordinal); + } + + /// Builds a host, maps every route, and puts it on a real socket. + /// The route the Responses endpoint answers on, or null for the default. + /// The started host. + private static async Task StartMappedAsync(string? responsesPattern = null) + { + WebApplication app = await HostingTestHost.BuildAsync(); + _ = app.MapAgentCoreHost(responsesPattern); + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + /// Reads the address Kestrel took, since the tests ask for port zero. + /// The started host. + /// The base address. + private static Uri Address(WebApplication app) + { + return new(app.Services + .GetRequiredService() + .Features + .Get()! + .Addresses + .First(), UriKind.Absolute); + } + + /// Posts a request with no user message, which every mapped route answers 400 to. + /// The client that speaks to the host. + /// The route to post to. + /// The answer. + private static Task PostEmptyAsync(HttpClient client, string route) + { + return client.PostAsync( + route, + new StringContent(/*lang=json,strict*/ "{\"input\":[]}", System.Text.Encoding.UTF8, "application/json"), + TestContext.Current.CancellationToken); + } + + + /// Picks the document's one entry for every caller. + private sealed class MainSelector : IEntrySelector + { + public ValueTask SelectAsync(HttpContext http, CancellationToken cancellationToken) + { + return ValueTask.FromResult("main"); + } + } + + /// A selector no test registers. + private sealed class UnregisteredSelector : IEntrySelector + { + public ValueTask SelectAsync(HttpContext http, CancellationToken cancellationToken) + { + return ValueTask.FromResult("main"); + } + } + } +} diff --git a/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs b/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs index b28f7081..c5beaa7e 100644 --- a/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs +++ b/tests/AgentCore.Hosting.Tests/AgentCoreHostTests.cs @@ -41,36 +41,6 @@ namespace AgentCore.Hosting.Tests /// public sealed class AgentCoreHostTests { - /// A model that says one thing, which is all the compile table asks for. - private sealed class FakeChatClient : IChatClient - { - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "hello"))); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask.ConfigureAwait(false); - yield return new ChatResponseUpdate(ChatRole.Assistant, "hello"); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - return null; - } - - public void Dispose() - { - } - } - // --------------------------------------------------------------------------------------------- // Who wins. The host does, on every seam, because its callback runs last. // --------------------------------------------------------------------------------------------- @@ -321,122 +291,10 @@ public async Task TheLoggerFactoryClosesWithTheHost() // The container built this factory, so nothing else can be holding its providers. _ = Assert.Throws(() => loggers.CreateLogger("after")); } - - // --------------------------------------------------------------------------------------------- - // The routes. Mapping is the other half: the registrations above answer nothing on their own. - // --------------------------------------------------------------------------------------------- - - [Fact] - public async Task HealthAnswersOnTheMappedRoute() - { - await using WebApplication app = await StartMappedAsync(); - using HttpClient client = new() { BaseAddress = Address(app) }; - - HttpResponseMessage response = await client.GetAsync( - AgentCoreHostEndpointExtensions.HealthPattern, - TestContext.Current.CancellationToken); - - Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); - } - - /// The default Responses route, with the document's one entry filled in. - private const string MainResponses = "/v1/main/responses"; - - [Fact] - public async Task ResponsesAnswersOnTheDefaultRoute() - { - await using WebApplication app = await StartMappedAsync(); - using HttpClient client = new() { BaseAddress = Address(app) }; - - // No user message is a caller mistake this endpoint names, and naming it proves the route - // reached the endpoint rather than the 404 handler. - HttpResponseMessage response = await PostEmptyAsync(client, MainResponses); - - Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task ResponsesRefusesAnEntryTheDocumentDoesNotDeclare() - { - await using WebApplication app = await StartMappedAsync(); - using HttpClient client = new() { BaseAddress = Address(app) }; - - HttpResponseMessage response = await PostEmptyAsync(client, "/v1/nobody/responses"); - - Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); - string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - Assert.Contains("\"code\":\"unknown_entry\"", body); - Assert.Contains("Valid entries: main", body); - } - - [Fact] - public async Task ResponsesMovesWhenTheHostNamesAnotherRoute() - { - // A host that mounts a second Responses surface of its own needs this one out of the - // way, and it must actually leave the default route behind when it moves. - await using WebApplication app = await StartMappedAsync("/agentcore/v1/{entry}/responses"); - using HttpClient client = new() { BaseAddress = Address(app) }; - - Assert.Equal( - System.Net.HttpStatusCode.BadRequest, - (await PostEmptyAsync(client, "/agentcore/v1/main/responses")).StatusCode); - Assert.Equal( - System.Net.HttpStatusCode.NotFound, - (await PostEmptyAsync(client, MainResponses)).StatusCode); - } - - [Fact] - public async Task ConversationRefusesAnEntryTheDocumentDoesNotDeclare() - { - await using WebApplication app = await StartMappedAsync(); - using HttpClient client = new() { BaseAddress = Address(app) }; - - HttpResponseMessage response = await client.GetAsync("/v1/nobody/call", TestContext.Current.CancellationToken); - - Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); - Assert.Contains( - "Valid entries: main", - await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task ResponsesRefusesARouteWithNoEntryParameter() - { - await using WebApplication app = await BuildAsync(); - - ArgumentException failure = Assert.Throws(() => app.MapAgentCoreHost("/v1/responses")); - - Assert.Contains("{entry}", failure.Message); - } - // --------------------------------------------------------------------------------------------- // Helpers. // --------------------------------------------------------------------------------------------- - /// The smallest document the schema accepts: one agent, one model, and the required pair. - /// - /// A document that writes a providers block at all must write conversation and - /// speech too, and both speech roles must name the same vendor the conversation does. Those kinds - /// resolve here because the default list this library registers already names that transport — - /// which is the point: a test writes no vendor of its own except the model. - /// - private const string Document = """ - apiVersion: agentcore/v1 - agents: - items: - - { id: only, instructions: "I answer everything" } - entries: - main: - agent: only - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - llm: - - { kind: fake, model: fake-model, as: reply } - """; - /// The providers line that asks for the durable audit vendor. private const string Audit = " audit: { kind: postgres }"; @@ -460,30 +318,6 @@ public async Task ResponsesRefusesARouteWithNoEntryParameter() + " knowledge: { kind: qdrant, endpoint: \"https://127.0.0.1:1\", collection: \"missing\", " + "mapper: " + StubPointMapper.MapperName + " }"; - /// Builds a host the way a deployable does, over a fake model and no key. - /// Anything else the test says on the options. - /// A further line under providers, or null for the document above. - /// The built host, not started. - private static async Task BuildAsync( - Action? configure = null, - string? providers = null) - { - WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); - _ = builder.WebHost.UseUrls("http://127.0.0.1:0"); - _ = builder.Logging.ClearProviders(); - - string document = providers is null ? Document : Document + Environment.NewLine + providers; - - _ = builder.AddAgentCoreHost(options => - { - options.Configuration = ConfigurationLoader.LoadYaml(document); - _ = options.UseChatClients(_ => new RecordingChatClientFactory(new FakeChatClient())); - configure?.Invoke(options); - }); - - return builder.Build(); - } - /// Starts a host and reads its container, with nothing mapped and nothing listening. /// Anything else the test says on the options. /// A further line under providers, or null for the plain document. @@ -496,7 +330,7 @@ private static async Task StartAsync( Action? configure = null, string? providers = null) { - WebApplication app = await BuildAsync(configure, providers); + WebApplication app = await HostingTestHost.BuildAsync(configure, providers); try { @@ -625,43 +459,6 @@ private sealed class StubPointMapper : IKnowledgePointMapper } } - /// Builds a host, maps every route, and puts it on a real socket. - /// The route the Responses endpoint answers on, or null for the default. - /// The started host. - private static async Task StartMappedAsync(string? responsesPattern = null) - { - WebApplication app = await BuildAsync(); - _ = app.MapAgentCoreHost(responsesPattern); - await app.StartAsync(TestContext.Current.CancellationToken); - return app; - } - - /// Reads the address Kestrel took, since the tests ask for port zero. - /// The started host. - /// The base address. - private static Uri Address(WebApplication app) - { - return new(app.Services - .GetRequiredService() - .Features - .Get()! - .Addresses - .First(), UriKind.Absolute); - } - - /// Posts a request with no user message, which every mapped route answers 400 to. - /// The client that speaks to the host. - /// The route to post to. - /// The answer. - private static Task PostEmptyAsync(HttpClient client, string route) - { - return client.PostAsync( - route, - new StringContent(/*lang=json,strict*/ "{\"input\":[]}", System.Text.Encoding.UTF8, "application/json"), - TestContext.Current.CancellationToken); - } - - // --------------------------------------------------------------------------------------------- // A transport: http MCP server is reached on this host's own pipeline, minus the one part of it // that does not apply to a stream. // --------------------------------------------------------------------------------------------- diff --git a/tests/AgentCore.Hosting.Tests/FakeChatClient.cs b/tests/AgentCore.Hosting.Tests/FakeChatClient.cs new file mode 100644 index 00000000..f517bea3 --- /dev/null +++ b/tests/AgentCore.Hosting.Tests/FakeChatClient.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Hosting.Tests +{ + /// A model that says one thing, which is all the compile table asks for. + internal sealed class FakeChatClient : IChatClient + { + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "hello"))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask.ConfigureAwait(false); + yield return new ChatResponseUpdate(ChatRole.Assistant, "hello"); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.Hosting.Tests/HostingTestHost.cs b/tests/AgentCore.Hosting.Tests/HostingTestHost.cs new file mode 100644 index 00000000..07ef7a94 --- /dev/null +++ b/tests/AgentCore.Hosting.Tests/HostingTestHost.cs @@ -0,0 +1,66 @@ +using AgentCore.Application.Configuration.Parsing; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.TestSupport; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AgentCore.Hosting.Tests +{ + /// The one host every test in this project builds, over a fake model and no key. + internal static class HostingTestHost + { + /// The smallest document the schema accepts: one agent, one model, and the required pair. + /// + /// A document that writes a providers block at all must write conversation and + /// speech too, and both speech roles must name the same vendor the conversation does. Those kinds + /// resolve here because the default list this library registers already names that transport — + /// which is the point: a test writes no vendor of its own except the model. + /// + internal const string Document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "I answer everything" } + entries: + main: + agent: only + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: fake, model: fake-model, as: reply } + """; + + /// Builds a host the way a deployable does, over a fake model and no key. + /// Anything else the test says on the options. + /// A further line under providers, or null for the document above. + /// Anything else the test registers, such as an entry selector. + /// The built host, not started. + internal static async Task BuildAsync( + Action? configure = null, + string? providers = null, + Action? services = null) + { + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + _ = builder.WebHost.UseUrls("http://127.0.0.1:0"); + _ = builder.Logging.ClearProviders(); + + string document = providers is null ? Document : Document + Environment.NewLine + providers; + + _ = builder.AddAgentCoreHost(options => + { + options.Configuration = ConfigurationLoader.LoadYaml(document); + _ = options.UseChatClients(_ => new RecordingChatClientFactory(new FakeChatClient())); + configure?.Invoke(options); + }); + + services?.Invoke(builder.Services); + + return builder.Build(); + } + } +}