Files
2026-10-07 14:31:35 +00:00

222 lines
12 KiB
C#

using System.Text;
using A2A;
using Volcanic.AgenticCore.A2A.Server.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.A2A.Server.Tests;
public class ServerTaskTests
{
private static async Task<List<StreamResponse>> CollectAsync(IAsyncEnumerable<StreamResponse> stream)
{
var list = new List<StreamResponse>();
await foreach (var e in stream) list.Add(e);
return list;
}
[Fact]
public async Task Stream_TextDeltas_AreArtifactAppends()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Say("A fairly long answer that the faux provider streams in several chunks."));
var events = await CollectAsync(host.Client().SendStreamingMessageAsync(Requests.Text("hi"), CancellationToken.None));
var appends = events.Where(e => e.ArtifactUpdate is { Append: true, Artifact.ArtifactId: "response" }).ToList();
Assert.True(appends.Count > 1);
Assert.Equal(TaskState.Completed, events.Last(e => e.StatusUpdate is not null).StatusUpdate!.Status.State);
}
[Fact]
public async Task Stream_ToolStart_IsWorkingStatus()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Tool("notify_owner", new { text = "hello owner" }), FauxTurn.Say("done"));
var events = await CollectAsync(host.Client().SendStreamingMessageAsync(Requests.Text("hi"), CancellationToken.None));
Assert.Contains(events, e => e.StatusUpdate is { Status.State: TaskState.Working } s && s.Status.Message?.Parts[0].Text == "using notify_owner");
}
[Fact]
public async Task NotifyOwner_CallsNotifier()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Tool("notify_owner", new { text = "hello owner" }), FauxTurn.Say("done"));
await host.Client().SendMessageAsync(Requests.Text("hi"), CancellationToken.None);
Assert.Equal(("alpha", "hello owner"), Assert.Single(host.Notifier.Sent));
}
[Fact]
public async Task NotifyOwner_AbsentWithoutNotifier()
{
await using var host = await A2ATestServer.StartAsync(withNotifier: false);
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.Client().SendMessageAsync(Requests.Text("hi"), CancellationToken.None);
Assert.DoesNotContain(host.Faux.Requests.Last().Tools, t => t.Name == "notify_owner");
Assert.Contains(host.Faux.Requests.Last().Tools, t => t.Name == "request_input");
Assert.DoesNotContain("notify_owner", host.Faux.Requests.Last().Messages.Last().Text);
}
[Fact]
public async Task Stream_SendTool_ProducesArtifact()
{
await using var host = await A2ATestServer.StartAsync();
File.WriteAllText(Path.Combine(host.Workspace, "report.txt"), "the report");
host.Faux.Enqueue(FauxTurn.Tool("send", new { path = "report.txt" }), FauxTurn.Say("sent"));
var task = (await host.Client().SendMessageAsync(Requests.Text("send me the report"), CancellationToken.None)).Task!;
var file = Assert.Single(task.Artifacts!, a => a.Name == "report.txt");
Assert.Equal("the report", Encoding.UTF8.GetString(file.Parts[0].Raw!));
}
[Fact]
public async Task RequestInput_SetsInputRequired_ContinuationResumesTask()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Tool("request_input", new { question = "Which date?" }), FauxTurn.Say("Booked for Friday"));
var client = host.Client();
var first = (await client.SendMessageAsync(Requests.Text("book a table"), CancellationToken.None)).Task!;
Assert.Equal(TaskState.InputRequired, first.Status.State);
Assert.Equal("Which date?", first.Status.Message!.Parts[0].Text);
var second = (await client.SendMessageAsync(Requests.Text("Friday", contextId: first.ContextId, taskId: first.Id), CancellationToken.None)).Task!;
Assert.Equal(first.Id, second.Id);
Assert.Equal(TaskState.Completed, second.Status.State);
Assert.Equal("Booked for Friday", Requests.ResponseText(second));
var stored = await client.GetTaskAsync(new GetTaskRequest { Id = first.Id }, CancellationToken.None);
var history = stored.History!.Select(m => string.Concat(m.Parts.Select(p => p.Text))).ToList();
Assert.Single(history, t => t == "Friday");
var question = history.IndexOf("Which date?");
Assert.True(question >= 0 && question < history.IndexOf("Friday"), string.Join(" | ", history));
}
[Fact]
public async Task RequestInput_StreamingContinuation_StartsWithWorkingTask()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Tool("request_input", new { question = "Which date?" }), FauxTurn.Say("Booked for Friday"));
var client = host.Client();
var first = (await client.SendMessageAsync(Requests.Text("book a table"), CancellationToken.None)).Task!;
var events = await CollectAsync(client.SendStreamingMessageAsync(
Requests.Text("Friday", contextId: first.ContextId, taskId: first.Id), CancellationToken.None));
var resumed = events[0].Task;
Assert.NotNull(resumed);
Assert.Equal(first.Id, resumed.Id);
Assert.Equal(TaskState.Working, resumed.Status.State);
Assert.Equal(TaskState.Completed, events.Last(e => e.StatusUpdate is not null).StatusUpdate!.Status.State);
}
[Fact]
public async Task RequestInput_FailedContinuation_DropsEarlierResponse()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Say("Let me check.").WithTool("request_input", new { question = "Which date?" }),
FauxTurn.Error("model exploded"));
var client = host.Client();
var first = (await client.SendMessageAsync(Requests.Text("book a table"), CancellationToken.None)).Task!;
Assert.Equal(TaskState.InputRequired, first.Status.State);
Assert.Equal("Let me check.", Requests.ResponseText(first));
var second = (await client.SendMessageAsync(Requests.Text("Friday", contextId: first.ContextId, taskId: first.Id), CancellationToken.None)).Task!;
Assert.Equal(TaskState.Failed, second.Status.State);
Assert.DoesNotContain("Let me check.", Requests.ResponseText(second));
}
[Fact]
public async Task RequestInput_ContinuationAnswer_ReplacesEarlierResponse()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Say("Let me check.").WithTool("request_input", new { question = "Which date?" }),
FauxTurn.Say("Booked for Friday"));
var client = host.Client();
var first = (await client.SendMessageAsync(Requests.Text("book a table"), CancellationToken.None)).Task!;
var events = await CollectAsync(client.SendStreamingMessageAsync(
Requests.Text("Friday", contextId: first.ContextId, taskId: first.Id), CancellationToken.None));
var chunks = events.Where(e => e.ArtifactUpdate is { Artifact.ArtifactId: "response" }).Select(e => e.ArtifactUpdate!).ToList();
Assert.False(chunks[0].Append); // the run's first chunk starts a fresh answer
var stored = await client.GetTaskAsync(new GetTaskRequest { Id = first.Id }, CancellationToken.None);
Assert.Equal("Booked for Friday", Requests.ResponseText(stored));
}
[Fact]
public async Task RequestInput_SecondContinuationWhileRunning_Rejected()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Tool("request_input", new { question = "Which date?" }), FauxTurn.Say("slow").WithDelay(TimeSpan.FromSeconds(5)));
var client = host.Client();
var first = (await client.SendMessageAsync(Requests.Text("book a table"), CancellationToken.None)).Task!;
await client.SendMessageAsync(Requests.Text("Friday", contextId: first.ContextId, taskId: first.Id, returnImmediately: true), CancellationToken.None);
await Requests.WaitUntilAsync(() => host.Faux.Requests.Count == 2, "the continuation's model call");
var ex = await Assert.ThrowsAsync<A2AException>(() =>
client.SendMessageAsync(Requests.Text("Saturday", contextId: first.ContextId, taskId: first.Id), CancellationToken.None));
Assert.Equal(A2AErrorCode.UnsupportedOperation, ex.ErrorCode);
await client.CancelTaskAsync(new CancelTaskRequest { Id = first.Id }, CancellationToken.None);
}
[Fact]
public async Task Send_ContextBusy_Rejected()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Say("first"), FauxTurn.Say("slow").WithDelay(TimeSpan.FromSeconds(5)));
var client = host.Client();
var first = (await client.SendMessageAsync(Requests.Text("one"), CancellationToken.None)).Task!;
var slow = (await client.SendMessageAsync(Requests.Text("two", contextId: first.ContextId, returnImmediately: true), CancellationToken.None)).Task!;
await Requests.WaitForStateAsync(client, slow.Id, s => s == TaskState.Working);
// Working is written before the run holds the context: wait until its model call has started.
await Requests.WaitUntilAsync(() => host.Faux.Requests.Count == 2, "the second model call");
var ex = await Assert.ThrowsAsync<A2AException>(() =>
client.SendMessageAsync(Requests.Text("three", contextId: first.ContextId), CancellationToken.None));
Assert.Equal(A2AErrorCode.UnsupportedOperation, ex.ErrorCode);
await client.CancelTaskAsync(new CancelTaskRequest { Id = slow.Id }, CancellationToken.None);
}
[Fact]
public async Task Cancel_AbortsRun()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Say("slow").WithDelay(TimeSpan.FromSeconds(10)));
var client = host.Client();
var task = (await client.SendMessageAsync(Requests.Text("go", returnImmediately: true), CancellationToken.None)).Task!;
await Requests.WaitForStateAsync(client, task.Id, s => s == TaskState.Working);
await client.CancelTaskAsync(new CancelTaskRequest { Id = task.Id }, CancellationToken.None);
var final = await Requests.WaitForStateAsync(client, task.Id, s => s.IsTerminal());
Assert.Equal(TaskState.Canceled, final.Status.State);
}
[Fact]
public async Task Subscribe_LiveTask_GetsEvents()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Say("eventually").WithDelay(TimeSpan.FromSeconds(2)));
var client = host.Client();
var task = (await client.SendMessageAsync(Requests.Text("go", returnImmediately: true), CancellationToken.None)).Task!;
var events = await CollectAsync(client.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = task.Id }, CancellationToken.None));
Assert.Contains(events, e => e.StatusUpdate?.Status.State == TaskState.Completed || e.Task?.Status.State == TaskState.Completed);
}
[Fact]
public async Task Subscribe_TerminalTask_Unsupported()
{
await using var host = await A2ATestServer.StartAsync();
host.Faux.Enqueue(FauxTurn.Say("done"));
var client = host.Client();
var task = (await client.SendMessageAsync(Requests.Text("go"), CancellationToken.None)).Task!;
var ex = await Assert.ThrowsAsync<A2AException>(() => CollectAsync(client.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = task.Id }, CancellationToken.None)));
Assert.Equal(A2AErrorCode.UnsupportedOperation, ex.ErrorCode);
}
[Fact]
public async Task Files_OverCap_InvalidParams()
{
await using var host = await A2ATestServer.StartAsync(o => o.MaxFileBytes = 4);
var big = Part.FromRaw(Encoding.UTF8.GetBytes("too large"), "text/plain", "a.txt");
var ex = await Assert.ThrowsAsync<A2AException>(() =>
host.Client().SendMessageAsync(Requests.Text("file", extra: big), CancellationToken.None));
Assert.Equal(A2AErrorCode.InvalidParams, ex.ErrorCode);
}
[Fact]
public async Task Hops_OverLimit_TaskFailed()
{
await using var host = await A2ATestServer.StartAsync();
var task = (await host.Client().SendMessageAsync(Requests.Text("hi", hops: 4), CancellationToken.None)).Task!;
Assert.Equal(TaskState.Failed, task.Status.State);
Assert.Contains("hop limit", task.Status.Message!.Parts[0].Text);
Assert.Empty(host.Faux.Requests);
}
}