test(a2a): end-to-end server flows through the SDK client
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
using System.Net;
|
||||
using A2A;
|
||||
using Volcanic.AgenticCore.A2A.Server.Tests.Support;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.A2A.Server.Tests;
|
||||
|
||||
public class ServerEndToEndTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Auth_UnknownKey_Returns401()
|
||||
{
|
||||
await using var host = await A2ATestServer.StartAsync();
|
||||
var response = await host.Http("nope").PostAsync("/a2a", new StringContent("{}", System.Text.Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await host.Http(null).GetAsync("/a2a/v1/tasks")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auth_KeyInTwoWorkspaces_Returns401()
|
||||
{
|
||||
await using var host = await A2ATestServer.StartAsync();
|
||||
host.WriteCallers(host.OtherWorkspace, $$"""[ { "name": "dup", "key": "{{A2ATestServer.Key}}" } ]""");
|
||||
var response = await host.Http().GetAsync("/a2a/v1/tasks");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Card_Public_ServedWithoutKey()
|
||||
{
|
||||
await using var host = await A2ATestServer.StartAsync();
|
||||
var json = await host.Http(null).GetStringAsync("/.well-known/agent-card.json");
|
||||
Assert.Contains("Volcanic assistant", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_Completes_ResponseArtifactHasFinalAnswer()
|
||||
{
|
||||
await using var host = await A2ATestServer.StartAsync();
|
||||
host.Faux.Enqueue(FauxTurn.Say("Hello from the assistant"));
|
||||
var response = await host.Client().SendMessageAsync(Requests.Text("hi"), CancellationToken.None);
|
||||
var task = response.Task!;
|
||||
Assert.Equal(TaskState.Completed, task.Status.State);
|
||||
Assert.Equal("Hello from the assistant", Requests.ResponseText(task));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prompt_HasFramingHeader_AndSession_UsesWorkspaceProjectContext()
|
||||
{
|
||||
await using var host = await A2ATestServer.StartAsync();
|
||||
File.WriteAllText(Path.Combine(host.Workspace, "PERSONA.md"), "I am the persona of workspace one.");
|
||||
File.WriteAllText(Path.Combine(host.Workspace, "MEMORY.md"), "Remember: the owner likes tea.");
|
||||
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
||||
await host.Client().SendMessageAsync(Requests.Text("hi"), CancellationToken.None);
|
||||
var request = host.Faux.Requests.Last();
|
||||
Assert.Contains("I am the persona of workspace one.", request.SystemPrompt);
|
||||
Assert.Contains("the owner likes tea", request.SystemPrompt);
|
||||
Assert.Contains("[A2A message from \"alpha\"", request.Messages.Last().Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_ContinuesContext_SameSession()
|
||||
{
|
||||
await using var host = await A2ATestServer.StartAsync();
|
||||
host.Faux.Enqueue(FauxTurn.Say("first"), FauxTurn.Say("second"));
|
||||
var first = (await host.Client().SendMessageAsync(Requests.Text("one"), CancellationToken.None)).Task!;
|
||||
await host.Client().SendMessageAsync(Requests.Text("two", contextId: first.ContextId), CancellationToken.None);
|
||||
var transcript = string.Join("\n", host.Faux.Requests.Last().Messages.Select(m => m.Text));
|
||||
Assert.Contains("one", transcript);
|
||||
Assert.Contains("first", transcript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_ProviderError_TaskFailed()
|
||||
{
|
||||
await using var host = await A2ATestServer.StartAsync();
|
||||
host.Faux.Enqueue(FauxTurn.Error("model exploded"));
|
||||
var task = (await host.Client().SendMessageAsync(Requests.Text("hi"), CancellationToken.None)).Task!;
|
||||
Assert.Equal(TaskState.Failed, task.Status.State);
|
||||
Assert.Contains("model exploded", task.Status.Message!.Parts[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tasks_OtherCallersTask_NotFound()
|
||||
{
|
||||
await using var host = await A2ATestServer.StartAsync();
|
||||
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
||||
var task = (await host.Client().SendMessageAsync(Requests.Text("hi"), CancellationToken.None)).Task!;
|
||||
var otherWorkspace = await Assert.ThrowsAsync<A2AException>(() =>
|
||||
host.Client(A2ATestServer.OtherKey).GetTaskAsync(new GetTaskRequest { Id = task.Id }, CancellationToken.None));
|
||||
Assert.Equal(A2AErrorCode.TaskNotFound, otherWorkspace.ErrorCode);
|
||||
var sameWorkspaceOtherCaller = await Assert.ThrowsAsync<A2AException>(() =>
|
||||
host.Client("key-gamma").GetTaskAsync(new GetTaskRequest { Id = task.Id }, CancellationToken.None));
|
||||
Assert.Equal(A2AErrorCode.TaskNotFound, sameWorkspaceOtherCaller.ErrorCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Collections.Concurrent;
|
||||
using A2A;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Volcanic.AgenticCore.A2A.Server.Callers;
|
||||
using Volcanic.AgenticCore.A2A.Server.Hosting;
|
||||
using Volcanic.AgenticCore.A2A.Server.Push;
|
||||
using Volcanic.AgenticCore.Features.Agent;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.A2A.Server.Tests.Support;
|
||||
|
||||
/// <summary>An in-process A2A server over two workspaces with the FauxProvider, a recording notifier and push handler.</summary>
|
||||
public sealed class A2ATestServer : IAsyncDisposable
|
||||
{
|
||||
public const string Key = "key-alpha";
|
||||
public const string OtherKey = "key-beta";
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "a2a-e2e", Guid.NewGuid().ToString("N"));
|
||||
|
||||
public FauxProvider Faux { get; } = new();
|
||||
public string Workspace => Path.Combine(_root, "ws1");
|
||||
public string OtherWorkspace => Path.Combine(_root, "ws2");
|
||||
public RecordingNotifier Notifier { get; } = new();
|
||||
public RecordingHandler Push { get; } = new();
|
||||
public WebApplication App { get; private set; } = null!;
|
||||
|
||||
public static async Task<A2ATestServer> StartAsync(Action<A2AHostOptions>? configure = null, bool withNotifier = true)
|
||||
{
|
||||
var host = new A2ATestServer();
|
||||
Directory.CreateDirectory(host.Workspace);
|
||||
Directory.CreateDirectory(host.OtherWorkspace);
|
||||
host.WriteCallers(host.Workspace, $$"""[ { "name": "alpha", "key": "{{Key}}" }, { "name": "gamma", "key": "key-gamma" } ]""");
|
||||
host.WriteCallers(host.OtherWorkspace, $$"""[ { "name": "beta", "key": "{{OtherKey}}" } ]""");
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Services.AddAgenticCore(o =>
|
||||
{
|
||||
o.AgentDir = Path.Combine(host._root, "agent");
|
||||
o.DiscoverExtensionAssemblies = false;
|
||||
o.DefaultModel = FauxProvider.ModelKey;
|
||||
o.Extensions.Add(ExtensionRegistration.FromInstance(host.Faux.Extension));
|
||||
o.ProcessEnvironment.HardenHostProcess = false;
|
||||
o.Retry = new RetrySettings { Enabled = false }; // FauxTurn.Error fails the task at once
|
||||
});
|
||||
builder.Services.AddSingleton<A2APushSender>(sp => new HttpA2APushSender(new HttpClient(host.Push), TimeProvider.System, NullLogger<HttpA2APushSender>.Instance));
|
||||
if (withNotifier) builder.Services.AddSingleton<IA2AOwnerNotifier>(host.Notifier);
|
||||
builder.Services.AddSingleton<IA2ACallerDirectory>(new WorkspaceCallerDirectory(
|
||||
() => [new A2AWorkspace(host.Workspace, "test-ws1", "ws1"), new A2AWorkspace(host.OtherWorkspace, "test-ws2", "ws2")],
|
||||
NullLogger<WorkspaceCallerDirectory>.Instance));
|
||||
builder.Services.AddA2AServer(o =>
|
||||
{
|
||||
o.AllowPrivatePushUrls = true;
|
||||
configure?.Invoke(o);
|
||||
});
|
||||
host.App = builder.Build();
|
||||
host.App.MapA2AServer();
|
||||
await host.App.StartAsync();
|
||||
return host;
|
||||
}
|
||||
|
||||
public void WriteCallers(string workspace, string json)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(CallersFile.PathFor(workspace))!);
|
||||
File.WriteAllText(CallersFile.PathFor(workspace), json);
|
||||
}
|
||||
|
||||
public HttpClient Http(string? key = Key)
|
||||
{
|
||||
var http = App.GetTestClient();
|
||||
if (key is not null) http.DefaultRequestHeaders.Add("X-API-Key", key);
|
||||
return http;
|
||||
}
|
||||
|
||||
public A2AClient Client(string key = Key) => new(new Uri("http://localhost/a2a"), Http(key));
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await App.StopAsync();
|
||||
await App.DisposeAsync();
|
||||
try { Directory.Delete(_root, true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
public sealed class RecordingNotifier : IA2AOwnerNotifier
|
||||
{
|
||||
public ConcurrentQueue<(string Caller, string Text)> Sent { get; } = new();
|
||||
public Task NotifyAsync(A2ACaller caller, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
Sent.Enqueue((caller.Name, text));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public ConcurrentQueue<(Uri Url, string? Token, string Body)> Requests { get; } = new();
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = request.Headers.TryGetValues(A2APushReceiver.TokenHeader, out var v) ? v.Single() : null;
|
||||
Requests.Enqueue((request.RequestUri!, token, await request.Content!.ReadAsStringAsync(cancellationToken)));
|
||||
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
using A2A;
|
||||
|
||||
namespace Volcanic.AgenticCore.A2A.Server.Tests.Support;
|
||||
|
||||
public static class Requests
|
||||
{
|
||||
public static SendMessageRequest Text(string text, string? contextId = null, string? taskId = null, int? hops = null,
|
||||
bool returnImmediately = false, params Part[] extra) => new()
|
||||
{
|
||||
Message = new Message
|
||||
{
|
||||
Role = Role.User, MessageId = Guid.NewGuid().ToString("N"), ContextId = contextId, TaskId = taskId,
|
||||
Parts = [Part.FromText(text), .. extra],
|
||||
},
|
||||
Metadata = hops is null ? null : new Dictionary<string, JsonElement> { ["volcanic.hops"] = JsonSerializer.SerializeToElement(hops.Value) },
|
||||
Configuration = returnImmediately ? new SendMessageConfiguration { ReturnImmediately = true } : null,
|
||||
};
|
||||
|
||||
public static string ResponseText(AgentTask task) =>
|
||||
string.Concat(task.Artifacts?.Where(a => a.ArtifactId == "response").SelectMany(a => a.Parts).Select(p => p.Text) ?? []);
|
||||
|
||||
public static async Task<AgentTask> WaitForStateAsync(A2AClient client, string taskId, Func<TaskState, bool> done, int timeoutMs = 10_000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (true)
|
||||
{
|
||||
var task = await client.GetTaskAsync(new GetTaskRequest { Id = taskId }, CancellationToken.None);
|
||||
if (done(task.Status.State) || DateTime.UtcNow > deadline) return task;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user