diff --git a/Directory.Packages.props b/Directory.Packages.props index 74e3b4e47..09aabaec6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -77,13 +77,10 @@ - - + + @@ -106,7 +103,8 @@ - + + diff --git a/examples/squad/CommunityToolkit.Aspire.Hosting.Squad.ApiApp/CommunityToolkit.Aspire.Hosting.Squad.ApiApp.csproj b/examples/squad/CommunityToolkit.Aspire.Hosting.Squad.ApiApp/CommunityToolkit.Aspire.Hosting.Squad.ApiApp.csproj index fca71350b..2b2d94e59 100644 --- a/examples/squad/CommunityToolkit.Aspire.Hosting.Squad.ApiApp/CommunityToolkit.Aspire.Hosting.Squad.ApiApp.csproj +++ b/examples/squad/CommunityToolkit.Aspire.Hosting.Squad.ApiApp/CommunityToolkit.Aspire.Hosting.Squad.ApiApp.csproj @@ -3,14 +3,17 @@ - + - + + + diff --git a/examples/squad/CommunityToolkit.Aspire.Hosting.Squad.ApiApp/Program.cs b/examples/squad/CommunityToolkit.Aspire.Hosting.Squad.ApiApp/Program.cs index c783f0404..7dba6b703 100644 --- a/examples/squad/CommunityToolkit.Aspire.Hosting.Squad.ApiApp/Program.cs +++ b/examples/squad/CommunityToolkit.Aspire.Hosting.Squad.ApiApp/Program.cs @@ -10,7 +10,7 @@ // or CliArgs boilerplate required. // // The single POST /ask endpoint takes a ?squad=research|dev query parameter and a -// JSON body with a list of prompts. The coordinator decides per-prompt whether to +// JSON body with either a single `prompt` or a list of `prompts`. The coordinator decides per-prompt whether to // answer directly (Direct Mode in squad.agent.md), do a single agent spawn // (Lightweight), or fan out via the task tool (Full). The "Sample prompts" // section returned from GET / shows what each mode looks like — paste them @@ -131,11 +131,12 @@ // Index: shows the single /ask endpoint plus a copy-paste menu of sample // prompts that exercise each coordinator mode (Direct, Lightweight, Full). -// Same body shape as /ask accepts — just `{"prompts": [...]}`. +// Same body shape as /ask accepts — either `{"prompts": [...]}` or, for a +// quick single turn, `{"prompt": "..."}`. app.MapGet("/", () => Results.Ok(new { squads = squadKeysByShortName, - endpoint = "POST /ask?squad=research|dev — body: { \"prompts\": [...] }. " + + endpoint = "POST /ask?squad=research|dev — body: { \"prompts\": [...] } or { \"prompt\": \"...\" }. " + "Each prompt runs sequentially on a single AgentSession (multi-turn memory). " + "The coordinator picks Direct / Lightweight / Full mode per prompt; only Full mode produces squad.subagent spans.", sample_prompts = new @@ -201,17 +202,23 @@ var agent = ResolveSquad(sp, q.Squad, out var error); if (agent is null) return Results.BadRequest(new { error }); + var prompts = req.GetPrompts(); + if (prompts.Length == 0) + { + return Results.BadRequest(new { error = "Provide either 'prompt' or a non-empty 'prompts' array." }); + } + using var activity = ApiAppDiagnostics.ActivitySource .StartActivity($"squad.ask {q.Squad}", ActivityKind.Server); activity?.SetTag("squad.name", q.Squad); - activity?.SetTag("squad.prompt.count", req.Prompts.Length); + activity?.SetTag("squad.prompt.count", prompts.Length); - logger.LogInformation("/ask squad={Squad} prompts={Count}", q.Squad, req.Prompts.Length); + logger.LogInformation("/ask squad={Squad} prompts={Count}", q.Squad, prompts.Length); var turns = new List(); var session = await agent.CreateSessionAsync(ct); - foreach (var prompt in req.Prompts) + foreach (var prompt in prompts) { var response = await agent.RunAsync(prompt, session, cancellationToken: ct); turns.Add(new TurnResult(prompt, response.Text)); @@ -239,7 +246,18 @@ internal sealed record SquadQuery(string Squad = "research"); -internal sealed record AskRequest(string[] Prompts); +internal sealed record AskRequest(string[]? Prompts, string? Prompt = null) +{ + public string[] GetPrompts() + { + if (Prompts is { Length: > 0 }) + { + return Prompts; + } + + return string.IsNullOrWhiteSpace(Prompt) ? [] : [Prompt]; + } +} internal sealed record TurnResult(string Prompt, string? Response); diff --git a/tests/CommunityToolkit.Aspire.Hosting.Squad.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Squad.Tests/AppHostTests.cs new file mode 100644 index 000000000..654392850 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Squad.Tests/AppHostTests.cs @@ -0,0 +1,65 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Squad.Tests; + +public class AppHostTests(AspireIntegrationTestFixture fixture) + : IClassFixture> +{ + [Fact] + public async Task SquadSampleAppHost_StartsAndServesMetadata() + { + const string resourceName = "squad-api"; + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(resourceName) + .WaitAsync(TimeSpan.FromMinutes(5)); + + var model = fixture.App.Services.GetRequiredService(); + var apiApp = model.Resources + .OfType() + .Single(resource => resource.Name == resourceName); + + using HttpClient httpClient = new() + { + BaseAddress = new Uri(apiApp.GetEndpoint("http").Url) + }; + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(2)); + + HttpResponseMessage? response = null; + while (!cts.IsCancellationRequested) + { + try + { + response = await httpClient.GetAsync("/", cts.Token); + if (response.IsSuccessStatusCode) + { + break; + } + + response.Dispose(); + response = null; + } + catch (HttpRequestException) when (!cts.IsCancellationRequested) + { + } + catch (TaskCanceledException) when (!cts.IsCancellationRequested) + { + } + + await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); + } + + Assert.NotNull(response); + using (response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("research-squad", body, StringComparison.Ordinal); + Assert.Contains("dev-squad", body, StringComparison.Ordinal); + Assert.Contains("/ask", body, StringComparison.Ordinal); + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Squad.Tests/CommunityToolkit.Aspire.Hosting.Squad.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Squad.Tests/CommunityToolkit.Aspire.Hosting.Squad.Tests.csproj index dc02b8d2a..cc0828641 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Squad.Tests/CommunityToolkit.Aspire.Hosting.Squad.Tests.csproj +++ b/tests/CommunityToolkit.Aspire.Hosting.Squad.Tests/CommunityToolkit.Aspire.Hosting.Squad.Tests.csproj @@ -1,6 +1,7 @@ +