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 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 Status_SettledTask_EndsItsWatch_SoTheResultIsNotDeliveredTwice() { await using var remote = await RemoteAgentServer.StartAsync(); remote.Faux.Enqueue(FauxTurn.Say("Answer by status").WithDelay(TimeSpan.FromMilliseconds(300))); RemoteRewriter? rewriter = null; // The watch's stream stays silent (a dead connection): only status can see the task finish. await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, WaitSeconds = 0 }, inner => rewriter = new RemoteRewriter(inner) { SilentSubscribes = true }); 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)); string status; do { await Task.Delay(100); (status, _) = await host.CallToolAsync(session, new { action = "status", agent = "remote", taskId }); } while (!status.Contains("Answer by status")); 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_BusyContext_SaysWhichTaskRunsAndThatItIsStillWatched() { await using var remote = await RemoteAgentServer.StartAsync(); remote.Faux.Enqueue(FauxTurn.Say("first").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 = "long job" }); var taskId = Field(started, "task"); var contextId = Field(started, "context"); var (refused, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "and another", contextId }); Assert.True(isError, refused); Assert.Contains($"still working on task {taskId} in context {contextId}", refused); Assert.Contains("still watched", refused); Assert.Contains("without contextId", refused); var watch = Assert.Single(host.Registry.List(A2AClientHost.Target)); // the refusal leaves the running task's watch alone Assert.Equal(taskId, watch.TaskId); } [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); } }