- Each run's first text delta replaces the response artifact (append:false) and a resumed task drops the previous run's response, so a failed/canceled continuation never keeps old text. - Resumed is built from a fresh store read, history chronological (question, then answer), timestamps on the TaskUpdater clock. - Pool.Track refuses a second unfinished run of the same task; the pre-check rejects continuations of a running task with UnsupportedOperation. - Tests: continuation history, streaming continuation, response replacement, concurrent continuation; busy test waits for the model call; TimeoutException in waits; push test matches TASK_STATE_COMPLETED; harness cleans up on failed start.
221 lines
12 KiB
C#
221 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");
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|