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();
+ }
+ }
+}