File parts were only taken from artifacts as inline bytes up to 10 MB; parts in the status message or the history were dropped silently, URL parts were never downloaded, and a plain message reply lost its files. Files now go to inbox/a2a/<taskId>/ from artifacts, status message and recent agent messages (deduplicated), URLs are streamed to disk (agent key only on the agent's origin, else https without it), the cap is the new MaxReceivedFileBytes (200 MB), and results list path, bytes and media type or why a file was not saved. Re-saving a task overwrites, so action=status refetches files.
352 lines
19 KiB
C#
352 lines
19 KiB
C#
using Volcanic.AgenticCore.Extensions.A2A;
|
|
using Volcanic.AgenticCore.Extensions.Tests.A2A.Support;
|
|
using Volcanic.AgenticCore.Testing;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
|
|
|
|
public class A2AToolTests
|
|
{
|
|
private static string Field(string text, string label) =>
|
|
System.Text.RegularExpressions.Regex.Match(text, label + @" ([A-Za-z0-9_-]+)").Groups[1].Value;
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 15_000)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
|
while (!condition())
|
|
{
|
|
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not met");
|
|
await Task.Delay(20);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Agents_ListsCardsAndSkills()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
File.WriteAllText(Path.Combine(remote.Workspace, ".volcanic", "a2a", "card.json"),
|
|
"""{ "name": "Remote travel", "description": "Plans trips", "skills": [ { "id": "trips", "name": "Trips", "description": "Plans trips", "tags": ["travel"], "examples": ["Find a flight"] } ] }""");
|
|
await using var host = new A2AClientHost(remote);
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "agents" });
|
|
Assert.False(isError);
|
|
Assert.Contains("remote", text);
|
|
Assert.Contains("Plans trips", text);
|
|
Assert.Contains("trips", text);
|
|
Assert.Contains("Find a flight", text);
|
|
Assert.DoesNotContain(RemoteAgentServer.Key, text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Agents_UnreachableAgent_Listed()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, CardTimeoutSeconds = 2 });
|
|
host.WriteAgents("""[ { "name": "ghost", "url": "http://nowhere.invalid/missing/card.json", "key": "ghost-key-0123456789" } ]""");
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "agents" });
|
|
Assert.False(isError);
|
|
Assert.Contains("ghost", text);
|
|
Assert.Contains("unreachable", text);
|
|
Assert.DoesNotContain("ghost-key-0123456789", text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_CompletesWithinWait_ReturnsAnswerAndIds()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
remote.Faux.Enqueue(FauxTurn.Say("Paris is lovely"));
|
|
await using var host = new A2AClientHost(remote);
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "Where should I go?" });
|
|
Assert.False(isError);
|
|
Assert.Contains("completed", text);
|
|
Assert.Contains("Paris is lovely", text);
|
|
Assert.NotEmpty(Field(text, "task"));
|
|
Assert.NotEmpty(Field(text, "context"));
|
|
Assert.Empty(host.Registry.List(A2AClientHost.Target));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_ContinuesContext()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
remote.Faux.Enqueue(FauxTurn.Say("first"), FauxTurn.Say("second"));
|
|
await using var host = new A2AClientHost(remote);
|
|
var session = await host.CreateSessionAsync();
|
|
var (first, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "one" });
|
|
var (second, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "two", contextId = Field(first, "context") });
|
|
Assert.Contains("second", second);
|
|
Assert.Equal(Field(first, "context"), Field(second, "context"));
|
|
Assert.Contains("first", string.Join("\n", remote.Faux.Requests.Last().Messages.Select(m => m.Text)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_InputRequired_ReturnsQuestion_AndAnswerContinues()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
remote.Faux.Enqueue(FauxTurn.Tool("request_input", new { question = "Which date?" }), FauxTurn.Say("Booked Friday"));
|
|
await using var host = new A2AClientHost(remote);
|
|
var session = await host.CreateSessionAsync();
|
|
var (question, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "Book a table" });
|
|
Assert.Contains("input-required", question);
|
|
Assert.Contains("Which date?", question);
|
|
var (answer, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "Friday", taskId = Field(question, "task") });
|
|
Assert.Contains("completed", answer);
|
|
Assert.Contains("Booked Friday", answer);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_SlowTask_StartsWatch_AndResultArrivesAsFollowUp()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
remote.Faux.Enqueue(FauxTurn.Say("Slow answer").WithDelay(TimeSpan.FromMilliseconds(1500)));
|
|
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, WaitSeconds = 0 });
|
|
var session = await host.CreateSessionAsync();
|
|
// The follow-up run gets the faux default answer.
|
|
var (text, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "take your time" });
|
|
Assert.True(text.Contains("working") || text.Contains("submitted"), text);
|
|
Assert.Contains("delivered", text);
|
|
Assert.Single(host.Registry.List(A2AClientHost.Target));
|
|
var delivered = await host.WaitForUserMessageAsync(session, "[A2A result from \"remote\"");
|
|
Assert.Contains("Slow answer", delivered);
|
|
Assert.Contains(host.Faux.Requests, r => r.Messages.Any(m => m.Text.Contains("Slow answer")));
|
|
Assert.Empty(host.Registry.List(A2AClientHost.Target));
|
|
await WaitUntilAsync(() => PendingFile.Read(host.Workspace).Count == 0); // removed right after the hand-over
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SessionStart_FlushesUndeliveredResult_AsFollowUp()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote);
|
|
PendingFile.Upsert(host.Workspace, new WatchRecord("w1", A2AClientHost.Target, "remote", "t1", "c1", null, DateTimeOffset.UtcNow,
|
|
WatchSources.Poll, "[A2A result from \"remote\", task t1 (completed)]\nKept for later"));
|
|
host.Faux.Enqueue(FauxTurn.Say("got it"));
|
|
var session = await host.CreateSessionAsync(); // returns before the flush's run (it runs off the creation path)
|
|
Assert.DoesNotContain(session.Messages, m => m.Text.Contains("Kept for later")); // not inside creation
|
|
// Read from the transcript, not the host's event subscription, so an early run can't be missed.
|
|
await WaitUntilAsync(() => session.Messages.Any(m => m is Abstractions.Messages.UserMessage && m.Text.Contains("Kept for later")));
|
|
var delivered = session.Messages.First(m => m.Text.Contains("Kept for later")).Text;
|
|
Assert.StartsWith("[A2A result from \"remote\"", delivered);
|
|
await WaitUntilAsync(() => PendingFile.Read(host.Workspace).Count == 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_FilesArgument_SentAsParts_AndFileArtifactSaved()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
File.WriteAllText(Path.Combine(remote.Workspace, "reply.txt"), "remote file");
|
|
remote.Faux.Enqueue(FauxTurn.Tool("send", new { path = "reply.txt" }), FauxTurn.Say("sent it"));
|
|
await using var host = new A2AClientHost(remote);
|
|
File.WriteAllText(Path.Combine(host.Workspace, "notes.txt"), "my notes");
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "here", files = new[] { "notes.txt" } });
|
|
Assert.False(isError, text);
|
|
Assert.Contains(remote.Faux.Requests.First().Messages, m => m.Text.Contains("Files: inbox/a2a-client-"));
|
|
var taskId = Field(text, "task");
|
|
Assert.Contains($"inbox/a2a/{taskId}/reply.txt (11 bytes", text);
|
|
Assert.Equal("remote file", File.ReadAllText(Path.Combine(host.Workspace, "inbox", "a2a", taskId, "reply.txt")));
|
|
var inbound = Assert.Single(Directory.GetFiles(Path.Combine(remote.Workspace, "inbox"), "a2a-client-*"));
|
|
Assert.Equal("my notes", File.ReadAllText(inbound));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_FileOutsideWorkspace_Error()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote);
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "x", files = new[] { "../../etc/passwd" } });
|
|
Assert.True(isError);
|
|
Assert.Contains("workspace", text);
|
|
Assert.Empty(remote.Faux.Requests);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public async Task Send_SymlinkOutsideWorkspace_Error(bool directoryLink)
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote);
|
|
var outside = Path.Combine(Path.GetTempPath(), "a2a-outside", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(outside);
|
|
try
|
|
{
|
|
File.WriteAllText(Path.Combine(outside, "secret.txt"), "secret");
|
|
string path;
|
|
if (directoryLink)
|
|
{
|
|
Directory.CreateSymbolicLink(Path.Combine(host.Workspace, "linked"), outside);
|
|
path = "linked/secret.txt";
|
|
}
|
|
else
|
|
{
|
|
File.CreateSymbolicLink(Path.Combine(host.Workspace, "secret.txt"), Path.Combine(outside, "secret.txt"));
|
|
path = "secret.txt";
|
|
}
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "x", files = new[] { path } });
|
|
Assert.True(isError);
|
|
Assert.Contains("outside the workspace", text);
|
|
Assert.Empty(remote.Faux.Requests);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(outside, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_UnknownAgent_Error()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote);
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "nobody", message = "hi" });
|
|
Assert.True(isError);
|
|
Assert.Contains("nobody", text);
|
|
Assert.Contains("remote", text); // lists the configured ones
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_401_ReadableError_AndResultNeverContainsKey()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote);
|
|
host.WriteAgents($$"""[ { "name": "remote", "url": "{{RemoteAgentServer.BaseUrl}}", "key": "wrong-key-abcdefghij" } ]""");
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "hi" });
|
|
Assert.True(isError);
|
|
Assert.Contains("rejected", text);
|
|
Assert.DoesNotContain("wrong-key-abcdefghij", text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Status_ReturnsTask_AndCancel_CancelsRemoteAndDropsWatch()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
remote.Faux.Enqueue(FauxTurn.Say("slow").WithDelay(TimeSpan.FromSeconds(10)));
|
|
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, WaitSeconds = 0 });
|
|
var session = await host.CreateSessionAsync();
|
|
var (started, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "go" });
|
|
var taskId = Field(started, "task");
|
|
Assert.Single(host.Registry.List(A2AClientHost.Target));
|
|
var (status, _) = await host.CallToolAsync(session, new { action = "status", agent = "remote", taskId });
|
|
Assert.True(status.Contains("working") || status.Contains("submitted"), status);
|
|
var (cancelled, isError) = await host.CallToolAsync(session, new { action = "cancel", agent = "remote", taskId });
|
|
Assert.False(isError, cancelled);
|
|
Assert.Contains("canceled", cancelled);
|
|
Assert.Empty(host.Registry.List(A2AClientHost.Target));
|
|
Assert.Empty(PendingFile.Read(host.Workspace));
|
|
Assert.DoesNotContain(host.UserTexts(), t => t.Contains("[A2A result", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_AbortedWhileWaiting_TaskWatched_AndResultArrives()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
remote.Faux.Enqueue(FauxTurn.Say("Late answer").WithDelay(TimeSpan.FromMilliseconds(1500)));
|
|
RemoteRewriter? rewriter = null;
|
|
await using var host = new A2AClientHost(remote, handler: inner => rewriter = new RemoteRewriter(inner));
|
|
var session = await host.CreateSessionAsync();
|
|
host.Faux.Enqueue(FauxTurn.Tool("a2a", new { action = "send", agent = "remote", message = "take your time" }), FauxTurn.Say("ok"));
|
|
var run = session.PromptAsync("use the remote agent");
|
|
await WaitUntilAsync(() => rewriter!.Subscribes > 0); // the task exists and the tool waits on its stream
|
|
await session.AbortAsync();
|
|
try { await run; } catch (OperationCanceledException) { }
|
|
Assert.Single(host.Registry.List(A2AClientHost.Target));
|
|
var delivered = await host.WaitForUserMessageAsync(session, "[A2A result from \"remote\"");
|
|
Assert.Contains("Late answer", delivered);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_NonStreamingAgent_PollsWithinWait()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
remote.Faux.Enqueue(FauxTurn.Say("Polled answer").WithDelay(TimeSpan.FromMilliseconds(1200)));
|
|
RemoteRewriter? rewriter = null;
|
|
await using var host = new A2AClientHost(remote, handler: inner => rewriter = new RemoteRewriter(inner) { NoStreaming = true });
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "go" });
|
|
Assert.False(isError, text);
|
|
Assert.Contains("completed", text);
|
|
Assert.Contains("Polled answer", text);
|
|
Assert.Equal(0, rewriter!.Subscribes);
|
|
Assert.Empty(host.Registry.List(A2AClientHost.Target));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_MessageReply_ReturnedAsText()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote, handler: inner => new RemoteRewriter(inner) { MessageReply = "Just a message" });
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "hi" });
|
|
Assert.False(isError, text);
|
|
Assert.Contains("replied (context ctx-message)", text);
|
|
Assert.Contains("Just a message", text);
|
|
Assert.Empty(host.Registry.List(A2AClientHost.Target));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_FileOverMaxBytes_Error()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, MaxFileBytes = 10 });
|
|
File.WriteAllText(Path.Combine(host.Workspace, "big.txt"), new string('x', 11));
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "x", files = new[] { "big.txt" } });
|
|
Assert.True(isError);
|
|
Assert.Contains("larger than 10 bytes", text);
|
|
Assert.Empty(remote.Faux.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_FilesTogetherOverMaxBytes_Error()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, MaxFileBytes = 10 });
|
|
File.WriteAllText(Path.Combine(host.Workspace, "a.txt"), new string('a', 6));
|
|
File.WriteAllText(Path.Combine(host.Workspace, "b.txt"), new string('b', 6));
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "x", files = new[] { "a.txt", "b.txt" } });
|
|
Assert.True(isError);
|
|
Assert.Contains("together are larger than 10 bytes", text);
|
|
Assert.Empty(remote.Faux.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancel_RemoteDown_KeepsWatching_AndResultStillArrives()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
remote.Faux.Enqueue(FauxTurn.Say("Survived").WithDelay(TimeSpan.FromMilliseconds(1500)));
|
|
RemoteRewriter? rewriter = null;
|
|
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, WaitSeconds = 0 }, inner => rewriter = new RemoteRewriter(inner));
|
|
var session = await host.CreateSessionAsync();
|
|
var (started, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "go" });
|
|
var watch = Assert.Single(host.Registry.List(A2AClientHost.Target));
|
|
rewriter!.Down = true;
|
|
var (cancelled, isError) = await host.CallToolAsync(session, new { action = "cancel", agent = "remote", taskId = Field(started, "task") });
|
|
Assert.True(isError);
|
|
Assert.Contains("still watched", cancelled);
|
|
Assert.Equal(watch.WatchId, Assert.Single(host.Registry.List(A2AClientHost.Target)).WatchId);
|
|
Assert.Contains(PendingFile.Read(host.Workspace), r => r.WatchId == watch.WatchId);
|
|
rewriter.Down = false;
|
|
var delivered = await host.WaitForUserMessageAsync(session, "[A2A result from \"remote\"");
|
|
Assert.Contains("Survived", delivered);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoAgentsConfigured_ExplainsFile()
|
|
{
|
|
await using var remote = await RemoteAgentServer.StartAsync();
|
|
await using var host = new A2AClientHost(remote);
|
|
File.Delete(AgentsFile.PathFor(host.Workspace));
|
|
var session = await host.CreateSessionAsync();
|
|
var (text, isError) = await host.CallToolAsync(session, new { action = "agents" });
|
|
Assert.False(isError);
|
|
Assert.Contains(".volcanic/a2a/agents.json", text);
|
|
}
|
|
}
|