From 3a79d9c0d708b034357503e973dc18a51b8f0526 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Wed, 27 May 2026 19:05:52 +0100 Subject: [PATCH 01/18] refactor(a2a): rewrite interop tests as explicit Ginkgo spec tree with thin probe adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the matrix-driven interop test infrastructure with an explicit When/Context/It spec tree where every test case is readable from source. Moves all assertion logic from language-specific probe binaries into the Go/Ginkgo test layer, making language probes thin protocol adapters only. Changes: - Add probeClient interface and goProbeClient wrapping the Go A2A SDK - Add externalProbeClient that invokes Rust/Python/.NET probes as subprocesses and deserializes raw JSON responses; errors are parsed from stderr JSON - Add fixtureServer abstraction (start/stop/targetFor) used per When block - Rewrite interop_behaviors_test.go: replace registerInteropTransportMatrix and the interopHarness with registerBehaviors containing an explicit When/Context/It tree covering send-message, streaming, task lifecycle, push config, and all scenario-parity behaviors - Rewrite all 10 suite wrapper files with explicit When blocks and newGoServer/newRustServer/newPythonServer/newDotNetServer factory functions - Delete native_go_harness_test.go (logic moved to registerBehaviors) - Rewrite Rust probe: replace ~1200-line scenario dispatch + assertions with ~285-line subcommand CLI (send-message, send-streaming-message, get-task, cancel-task, list-tasks, create/get/list/delete-push-config, get-extended-card) - Rewrite Python probe: same subcommand CLI refactor (~310 lines) - Rewrite .NET probe: same subcommand CLI refactor (~420 lines) All probes now: write raw JSON to stdout on success; write {"code":…,"message":"…"} to stderr and exit 1 on A2A error. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../fixtures/dotnet/InteropProbe/Program.cs | 1258 ++++----------- .../fixtures/python/interop_python_probe.py | 1137 ++++---------- .../rust/src/bin/interop-rust-probe.rs | 1388 ++++------------- .../tests/interop_behaviors_test.go | 880 +++++++---- .../agntcy-a2a/tests/interop_client_test.go | 107 ++ .../tests/interop_go_dotnet_test.go | 151 +- .../tests/interop_launchers_test.go | 154 +- .../tests/interop_probe_clients_test.go | 335 ++++ .../tests/interop_python_dotnet_test.go | 162 +- .../tests/interop_python_go_test.go | 170 +- .../tests/interop_rust_dotnet_test.go | 168 +- .../agntcy-a2a/tests/interop_rust_go_test.go | 127 +- .../tests/interop_rust_python_test.go | 175 ++- .../tests/interop_self_dotnet_test.go | 61 +- .../agntcy-a2a/tests/interop_self_go_test.go | 53 +- .../tests/interop_self_python_test.go | 61 +- .../tests/interop_self_rust_test.go | 60 +- .../agntcy-a2a/tests/interop_shared_test.go | 87 +- .../tests/native_go_harness_test.go | 569 ------- .../tests/native_go_launchers_test.go | 20 + 20 files changed, 2649 insertions(+), 4474 deletions(-) create mode 100644 integrations/agntcy-a2a/tests/interop_client_test.go create mode 100644 integrations/agntcy-a2a/tests/interop_probe_clients_test.go delete mode 100644 integrations/agntcy-a2a/tests/native_go_harness_test.go diff --git a/integrations/agntcy-a2a/fixtures/dotnet/InteropProbe/Program.cs b/integrations/agntcy-a2a/fixtures/dotnet/InteropProbe/Program.cs index 5bfa5b57..0328127c 100644 --- a/integrations/agntcy-a2a/fixtures/dotnet/InteropProbe/Program.cs +++ b/integrations/agntcy-a2a/fixtures/dotnet/InteropProbe/Program.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Text; using System.Net; using System.Net.Http.Headers; @@ -12,503 +13,334 @@ namespace InteropProbe; -// This probe is the .NET harness implementation behind the shared Go/Ginkgo interop behaviors. -// It connects to a fixture card URL, executes one behavior scenario at a time, and exits non-zero -// on any interoperability mismatch. -// When adding a new shared behavior, mirror the scenario here if the behavior should be runnable -// independently, then keep its assertions aligned with interop_behaviors_test.go. +// Thin A2A probe adapter: executes a single A2A operation and writes the raw +// JSON result to stdout. On A2A error, writes {"code":…,"message":"…"} to +// stderr and exits 1. No assertions or orchestration live here — all test +// logic is in the Go/Ginkgo spec tree. internal static class Program { - private const string RequestText = "ping"; - private const string PendingRequestText = "pending"; - private const string MessageOnlyRequestText = "message-only"; - private const string TaskFailureRequestText = "task-failure"; - private const string MultiTurnStartRequestText = "multi-turn start"; - private const string MultiTurnContinueRequestText = "multi-turn continue"; - private const string StreamingRequestText = "streaming"; - private const string LongRunningRequestText = "long-running"; - private const string DataTypesRequestText = "data-types"; - private const string RequestDataKind = "structured"; - private const string RequestDataScope = "interop"; - private const string RequestMetadataKey = "csit"; - private const string RequestMetadataValue = "multipart"; - private const string ExtendedCardSchemeId = "bearer_token"; - private static readonly string[] ExpectedSkillIds = - [ - "message-only", - "task-lifecycle", - "task-failure", - "task-cancel", - "multi-turn", - "streaming", - "long-running", - "data-types", - ]; - public static async Task Main(string[] args) { - ProbeOptions options; - + ParsedArgs parsed; try { - options = ProbeOptions.Parse(args); + parsed = ParseArgs(args); } catch (Exception error) { - Console.Error.WriteLine(error); - return 2; + Fail(-32600, error.Message); + return 1; } try { - await RunAsync(options); + var resolver = new A2ACardResolver(new Uri(parsed.CardUrl)); + var card = await resolver.GetAgentCardAsync().ConfigureAwait(false); + var client = CreateClient(card); + await RunSubcommandAsync(parsed, card, client).ConfigureAwait(false); return 0; } + catch (A2AException error) + { + Fail((int)error.ErrorCode, error.Message); + return 1; + } catch (Exception error) { - Console.Error.WriteLine(error); + Fail(-32000, error.Message); return 1; } } - private static async Task RunAsync(ProbeOptions options) + private static void Fail(int code, string message) { - var resolver = new A2ACardResolver(new Uri(options.CardUrl)); - var card = await resolver.GetAgentCardAsync().ConfigureAwait(false); - var client = CreateClient(card); - - var expectedPingText = ExpectedResponseText(options.ServerPrefix, RequestText); - var expectedPendingText = ExpectedResponseText(options.ServerPrefix, PendingRequestText); - var expectedCancelText = ExpectedCancelText(options.ServerPrefix); - var expectedMessageOnlyText = ExpectedScenarioText(options.ServerPrefix, "message-only response"); - var expectedFailedText = ExpectedScenarioText(options.ServerPrefix, "failed task"); - var expectedInputRequiredText = ExpectedScenarioText(options.ServerPrefix, "needs more input"); - var expectedMultiTurnCompleteText = ExpectedScenarioText(options.ServerPrefix, "multi-turn completed"); - var expectedStreamingStartText = ExpectedScenarioText(options.ServerPrefix, "streaming started"); - var expectedStreamingCompleteText = ExpectedScenarioText(options.ServerPrefix, "streaming complete"); - var expectedLongRunningStartText = ExpectedScenarioText(options.ServerPrefix, "long-running started"); - var expectedLongRunningCompleteText = ExpectedScenarioText(options.ServerPrefix, "long-running complete"); - var expectedDataTypesText = ExpectedScenarioText(options.ServerPrefix, "data-types ready"); - - if (options.Scenario.RunsUnaryStreaming()) - { - var request = BuildRequest(RequestText, false); + Console.Error.WriteLine(JsonSerializer.Serialize(new { code, message })); + } - var completedTask = TaskFromResponse( - await SendMessageAsync(client, card, request, false).ConfigureAwait(false), - "unary"); - AssertState(completedTask.Status.State, TaskState.Completed, "unary"); - AssertText(TaskText(completedTask), expectedPingText, "unary"); - AssertTaskHistory(completedTask, RequestText, "unary"); + // ── argument parsing ──────────────────────────────────────────────────── - var streamingText = await ReadStreamingTextAsync(SendStreamingMessageAsync(client, card, request)).ConfigureAwait(false); - AssertText(streamingText, expectedPingText, "streaming"); - } + private record ParsedArgs(string CardUrl, string Subcommand, string[] Rest); - if (options.Scenario.RunsTaskLifecycle()) + private static ParsedArgs ParseArgs(string[] args) + { + string? cardUrl = null; + var i = 0; + while (i < args.Length) { - var completedTask = TaskFromResponse( - await SendMessageAsync(client, card, BuildRequest(RequestText, false), false).ConfigureAwait(false), - "task lifecycle setup"); - AssertState(completedTask.Status.State, TaskState.Completed, "task lifecycle setup"); - AssertText(TaskText(completedTask), expectedPingText, "task lifecycle setup"); - AssertTaskHistory(completedTask, RequestText, "task lifecycle setup"); - - var fetchedTask = await GetTaskAsync(client, card, new GetTaskRequest + if (args[i] == "--card-url") { - Id = completedTask.Id, - HistoryLength = 1, - }).ConfigureAwait(false); - AssertState(fetchedTask.Status.State, TaskState.Completed, "get_task"); - AssertText(TaskText(fetchedTask), expectedPingText, "get_task"); - AssertTaskHistory(fetchedTask, RequestText, "get_task"); - - var listedTasks = await ListTasksAsync(client, card, new ListTasksRequest - { - ContextId = completedTask.ContextId, - }).ConfigureAwait(false); - if (!listedTasks.Any(task => task.Id == completedTask.Id)) - { - throw new InvalidOperationException($"list_tasks did not include expected task {completedTask.Id}"); + i++; + if (i >= args.Length) throw new ArgumentException("--card-url requires a value"); + cardUrl = args[i++]; } + else break; + } + if (cardUrl is null) throw new ArgumentException("missing --card-url"); + if (i >= args.Length) throw new ArgumentException("missing subcommand"); + var subcommand = args[i++]; + return new ParsedArgs(cardUrl, subcommand, args[i..]); + } - var pendingTask = TaskFromResponse( - await SendMessageAsync(client, card, BuildRequest(PendingRequestText, true), true).ConfigureAwait(false), - "pending unary"); - AssertState(pendingTask.Status.State, TaskState.Working, "pending unary"); - AssertText(TaskText(pendingTask), expectedPendingText, "pending unary"); - - var canceledTask = await CancelTaskAsync(client, card, new CancelTaskRequest - { - Id = pendingTask.Id, - }).ConfigureAwait(false); - AssertState(canceledTask.Status.State, TaskState.Canceled, "cancel_task"); - AssertText(TaskText(canceledTask), expectedCancelText, "cancel_task"); + private static string RequireFlag(string[] args, string flag) + { + for (var i = 0; i < args.Length - 1; i++) + { + if (args[i] == flag) return args[i + 1]; + } + throw new ArgumentException($"missing {flag}"); + } - var fetchedCanceledTask = await GetTaskAsync(client, card, new GetTaskRequest - { - Id = pendingTask.Id, - }).ConfigureAwait(false); - AssertState(fetchedCanceledTask.Status.State, TaskState.Canceled, "get_task after cancel"); - AssertText(TaskText(fetchedCanceledTask), expectedCancelText, "get_task after cancel"); + // ── subcommand dispatch ───────────────────────────────────────────────── - if (options.RelaxedErrorChecks) - { - await ExpectFailureAsync(() => GetTaskAsync(client, card, new GetTaskRequest { Id = Guid.NewGuid().ToString("N") }), "get missing task").ConfigureAwait(false); - await ExpectFailureAsync(() => CancelTaskAsync(client, card, new CancelTaskRequest { Id = completedTask.Id }), "cancel completed task").ConfigureAwait(false); - } - else + private static async Task RunSubcommandAsync(ParsedArgs parsed, AgentCard card, IA2AClient? client) + { + switch (parsed.Subcommand) + { + case "send-message": { - await ExpectA2AErrorAsync(() => GetTaskAsync(client, card, new GetTaskRequest { Id = Guid.NewGuid().ToString("N") }), A2AErrorCode.TaskNotFound, "get missing task").ConfigureAwait(false); - await ExpectA2AErrorAsync(() => CancelTaskAsync(client, card, new CancelTaskRequest { Id = completedTask.Id }), A2AErrorCode.TaskNotCancelable, "cancel completed task").ConfigureAwait(false); + var msgJson = RequireFlag(parsed.Rest, "--message-json"); + var reqNode = JsonNode.Parse(msgJson) + ?? throw new InvalidOperationException("failed to parse send-message request"); + var response = await SendMessageAsync(client, card, reqNode).ConfigureAwait(false); + PrintMessageResponse(response); + return; } - } - - if (options.Scenario.RunsPushConfig()) - { - var completedTask = TaskFromResponse( - await SendMessageAsync(client, card, BuildRequest(RequestText, false), false).ConfigureAwait(false), - "push-config setup"); - AssertState(completedTask.Status.State, TaskState.Completed, "push-config setup"); - AssertText(TaskText(completedTask), expectedPingText, "push-config setup"); - AssertTaskHistory(completedTask, RequestText, "push-config setup"); - - if (options.ExpectPushUnsupported) + case "send-streaming-message": { - var pushConfig = new PushNotificationConfig - { - Id = "interop-config", - Url = "https://example.invalid/webhook", - }; - - if (options.RelaxedErrorChecks) - { - await ExpectFailureAsync(() => CreateTaskPushNotificationConfigAsync(client, card, new CreateTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - ConfigId = "interop-config", - Config = pushConfig, - }), "create_push_config").ConfigureAwait(false); - await ExpectFailureAsync(() => GetTaskPushNotificationConfigAsync(client, card, new GetTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - Id = "interop-config", - }), "get_push_config").ConfigureAwait(false); - await ExpectFailureAsync(() => ListTaskPushNotificationConfigAsync(client, card, new ListTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - }), "list_push_configs").ConfigureAwait(false); - await ExpectFailureAsync(() => DeleteTaskPushNotificationConfigAsync(client, card, new DeleteTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - Id = "interop-config", - }), "delete_push_config").ConfigureAwait(false); - } - else + var msgJson = RequireFlag(parsed.Rest, "--message-json"); + var request = JsonSerializer.Deserialize(msgJson, A2AJsonUtilities.DefaultOptions) + ?? throw new InvalidOperationException("failed to parse send-streaming-message request"); + await foreach (var ev in SendStreamingMessageAsync(client, card, request, msgJson).ConfigureAwait(false)) { - await ExpectA2AErrorAsync(() => CreateTaskPushNotificationConfigAsync(client, card, new CreateTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - ConfigId = "interop-config", - Config = pushConfig, - }), options.ExpectedPushErrorCode, "create_push_config").ConfigureAwait(false); - await ExpectA2AErrorAsync(() => GetTaskPushNotificationConfigAsync(client, card, new GetTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - Id = "interop-config", - }), options.ExpectedPushErrorCode, "get_push_config").ConfigureAwait(false); - await ExpectA2AErrorAsync(() => ListTaskPushNotificationConfigAsync(client, card, new ListTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - }), options.ExpectedPushErrorCode, "list_push_configs").ConfigureAwait(false); - await ExpectA2AErrorAsync(() => DeleteTaskPushNotificationConfigAsync(client, card, new DeleteTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - Id = "interop-config", - }), options.ExpectedPushErrorCode, "delete_push_config").ConfigureAwait(false); + PrintStreamEvent(ev); } + return; } - else if (options.ExpectPushSupported) - { - var pushConfig = new PushNotificationConfig - { - Id = "interop-config", - Url = "https://example.invalid/webhook", - Token = "interop-token", - Authentication = new AuthenticationInfo - { - Scheme = "Bearer", - Credentials = "interop-credential", - }, - }; - - var createdConfig = await CreateTaskPushNotificationConfigAsync(client, card, new CreateTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - ConfigId = "interop-config", - Config = pushConfig, - }).ConfigureAwait(false); - AssertPushConfig(createdConfig, completedTask.Id, pushConfig, "create_push_config"); - - var fetchedConfig = await GetTaskPushNotificationConfigAsync(client, card, new GetTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - Id = "interop-config", - }).ConfigureAwait(false); - AssertPushConfig(fetchedConfig, completedTask.Id, pushConfig, "get_push_config"); - var listedConfigs = await ListTaskPushNotificationConfigAsync(client, card, new ListTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - }).ConfigureAwait(false); - if (listedConfigs.Count != 1) - { - throw new InvalidOperationException($"unexpected list_push_configs result count: got {listedConfigs.Count}, want 1"); - } - AssertPushConfig(listedConfigs[0], completedTask.Id, pushConfig, "list_push_configs"); - - await DeleteTaskPushNotificationConfigAsync(client, card, new DeleteTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - Id = "interop-config", - }).ConfigureAwait(false); - - var listedAfterDelete = await ListTaskPushNotificationConfigAsync(client, card, new ListTaskPushNotificationConfigRequest - { - TaskId = completedTask.Id, - }).ConfigureAwait(false); - if (listedAfterDelete.Count > 0) - { - throw new InvalidOperationException($"expected list_push_configs after delete to be empty, got {listedAfterDelete.Count}"); - } + case "get-task": + { + var taskId = RequireFlag(parsed.Rest, "--task-id"); + var task = await GetTaskAsync(client, card, new GetTaskRequest { Id = taskId }).ConfigureAwait(false); + Console.WriteLine(JsonSerializer.Serialize(task, A2AJsonUtilities.DefaultOptions)); + return; } - } - if (options.Scenario.RunsParity()) - { - var messageOnly = MessageFromResponse( - await SendMessageAsync(client, card, BuildRequest(MessageOnlyRequestText, false), false).ConfigureAwait(false), - "message-only"); - AssertText(FirstText(messageOnly), expectedMessageOnlyText, "message-only"); - - var failedTask = TaskFromResponse( - await SendMessageAsync(client, card, BuildRequest(TaskFailureRequestText, false), false).ConfigureAwait(false), - "task-failure"); - AssertState(failedTask.Status.State, TaskState.Failed, "task-failure"); - AssertText(TaskText(failedTask), expectedFailedText, "task-failure"); - - var inputRequiredTask = TaskFromResponse( - await SendMessageAsync(client, card, BuildRequest(MultiTurnStartRequestText, false), false).ConfigureAwait(false), - "multi-turn start"); - AssertState(inputRequiredTask.Status.State, TaskState.InputRequired, "multi-turn start"); - AssertText(TaskText(inputRequiredTask), expectedInputRequiredText, "multi-turn start"); - AssertTaskHistory(inputRequiredTask, MultiTurnStartRequestText, "multi-turn start"); - - var multiTurnCompleted = TaskFromResponse( - await SendMessageAsync( - client, - card, - BuildRequest(MultiTurnContinueRequestText, false, inputRequiredTask.Id, inputRequiredTask.ContextId), - false).ConfigureAwait(false), - "multi-turn continuation"); - AssertState(multiTurnCompleted.Status.State, TaskState.Completed, "multi-turn continuation"); - AssertText(TaskText(multiTurnCompleted), expectedMultiTurnCompleteText, "multi-turn continuation"); - AssertTaskHistoryEntries(multiTurnCompleted, [MultiTurnStartRequestText, MultiTurnContinueRequestText], "multi-turn continuation"); - - var sawStreamingStart = false; - var sawStreamingComplete = false; - var sawAppend = false; - var streamingChunks = new List(); - await foreach (var response in SendStreamingMessageAsync(client, card, BuildRequest(StreamingRequestText, false)).ConfigureAwait(false)) + case "cancel-task": { - switch (response.PayloadCase) - { - case StreamResponseCase.Task: - sawStreamingStart = true; - AssertState(response.Task!.Status.State, TaskState.Working, "streaming scenario task"); - AssertText(TaskText(response.Task), expectedStreamingStartText, "streaming scenario task"); - break; - case StreamResponseCase.ArtifactUpdate: - streamingChunks.Add(FirstArtifactText(response.ArtifactUpdate!.Artifact)); - if (response.ArtifactUpdate.Append) - { - sawAppend = true; - } - break; - case StreamResponseCase.StatusUpdate: - sawStreamingComplete = true; - AssertState(response.StatusUpdate!.Status.State, TaskState.Completed, "streaming scenario status"); - AssertText(FirstText(response.StatusUpdate.Status.Message!), expectedStreamingCompleteText, "streaming scenario status"); - break; - case StreamResponseCase.Message: - throw new InvalidOperationException("streaming scenario yielded an unexpected message event"); - default: - throw new InvalidOperationException($"unexpected streaming scenario payload case {response.PayloadCase}"); - } + var taskId = RequireFlag(parsed.Rest, "--task-id"); + var task = await CancelTaskAsync(client, card, new CancelTaskRequest { Id = taskId }).ConfigureAwait(false); + Console.WriteLine(JsonSerializer.Serialize(task, A2AJsonUtilities.DefaultOptions)); + return; } - if (!sawStreamingStart || !sawStreamingComplete) + + case "list-tasks": { - throw new InvalidOperationException("streaming scenario did not emit the expected task/status events"); + var contextId = RequireFlag(parsed.Rest, "--context-id"); + var tasks = await ListTasksAsync(client, card, new ListTasksRequest { ContextId = contextId }).ConfigureAwait(false); + var output = new { tasks }; + Console.WriteLine(JsonSerializer.Serialize(output, A2AJsonUtilities.DefaultOptions)); + return; } - if (streamingChunks.Count != 2 || streamingChunks[0] != "streaming chunk 1" || streamingChunks[1] != "streaming chunk 2") + + case "create-push-config": { - throw new InvalidOperationException($"streaming scenario artifact chunks mismatch: got [{string.Join(", ", streamingChunks)}]"); + var configJson = RequireFlag(parsed.Rest, "--config-json"); + var flat = JsonSerializer.Deserialize(configJson) + ?? throw new InvalidOperationException("failed to parse push config"); + var created = await CreatePushConfigAsync(client, card, flat).ConfigureAwait(false); + Console.WriteLine(JsonSerializer.Serialize(created)); + return; } - if (!sawAppend) + + case "get-push-config": { - throw new InvalidOperationException("streaming scenario did not emit an append artifact update"); + var taskId = RequireFlag(parsed.Rest, "--task-id"); + var configId = RequireFlag(parsed.Rest, "--config-id"); + var result = await GetPushConfigAsync(client, card, + new GetTaskPushNotificationConfigRequest { TaskId = taskId, Id = configId }).ConfigureAwait(false); + Console.WriteLine(JsonSerializer.Serialize(result)); + return; } - var longRunningTask = TaskFromResponse( - await SendMessageAsync(client, card, BuildRequest(LongRunningRequestText, true), true).ConfigureAwait(false), - "long-running"); - var longRunningCompleted = longRunningTask.Status.State switch + case "list-push-configs": { - TaskState.Working => await WaitForTaskStateAsync(client, card, longRunningTask.Id, TaskState.Completed, "long-running").ConfigureAwait(false), - TaskState.Completed => longRunningTask, - _ => throw new InvalidOperationException($"unexpected long-running task state: got {longRunningTask.Status.State}, want Working or Completed"), - }; - if (longRunningTask.Status.State == TaskState.Working) + var taskId = RequireFlag(parsed.Rest, "--task-id"); + var configs = await ListPushConfigsAsync(client, card, + new ListTaskPushNotificationConfigRequest { TaskId = taskId }).ConfigureAwait(false); + Console.WriteLine(JsonSerializer.Serialize(new { configs })); + return; + } + + case "delete-push-config": { - AssertText(TaskText(longRunningTask), expectedLongRunningStartText, "long-running"); + var taskId = RequireFlag(parsed.Rest, "--task-id"); + var configId = RequireFlag(parsed.Rest, "--config-id"); + await DeletePushConfigAsync(client, card, + new DeleteTaskPushNotificationConfigRequest { TaskId = taskId, Id = configId }).ConfigureAwait(false); + return; } - AssertText(TaskText(longRunningCompleted), expectedLongRunningCompleteText, "long-running completion"); - var dataTypesTask = TaskFromResponse( - await SendMessageAsync(client, card, BuildRequest(DataTypesRequestText, false), false).ConfigureAwait(false), - "data-types"); - AssertState(dataTypesTask.Status.State, TaskState.Completed, "data-types"); - AssertText(TaskText(dataTypesTask), expectedDataTypesText, "data-types"); - AssertDataTypesTask(dataTypesTask, "data-types"); + case "get-extended-card": + { + var extCard = await GetExtendedAgentCardAsync(client, card).ConfigureAwait(false); + Console.WriteLine(JsonSerializer.Serialize(extCard, A2AJsonUtilities.DefaultOptions)); + return; + } - var extendedCard = await GetExtendedAgentCardAsync(client, card).ConfigureAwait(false); - AssertExtendedCardMetadata(extendedCard, "extended-card"); + default: + throw new ArgumentException($"unknown subcommand: {parsed.Subcommand}"); } - - var protocol = card.SupportedInterfaces.FirstOrDefault()?.ProtocolBinding ?? "unknown"; - Console.WriteLine($"validated {options.ServerPrefix} {protocol} {options.Scenario.ToString().ToLowerInvariant()} against {options.CardUrl}"); } - private static SendMessageRequest BuildRequest(string text, bool returnImmediately, string? taskId = null, string? contextId = null) - { - return new SendMessageRequest - { - Message = new Message - { - Role = Role.User, - MessageId = Guid.NewGuid().ToString("N"), - TaskId = taskId, - ContextId = contextId, - Parts = - [ - Part.FromText(text), - Part.FromData(JsonSerializer.SerializeToElement(new { kind = RequestDataKind, scope = RequestDataScope })), - ], - Metadata = new Dictionary - { - [RequestMetadataKey] = JsonSerializer.SerializeToElement(RequestMetadataValue), - }, - }, - Configuration = returnImmediately - ? new SendMessageConfiguration { ReturnImmediately = true } - : null, - }; - } + // ── response printing ──────────────────────────────────────────────────── - private static async Task SendMessageAsync( - IA2AClient? client, - AgentCard card, - SendMessageRequest request, - bool returnImmediately) + private static void PrintMessageResponse(SendMessageResponse response) { - if (!UsesJsonRpcCompat(card)) + if (response.Task is not null) { - return await PostRestAsync(card, "/message:send", BuildRestSendMessagePayload(request)).ConfigureAwait(false); + var data = JsonSerializer.SerializeToNode(response.Task, A2AJsonUtilities.DefaultOptions); + Console.WriteLine(JsonSerializer.Serialize(new { type = "task", data })); } - - if (!returnImmediately) + else if (response.Message is not null) { - return await client!.SendMessageAsync(request).ConfigureAwait(false); + var data = JsonSerializer.SerializeToNode(response.Message, A2AJsonUtilities.DefaultOptions); + Console.WriteLine(JsonSerializer.Serialize(new { type = "message", data })); } + else + { + throw new InvalidOperationException("send-message: empty response"); + } + } - var payload = new + private static void PrintStreamEvent(StreamResponse ev) + { + switch (ev.PayloadCase) { - message = request.Message, - configuration = new + case StreamResponseCase.Task: { - returnImmediately = true, - }, - }; - - return await SendJsonRpcAsync(card, "SendMessage", payload).ConfigureAwait(false); + var data = JsonSerializer.SerializeToNode(ev.Task!, A2AJsonUtilities.DefaultOptions); + Console.WriteLine(JsonSerializer.Serialize(new { type = "task", data })); + break; + } + case StreamResponseCase.StatusUpdate: + { + var data = JsonSerializer.SerializeToNode(ev.StatusUpdate!, A2AJsonUtilities.DefaultOptions); + Console.WriteLine(JsonSerializer.Serialize(new { type = "task-status-update", data })); + break; + } + case StreamResponseCase.ArtifactUpdate: + { + var data = JsonSerializer.SerializeToNode(ev.ArtifactUpdate!, A2AJsonUtilities.DefaultOptions); + Console.WriteLine(JsonSerializer.Serialize(new { type = "task-artifact-update", data })); + break; + } + case StreamResponseCase.Message: + { + var data = JsonSerializer.SerializeToNode(ev.Message!, A2AJsonUtilities.DefaultOptions); + Console.WriteLine(JsonSerializer.Serialize(new { type = "message", data })); + break; + } + } } - private static JsonNode BuildRestSendMessagePayload(SendMessageRequest request) + // ── push config flat DTO (matches Go SDK's a2a.PushConfig JSON fields) ── + + private sealed class FlatPushConfig { - var payload = JsonSerializer.SerializeToNode(request, A2AJsonUtilities.DefaultOptions) as JsonObject - ?? throw new InvalidOperationException("failed to serialize REST send message request"); + [JsonPropertyName("taskId")] + public string TaskId { get; set; } = string.Empty; - RemoveNullProperties(payload); + [JsonPropertyName("id")] + public string? Id { get; set; } - if (payload["configuration"] is JsonObject configuration - && configuration.TryGetPropertyValue("blocking", out var blockingNode) - && blockingNode is not null) - { - configuration["returnImmediately"] = !blockingNode.GetValue(); - configuration.Remove("blocking"); - } + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; - return payload; + [JsonPropertyName("token")] + public string? Token { get; set; } + + [JsonPropertyName("authentication")] + public FlatAuthInfo? Authentication { get; set; } } - private static async Task> ListTasksAsync(IA2AClient? client, AgentCard card, ListTasksRequest request) + private sealed class FlatAuthInfo { - if (!UsesJsonRpcCompat(card)) - { - var restResponse = await GetRestAsync(card, BuildListTasksPath(request)).ConfigureAwait(false); - return restResponse.Tasks; - } + [JsonPropertyName("scheme")] + public string Scheme { get; set; } = string.Empty; - var rpcResponse = await SendJsonRpcAsync(card, "ListTasks", request).ConfigureAwait(false); - return rpcResponse.Tasks; + [JsonPropertyName("credentials")] + public string? Credentials { get; set; } } - private static async Task CreateTaskPushNotificationConfigAsync( + private static FlatPushConfig ToFlatPushConfig(string taskId, PushNotificationConfig config) => + new() + { + TaskId = taskId, + Id = config.Id, + Url = config.Url, + Token = config.Token, + Authentication = config.Authentication is not null + ? new FlatAuthInfo { Scheme = config.Authentication.Scheme, Credentials = config.Authentication.Credentials } + : null, + }; + + private static PushNotificationConfig FromFlatPushConfig(FlatPushConfig flat) => + new() + { + Id = flat.Id, + Url = flat.Url, + Token = flat.Token, + Authentication = flat.Authentication is not null + ? new AuthenticationInfo { Scheme = flat.Authentication.Scheme, Credentials = flat.Authentication.Credentials } + : null, + }; + + // ── push config operations ────────────────────────────────────────────── + + private static async Task CreatePushConfigAsync( IA2AClient? client, AgentCard card, - CreateTaskPushNotificationConfigRequest request) + FlatPushConfig flat) { + var config = FromFlatPushConfig(flat); if (!UsesJsonRpcCompat(card)) { - return ToCompatibleTaskPushNotificationConfig( - await PostRestAsync( - card, - $"/tasks/{Uri.EscapeDataString(request.TaskId)}/pushNotificationConfigs", - request.Config).ConfigureAwait(false)); + var result = await PostRestAsync( + card, + $"/tasks/{Uri.EscapeDataString(flat.TaskId)}/pushNotificationConfigs", + config).ConfigureAwait(false); + return ToFlatPushConfig(flat.TaskId, result.PushNotificationConfig ?? config); } - return await SendJsonRpcAsync(card, "CreateTaskPushNotificationConfig", request).ConfigureAwait(false); + var rpcResult = await SendJsonRpcAsync( + card, "CreateTaskPushNotificationConfig", + new CreateTaskPushNotificationConfigRequest { TaskId = flat.TaskId, ConfigId = flat.Id ?? string.Empty, Config = config } + ).ConfigureAwait(false); + return ToFlatPushConfig(rpcResult.TaskId, rpcResult.GetConfig()); } - private static async Task GetTaskPushNotificationConfigAsync( + private static async Task GetPushConfigAsync( IA2AClient? client, AgentCard card, GetTaskPushNotificationConfigRequest request) { if (!UsesJsonRpcCompat(card)) { - return ToCompatibleTaskPushNotificationConfig( - await GetRestAsync( - card, - $"/tasks/{Uri.EscapeDataString(request.TaskId)}/pushNotificationConfigs/{Uri.EscapeDataString(request.Id)}").ConfigureAwait(false)); + var result = await GetRestAsync( + card, + $"/tasks/{Uri.EscapeDataString(request.TaskId)}/pushNotificationConfigs/{Uri.EscapeDataString(request.Id)}" + ).ConfigureAwait(false); + return ToFlatPushConfig(request.TaskId, result.PushNotificationConfig ?? new PushNotificationConfig { Id = request.Id, Url = string.Empty }); } - return await SendJsonRpcAsync(card, "GetTaskPushNotificationConfig", request).ConfigureAwait(false); + var rpcResult = await SendJsonRpcAsync( + card, "GetTaskPushNotificationConfig", request).ConfigureAwait(false); + return ToFlatPushConfig(rpcResult.TaskId, rpcResult.GetConfig()); } - private static async Task> ListTaskPushNotificationConfigAsync( + private static async Task> ListPushConfigsAsync( IA2AClient? client, AgentCard card, ListTaskPushNotificationConfigRequest request) @@ -518,27 +350,26 @@ private static async Task> ListTaskPu var restResponse = await GetRestAsync( card, BuildListPushConfigsPath(request)).ConfigureAwait(false); - return (restResponse.Configs ?? []).Select(ToCompatibleTaskPushNotificationConfig).ToList(); + return (restResponse.Configs ?? []) + .Select(c => ToFlatPushConfig(request.TaskId, c.PushNotificationConfig ?? new PushNotificationConfig { Url = string.Empty })) + .ToList(); } var rpcResponse = await SendJsonRpcAsync( - card, - "ListTaskPushNotificationConfigs", - request).ConfigureAwait(false); + card, "ListTaskPushNotificationConfigs", request).ConfigureAwait(false); - return rpcResponse.ValueKind switch + var compatList = rpcResponse.ValueKind switch { JsonValueKind.Array => JsonSerializer.Deserialize>( - rpcResponse.GetRawText(), - A2AJsonUtilities.DefaultOptions) ?? [], + rpcResponse.GetRawText(), A2AJsonUtilities.DefaultOptions) ?? [], JsonValueKind.Object => JsonSerializer.Deserialize( - rpcResponse.GetRawText(), - A2AJsonUtilities.DefaultOptions)?.Configs ?? [], + rpcResponse.GetRawText(), A2AJsonUtilities.DefaultOptions)?.Configs ?? [], _ => throw new InvalidOperationException("unexpected list push config JSON-RPC result shape"), }; + return compatList.Select(c => ToFlatPushConfig(c.TaskId, c.GetConfig())).ToList(); } - private static async Task DeleteTaskPushNotificationConfigAsync( + private static async Task DeletePushConfigAsync( IA2AClient? client, AgentCard card, DeleteTaskPushNotificationConfigRequest request) @@ -548,47 +379,44 @@ private static async Task DeleteTaskPushNotificationConfigAsync( await SendRestAsync( HttpMethod.Delete, card, - $"/tasks/{Uri.EscapeDataString(request.TaskId)}/pushNotificationConfigs/{Uri.EscapeDataString(request.Id)}").ConfigureAwait(false); + $"/tasks/{Uri.EscapeDataString(request.TaskId)}/pushNotificationConfigs/{Uri.EscapeDataString(request.Id)}" + ).ConfigureAwait(false); return; } await SendJsonRpcWithoutResultAsync(card, "DeleteTaskPushNotificationConfig", request).ConfigureAwait(false); } - private static IA2AClient? CreateClient(AgentCard card) - { - var agentInterface = GetPrimaryInterface(card); + // ── A2A operations ────────────────────────────────────────────────────── - if (string.Equals(agentInterface.ProtocolBinding, "JSONRPC", StringComparison.OrdinalIgnoreCase)) - { - return new A2AClient(new Uri(agentInterface.Url)); - } - - if (string.Equals(agentInterface.ProtocolBinding, "HTTP+JSON", StringComparison.OrdinalIgnoreCase)) + private static async Task SendMessageAsync( + IA2AClient? client, + AgentCard card, + JsonNode request) + { + if (UsesJsonRpcCompat(card)) { - return null; + return await SendJsonRpcAsync(card, "SendMessage", request).ConfigureAwait(false); } - throw new InvalidOperationException($"agent card did not advertise a supported interface: {agentInterface.ProtocolBinding}"); + RemoveNullProperties(request); + return await PostRestAsync(card, "/message:send", request).ConfigureAwait(false); } - private static async Task GetExtendedAgentCardAsync(IA2AClient? client, AgentCard card) + private static IAsyncEnumerable SendStreamingMessageAsync( + IA2AClient? client, + AgentCard card, + SendMessageRequest request, + string rawJson) { if (UsesJsonRpcCompat(card)) { - return await client!.GetExtendedAgentCardAsync(new GetExtendedAgentCardRequest()).ConfigureAwait(false); + return client!.SendStreamingMessageAsync(request); } - return await GetRestAsync(card, "/extendedAgentCard").ConfigureAwait(false); - } - - private static CompatibleTaskPushNotificationConfig ToCompatibleTaskPushNotificationConfig(TaskPushNotificationConfig config) - { - return new CompatibleTaskPushNotificationConfig - { - TaskId = config.TaskId, - Config = config.PushNotificationConfig, - }; + var reqNode = JsonNode.Parse(rawJson) ?? throw new InvalidOperationException("failed to re-parse streaming request"); + RemoveNullProperties(reqNode); + return PostRestStreamingAsync(card, "/message:stream", reqNode); } private static async Task GetTaskAsync(IA2AClient? client, AgentCard card, GetTaskRequest request) @@ -614,45 +442,55 @@ private static async Task CancelTaskAsync(IA2AClient? client, AgentCa : await PostRestAsync(card, path, new { metadata = request.Metadata }).ConfigureAwait(false); } - private static IAsyncEnumerable SendStreamingMessageAsync(IA2AClient? client, AgentCard card, SendMessageRequest request) + private static async Task> ListTasksAsync(IA2AClient? client, AgentCard card, ListTasksRequest request) { - if (UsesJsonRpcCompat(card)) + if (!UsesJsonRpcCompat(card)) { - return client!.SendStreamingMessageAsync(request); + var restResponse = await GetRestAsync(card, BuildListTasksPath(request)).ConfigureAwait(false); + return restResponse.Tasks; } - return PostRestStreamingAsync(card, "/message:stream", request); + var rpcResponse = await SendJsonRpcAsync(card, "ListTasks", request).ConfigureAwait(false); + return rpcResponse.Tasks; } - private static string BuildGetTaskPath(GetTaskRequest request) + private static async Task GetExtendedAgentCardAsync(IA2AClient? client, AgentCard card) { - return $"/tasks/{Uri.EscapeDataString(request.Id)}{BuildQueryString(("historyLength", request.HistoryLength?.ToString(System.Globalization.CultureInfo.InvariantCulture)))}"; - } + if (UsesJsonRpcCompat(card)) + { + return await client!.GetExtendedAgentCardAsync(new GetExtendedAgentCardRequest()).ConfigureAwait(false); + } - private static string BuildListTasksPath(ListTasksRequest request) - { - return "/tasks" + BuildQueryString( - ("contextId", request.ContextId), - ("status", request.Status?.ToString()), - ("pageSize", request.PageSize?.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("pageToken", request.PageToken), - ("historyLength", request.HistoryLength?.ToString(System.Globalization.CultureInfo.InvariantCulture))); + return await GetRestAsync(card, "/extendedAgentCard").ConfigureAwait(false); } - private static string BuildListPushConfigsPath(ListTaskPushNotificationConfigRequest request) + // ── client creation ───────────────────────────────────────────────────── + + private static IA2AClient? CreateClient(AgentCard card) { - return $"/tasks/{Uri.EscapeDataString(request.TaskId)}/pushNotificationConfigs" + BuildQueryString( - ("pageSize", request.PageSize?.ToString(System.Globalization.CultureInfo.InvariantCulture)), - ("pageToken", request.PageToken)); + var agentInterface = GetPrimaryInterface(card); + + if (string.Equals(agentInterface.ProtocolBinding, "JSONRPC", StringComparison.OrdinalIgnoreCase)) + { + return new A2AClient(new Uri(agentInterface.Url)); + } + + if (string.Equals(agentInterface.ProtocolBinding, "HTTP+JSON", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + throw new InvalidOperationException($"unsupported protocol binding: {agentInterface.ProtocolBinding}"); } + // ── REST helpers ──────────────────────────────────────────────────────── + private static async Task GetRestAsync(AgentCard card, string path) { using var httpClient = new HttpClient(); using var request = CreateRestRequest(HttpMethod.Get, card, path); using var response = await httpClient.SendAsync(request).ConfigureAwait(false); await EnsureRestSuccessOrThrowA2AExceptionAsync(response).ConfigureAwait(false); - return await ReadRestResponseAsync(response.Content, path).ConfigureAwait(false); } @@ -661,10 +499,8 @@ private static async Task PostRestAsync(AgentCard card, using var httpClient = new HttpClient(); using var request = CreateRestRequest(HttpMethod.Post, card, path); request.Content = new StringContent(JsonSerializer.Serialize(body, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json"); - using var response = await httpClient.SendAsync(request).ConfigureAwait(false); await EnsureRestSuccessOrThrowA2AExceptionAsync(response).ConfigureAwait(false); - return await ReadRestResponseAsync(response.Content, path).ConfigureAwait(false); } @@ -674,7 +510,6 @@ private static async Task PostRestEmptyAsync(AgentCard card, s using var request = CreateRestRequest(HttpMethod.Post, card, path); using var response = await httpClient.SendAsync(request).ConfigureAwait(false); await EnsureRestSuccessOrThrowA2AExceptionAsync(response).ConfigureAwait(false); - return await ReadRestResponseAsync(response.Content, path).ConfigureAwait(false); } @@ -689,7 +524,7 @@ private static async Task SendRestAsync(HttpMethod method, AgentCard card, strin private static async IAsyncEnumerable PostRestStreamingAsync( AgentCard card, string path, - SendMessageRequest body, + JsonNode body, [EnumeratorCancellation] CancellationToken cancellationToken = default) { using var httpClient = new HttpClient(); @@ -703,8 +538,7 @@ private static async IAsyncEnumerable PostRestStreamingAsync( await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await foreach (var sseItem in SseParser.Create(stream).EnumerateAsync(cancellationToken).ConfigureAwait(false)) { - var result = ReadRestPayload(sseItem.Data, path); - yield return result; + yield return ReadRestPayload(sseItem.Data, path); } } @@ -713,7 +547,6 @@ private static async Task ReadRestResponseAsync(HttpContent co await using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); var payload = await JsonNode.ParseAsync(stream).ConfigureAwait(false) ?? throw new InvalidOperationException($"received empty REST response for {path}"); - return ReadRestPayload(payload, path); } @@ -721,7 +554,6 @@ private static TResult ReadRestPayload(string json, string path) { var payload = JsonNode.Parse(json) ?? throw new InvalidOperationException($"received empty REST response for {path}"); - return ReadRestPayload(payload, path); } @@ -729,7 +561,6 @@ private static TResult ReadRestPayload(JsonNode payload, string path) { RemoveNullProperties(payload); payload = NormalizeRestPayload(payload); - var result = payload.Deserialize(A2AJsonUtilities.DefaultOptions); return result ?? throw new InvalidOperationException($"failed to deserialize REST response for {path}: {payload.ToJsonString()}"); } @@ -766,7 +597,6 @@ private static JsonNode NormalizeRestPayload(JsonNode payload) if (typeof(TResult) == typeof(ListTaskPushNotificationConfigResponse)) { EnsureStringProperty(root, "nextPageToken"); - if (root["configs"] is JsonArray configs) { foreach (var item in configs.OfType()) @@ -774,7 +604,6 @@ private static JsonNode NormalizeRestPayload(JsonNode payload) NormalizeTaskPushNotificationConfig(item); } } - return root; } @@ -906,20 +735,6 @@ private static async Task EnsureRestSuccessOrThrowA2AExceptionAsync(HttpResponse throw new A2AException(message, errorCode); } - private static string BuildQueryString(params (string Key, string? Value)[] parameters) - { - var parts = new List(); - foreach (var (key, value) in parameters) - { - if (!string.IsNullOrEmpty(value)) - { - parts.Add($"{key}={Uri.EscapeDataString(value)}"); - } - } - - return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; - } - private static readonly Dictionary ReasonToErrorCode = new(StringComparer.OrdinalIgnoreCase) { ["TASK_NOT_FOUND"] = A2AErrorCode.TaskNotFound, @@ -933,30 +748,53 @@ private static string BuildQueryString(params (string Key, string? Value)[] para ["VERSION_NOT_SUPPORTED"] = A2AErrorCode.VersionNotSupported, }; - private static bool UsesJsonRpcCompat(AgentCard card) - { - return string.Equals(GetPrimaryInterface(card).ProtocolBinding, "JSONRPC", StringComparison.OrdinalIgnoreCase); - } + private static bool UsesJsonRpcCompat(AgentCard card) => + string.Equals(GetPrimaryInterface(card).ProtocolBinding, "JSONRPC", StringComparison.OrdinalIgnoreCase); - private static AgentInterface GetPrimaryInterface(AgentCard card) - { - return card.SupportedInterfaces.FirstOrDefault() - ?? throw new InvalidOperationException("agent card did not advertise any interfaces"); - } + private static AgentInterface GetPrimaryInterface(AgentCard card) => + card.SupportedInterfaces.FirstOrDefault() + ?? throw new InvalidOperationException("agent card did not advertise any interfaces"); private static AgentInterface GetJsonRpcInterface(AgentCard card) { var jsonRpcInterface = card.SupportedInterfaces.FirstOrDefault(candidate => string.Equals(candidate.ProtocolBinding, "JSONRPC", StringComparison.OrdinalIgnoreCase)); - if (jsonRpcInterface is null) + return jsonRpcInterface + ?? throw new InvalidOperationException("agent card did not advertise a JSON-RPC interface"); + } + + private static string BuildGetTaskPath(GetTaskRequest request) => + $"/tasks/{Uri.EscapeDataString(request.Id)}{BuildQueryString(("historyLength", request.HistoryLength?.ToString(System.Globalization.CultureInfo.InvariantCulture)))}"; + + private static string BuildListTasksPath(ListTasksRequest request) => + "/tasks" + BuildQueryString( + ("contextId", request.ContextId), + ("status", request.Status?.ToString()), + ("pageSize", request.PageSize?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("pageToken", request.PageToken), + ("historyLength", request.HistoryLength?.ToString(System.Globalization.CultureInfo.InvariantCulture))); + + private static string BuildListPushConfigsPath(ListTaskPushNotificationConfigRequest request) => + $"/tasks/{Uri.EscapeDataString(request.TaskId)}/pushNotificationConfigs" + BuildQueryString( + ("pageSize", request.PageSize?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("pageToken", request.PageToken)); + + private static string BuildQueryString(params (string Key, string? Value)[] parameters) + { + var parts = new List(); + foreach (var (key, value) in parameters) { - throw new InvalidOperationException("agent card did not advertise a JSON-RPC interface"); + if (!string.IsNullOrEmpty(value)) + { + parts.Add($"{key}={Uri.EscapeDataString(value)}"); + } } - - return jsonRpcInterface; + return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; } + // ── JSON-RPC helpers ───────────────────────────────────────────────────── + private static async Task SendJsonRpcAsync(AgentCard card, string method, object payload) { using var httpClient = new HttpClient(); @@ -980,9 +818,7 @@ private static async Task SendJsonRpcAsync(AgentCard card, str response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var rpcResponse = await JsonSerializer.DeserializeAsync( - stream, - A2AJsonUtilities.DefaultOptions).ConfigureAwait(false) + var rpcResponse = await JsonSerializer.DeserializeAsync(stream, A2AJsonUtilities.DefaultOptions).ConfigureAwait(false) ?? throw new InvalidOperationException("failed to deserialize JSON-RPC response"); if (rpcResponse.Error is not null) @@ -992,16 +828,12 @@ private static async Task SendJsonRpcAsync(AgentCard card, str if (rpcResponse.Result is null) { - throw new InvalidOperationException($"failed to deserialize JSON-RPC result for {method}: null result payload"); + throw new InvalidOperationException($"JSON-RPC {method}: null result payload"); } var rawResult = rpcResponse.Result.ToJsonString(); - - var result = JsonSerializer.Deserialize( - rawResult, - A2AJsonUtilities.DefaultOptions); - - return result ?? throw new InvalidOperationException($"failed to deserialize JSON-RPC result for {method}: {rawResult}"); + return JsonSerializer.Deserialize(rawResult, A2AJsonUtilities.DefaultOptions) + ?? throw new InvalidOperationException($"failed to deserialize JSON-RPC result for {method}"); } private static async Task SendJsonRpcWithoutResultAsync(AgentCard card, string method, object payload) @@ -1027,9 +859,7 @@ private static async Task SendJsonRpcWithoutResultAsync(AgentCard card, string m response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var rpcResponse = await JsonSerializer.DeserializeAsync( - stream, - A2AJsonUtilities.DefaultOptions).ConfigureAwait(false) + var rpcResponse = await JsonSerializer.DeserializeAsync(stream, A2AJsonUtilities.DefaultOptions).ConfigureAwait(false) ?? throw new InvalidOperationException($"failed to deserialize JSON-RPC response for {method}"); if (rpcResponse.Error is not null) @@ -1037,318 +867,21 @@ private static async Task SendJsonRpcWithoutResultAsync(AgentCard card, string m throw new A2AException(rpcResponse.Error.Message, (A2AErrorCode)rpcResponse.Error.Code); } } - - private static string ExpectedResponseText(string serverPrefix, string requestText) => - $"{serverPrefix} server received: {requestText}"; - - private static string ExpectedCancelText(string serverPrefix) => - $"{serverPrefix} server canceled task"; - - private static string ExpectedScenarioText(string serverPrefix, string suffix) => - $"{serverPrefix} server {suffix}"; - - private static AgentTask TaskFromResponse(SendMessageResponse response, string kind) - { - return response.Task ?? throw new InvalidOperationException($"unexpected {kind} response type: Message"); - } - - private static Message MessageFromResponse(SendMessageResponse response, string kind) - { - return response.Message ?? throw new InvalidOperationException($"unexpected {kind} response type: Task"); - } - - private static string TaskText(AgentTask task) - { - return task.Status.Message is null - ? throw new InvalidOperationException("task response contained no message") - : FirstText(task.Status.Message); - } - - private static string FirstText(Message message) - { - var part = message.Parts.FirstOrDefault(value => value.Text is not null); - return part?.Text ?? throw new InvalidOperationException("message contained no text parts"); - } - - private static void AssertText(string actual, string expected, string kind) - { - if (!string.Equals(actual, expected, StringComparison.Ordinal)) - { - throw new InvalidOperationException($"unexpected {kind} response text: got '{actual}', want '{expected}'"); - } - } - - private static void AssertState(TaskState actual, TaskState expected, string kind) - { - if (actual != expected) - { - throw new InvalidOperationException($"unexpected {kind} task state: got {actual}, want {expected}"); - } - } - - private static void AssertTaskHistory(AgentTask task, string expectedText, string kind) - { - AssertTaskHistoryEntries(task, [expectedText], kind); - } - - private static void AssertTaskHistoryEntries(AgentTask task, IReadOnlyList expectedTexts, string kind) - { - if (task.History is null || task.History.Count != expectedTexts.Count) - { - throw new InvalidOperationException($"{kind} task history length mismatch: got {task.History?.Count ?? 0}, want {expectedTexts.Count}"); - } - - foreach (var (message, expectedText) in task.History.Zip(expectedTexts)) - { - AssertMessagePayload(message, expectedText, kind); - } - } - - private static void AssertMessagePayload(Message message, string expectedText, string kind) - { - AssertText(FirstText(message), expectedText, kind); - if (message.Parts.Count != 2) - { - throw new InvalidOperationException($"{kind} task history had {message.Parts.Count} parts, want 2"); - } - - var dataPart = message.Parts[1].Data ?? throw new InvalidOperationException($"{kind} task history second part was not a structured data part"); - var kindValue = dataPart.GetProperty("kind").GetString(); - var scopeValue = dataPart.GetProperty("scope").GetString(); - if (!string.Equals(kindValue, RequestDataKind, StringComparison.Ordinal) || !string.Equals(scopeValue, RequestDataScope, StringComparison.Ordinal)) - { - throw new InvalidOperationException($"{kind} task history data part mismatch: got kind={kindValue} scope={scopeValue}"); - } - - if (message.Metadata is null || !message.Metadata.TryGetValue(RequestMetadataKey, out var metadataValue)) - { - throw new InvalidOperationException($"{kind} task history metadata was missing {RequestMetadataKey}"); - } - - if (!string.Equals(metadataValue.GetString(), RequestMetadataValue, StringComparison.Ordinal)) - { - throw new InvalidOperationException($"{kind} task history metadata mismatch: got '{metadataValue.GetString()}', want '{RequestMetadataValue}'"); - } - } - - private static async Task WaitForTaskStateAsync(IA2AClient? client, AgentCard card, string taskId, TaskState expectedState, string kind) - { - for (var attempt = 0; attempt < 40; attempt++) - { - var task = await GetTaskAsync(client, card, new GetTaskRequest { Id = taskId }).ConfigureAwait(false); - if (task.Status.State == expectedState) - { - return task; - } - - await Task.Delay(TimeSpan.FromMilliseconds(50)).ConfigureAwait(false); - } - - throw new InvalidOperationException($"timed out waiting for {kind} to reach state {expectedState}"); - } - - private static string FirstArtifactText(Artifact artifact) - { - var part = artifact.Parts.FirstOrDefault(value => value.Text is not null); - return part?.Text ?? throw new InvalidOperationException("artifact contained no text parts"); - } - - private static void AssertDataTypesTask(AgentTask task, string kind) - { - if (task.Artifacts is null || task.Artifacts.Count != 1) - { - throw new InvalidOperationException($"{kind} task artifact count mismatch: got {task.Artifacts?.Count ?? 0}, want 1"); - } - - var artifact = task.Artifacts[0]; - if (artifact.Parts.Count != 3) - { - throw new InvalidOperationException($"{kind} artifact part count mismatch: got {artifact.Parts.Count}, want 3"); - } - - AssertText(artifact.Parts[0].Text ?? string.Empty, "structured summary", kind); - - var data = artifact.Parts[1].Data - ?? throw new InvalidOperationException($"{kind} data-types artifact second part was not data"); - var items = data.GetProperty("items"); - var itemsMatches = (items.TryGetInt32(out var itemsInt) && itemsInt == 2) - || (items.TryGetInt64(out var itemsLong) && itemsLong == 2) - || (items.TryGetDouble(out var itemsDouble) && Math.Abs(itemsDouble - 2.0) < double.Epsilon); - if (data.ValueKind != JsonValueKind.Object - || data.GetProperty("kind").GetString() != "report" - || !itemsMatches) - { - throw new InvalidOperationException($"{kind} data-types artifact data payload mismatch"); - } - - if (!string.Equals(artifact.Parts[2].Url, "https://example.invalid/diagram.svg", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"{kind} data-types artifact URL mismatch"); - } - if (!string.Equals(artifact.Parts[2].MediaType, "image/svg+xml", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"{kind} data-types artifact media type mismatch"); - } - if (!string.Equals(artifact.Parts[2].Filename, "diagram.svg", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"{kind} data-types artifact filename mismatch"); - } - } - - private static void AssertExtendedCardMetadata(AgentCard card, string kind) - { - if (card.Capabilities.ExtendedAgentCard != true) - { - throw new InvalidOperationException($"{kind} card did not advertise extendedAgentCard support"); - } - if (!card.Description.Contains("(extended)", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"{kind} card did not include extended description metadata"); - } - if (card.SecuritySchemes is not null && card.SecuritySchemes.Count > 0) - { - if (!card.SecuritySchemes.TryGetValue(ExtendedCardSchemeId, out var securityScheme)) - { - throw new InvalidOperationException($"{kind} card did not include {ExtendedCardSchemeId}"); - } - if (securityScheme.HttpAuthSecurityScheme?.Scheme is not "Bearer") - { - throw new InvalidOperationException($"{kind} card bearer scheme mismatch"); - } - } - - foreach (var expectedSkill in ExpectedSkillIds) - { - if (!card.Skills.Any(skill => string.Equals(skill.Id, expectedSkill, StringComparison.Ordinal))) - { - throw new InvalidOperationException($"{kind} card was missing skill {expectedSkill}"); - } - } - } - - private static async Task ReadStreamingTextAsync(IAsyncEnumerable stream) - { - await foreach (var response in stream.ConfigureAwait(false)) - { - var text = StreamResponseText(response); - if (text is not null) - { - return text; - } - } - - throw new InvalidOperationException("stream completed without a terminal response event"); - } - - private static string? StreamResponseText(StreamResponse response) - { - return response.PayloadCase switch - { - StreamResponseCase.Message => FirstText(response.Message!), - StreamResponseCase.Task => TaskText(response.Task!), - StreamResponseCase.StatusUpdate when response.StatusUpdate?.Status.Message is not null => FirstText(response.StatusUpdate.Status.Message), - _ => null, - }; - } - - private static void AssertPushConfig(CompatibleTaskPushNotificationConfig actual, string taskId, PushNotificationConfig expected, string kind) - { - if (!string.Equals(actual.TaskId, taskId, StringComparison.Ordinal)) - { - throw new InvalidOperationException($"unexpected {kind} task id: got '{actual.TaskId}', want '{taskId}'"); - } - - var config = actual.GetConfig(); - if (!string.Equals(config.Id, expected.Id, StringComparison.Ordinal) - || !string.Equals(config.Url, expected.Url, StringComparison.Ordinal) - || !string.Equals(config.Token, expected.Token, StringComparison.Ordinal) - || !string.Equals(config.Authentication?.Scheme, expected.Authentication?.Scheme, StringComparison.Ordinal) - || !string.Equals(config.Authentication?.Credentials, expected.Authentication?.Credentials, StringComparison.Ordinal)) - { - throw new InvalidOperationException($"unexpected {kind} push config result"); - } - } - - private static async Task ExpectFailureAsync(Func action, string kind) - { - try - { - await action().ConfigureAwait(false); - } - catch - { - return; - } - - throw new InvalidOperationException($"expected {kind} to fail, but it succeeded"); - } - - private static async Task ExpectFailureAsync(Func> action, string kind) - { - try - { - _ = await action().ConfigureAwait(false); - } - catch - { - return; - } - - throw new InvalidOperationException($"expected {kind} to fail, but it succeeded"); - } - - private static async Task ExpectA2AErrorAsync(Func action, A2AErrorCode expectedCode, string kind) - { - try - { - await action().ConfigureAwait(false); - } - catch (A2AException error) when (error.ErrorCode == expectedCode) - { - return; - } - catch (A2AException error) - { - throw new InvalidOperationException($"unexpected {kind} error code: got {(int)error.ErrorCode}, want {(int)expectedCode} ({error.Message})"); - } - - throw new InvalidOperationException($"expected {kind} to fail with code {(int)expectedCode}, but it succeeded"); - } - - private static async Task ExpectA2AErrorAsync(Func> action, A2AErrorCode expectedCode, string kind) - { - try - { - _ = await action().ConfigureAwait(false); - } - catch (A2AException error) when (error.ErrorCode == expectedCode) - { - return; - } - catch (A2AException error) - { - throw new InvalidOperationException($"unexpected {kind} error code: got {(int)error.ErrorCode}, want {(int)expectedCode} ({error.Message})"); - } - - throw new InvalidOperationException($"expected {kind} to fail with code {(int)expectedCode}, but it succeeded"); - } } +// ── supporting types ────────────────────────────────────────────────────────── + internal sealed class CompatibleListTasksResponse { public List Tasks { get; set; } = []; - public string? NextPageToken { get; set; } - public int? PageSize { get; set; } - public int? TotalSize { get; set; } } internal sealed class CompatibleTaskPushNotificationConfig { public string TaskId { get; set; } = string.Empty; - public PushNotificationConfig? Config { get; set; } // Flat fields for v1 format @@ -1380,135 +913,14 @@ internal sealed class RestA2AErrorResponse internal sealed class RestA2AErrorStatus { public int Code { get; set; } - public string? Status { get; set; } - public string? Message { get; set; } - public List? Details { get; set; } } internal sealed class RestA2AErrorDetail { public string? Type { get; set; } - public string? Reason { get; set; } - public string? Domain { get; set; } } - -internal sealed class ProbeOptions -{ - public required string CardUrl { get; init; } - - public required string ServerPrefix { get; init; } - - public ProbeScenario Scenario { get; init; } = ProbeScenario.All; - - public bool ExpectPushSupported { get; init; } - - public bool ExpectPushUnsupported { get; init; } - - public bool RelaxedErrorChecks { get; init; } - - public A2AErrorCode ExpectedPushErrorCode { get; init; } = A2AErrorCode.PushNotificationNotSupported; - - public static ProbeOptions Parse(string[] args) - { - string? cardUrl = null; - string? serverPrefix = null; - var scenario = ProbeScenario.All; - var expectPushSupported = false; - var expectPushUnsupported = false; - var relaxedErrorChecks = false; - var expectedPushErrorCode = A2AErrorCode.PushNotificationNotSupported; - - for (var index = 0; index < args.Length; index++) - { - switch (args[index]) - { - case "--card-url": - index++; - cardUrl = args[index]; - break; - case "--server-prefix": - index++; - serverPrefix = args[index]; - break; - case "--scenario": - index++; - scenario = ProbeScenarioParser.Parse(args[index]); - break; - case "--expect-push-supported": - expectPushSupported = true; - break; - case "--expect-push-unsupported": - expectPushUnsupported = true; - break; - case "--relaxed-error-checks": - relaxedErrorChecks = true; - break; - case "--expected-push-error-code": - index++; - expectedPushErrorCode = (A2AErrorCode)int.Parse(args[index], System.Globalization.CultureInfo.InvariantCulture); - break; - default: - throw new ArgumentException($"unknown argument: {args[index]}"); - } - } - - if (expectPushSupported && expectPushUnsupported) - { - throw new ArgumentException("--expect-push-supported and --expect-push-unsupported are mutually exclusive"); - } - - return new ProbeOptions - { - CardUrl = cardUrl ?? throw new ArgumentException("missing --card-url"), - ServerPrefix = serverPrefix ?? throw new ArgumentException("missing --server-prefix"), - Scenario = scenario, - ExpectPushSupported = expectPushSupported, - ExpectPushUnsupported = expectPushUnsupported, - RelaxedErrorChecks = relaxedErrorChecks, - ExpectedPushErrorCode = expectedPushErrorCode, - }; - } -} - -internal enum ProbeScenario -{ - All, - Core, - UnaryStreaming, - TaskLifecycle, - PushConfig, - Parity, -} - -internal static class ProbeScenarioExtensions -{ - public static bool RunsUnaryStreaming(this ProbeScenario scenario) => - scenario is ProbeScenario.All or ProbeScenario.Core or ProbeScenario.UnaryStreaming; - - public static bool RunsTaskLifecycle(this ProbeScenario scenario) => - scenario is ProbeScenario.All or ProbeScenario.Core or ProbeScenario.TaskLifecycle; - - public static bool RunsPushConfig(this ProbeScenario scenario) => - scenario is ProbeScenario.All or ProbeScenario.Core or ProbeScenario.PushConfig; - - public static bool RunsParity(this ProbeScenario scenario) => scenario is ProbeScenario.All or ProbeScenario.Parity; -} - -internal static class ProbeScenarioParser -{ - public static ProbeScenario Parse(string value) => value switch - { - "all" => ProbeScenario.All, - "core" => ProbeScenario.Core, - "task-streaming" => ProbeScenario.UnaryStreaming, - "task-lifecycle" => ProbeScenario.TaskLifecycle, - "push-config" => ProbeScenario.PushConfig, - "parity" => ProbeScenario.Parity, - _ => throw new ArgumentException($"--scenario must be one of all, core, task-streaming, task-lifecycle, push-config, or parity; got {value}"), - }; -} \ No newline at end of file diff --git a/integrations/agntcy-a2a/fixtures/python/interop_python_probe.py b/integrations/agntcy-a2a/fixtures/python/interop_python_probe.py index 08e0ad9a..ce6b2da1 100644 --- a/integrations/agntcy-a2a/fixtures/python/interop_python_probe.py +++ b/integrations/agntcy-a2a/fixtures/python/interop_python_probe.py @@ -1,11 +1,12 @@ # Copyright AGNTCY Contributors (https://github.com/agntcy) # SPDX-License-Identifier: Apache-2.0 -"""A2A Python v1.0 interoperability probe. +"""Thin A2A probe adapter. -This probe is the Python-client analogue of the existing Rust and .NET probes. -It exercises one shared behavior slice at a time so the Go Ginkgo layer can -reuse the same behavior matrix across SDKs. +Executes a single A2A operation and writes the raw JSON result to stdout. +On A2A error, writes {"code":…,"message":"…"} to stderr and exits 1. +No assertions or orchestration live here — all test logic is in the +Go/Ginkgo spec tree. """ from __future__ import annotations @@ -13,14 +14,12 @@ import argparse import asyncio import json -from collections.abc import AsyncIterator +import sys from typing import Any from urllib.parse import urlparse -from uuid import uuid4 import grpc import httpx - from google.protobuf.json_format import MessageToDict, ParseDict from a2a.client import Client, ClientConfig, create_client @@ -32,170 +31,90 @@ ) -REQUEST_TEXT = 'ping' -PENDING_REQUEST_TEXT = 'pending' -MESSAGE_ONLY_REQUEST_TEXT = 'message-only' -TASK_FAILURE_REQUEST_TEXT = 'task-failure' -MULTI_TURN_START_REQUEST_TEXT = 'multi-turn start' -MULTI_TURN_CONTINUE_REQUEST_TEXT = 'multi-turn continue' -STREAMING_REQUEST_TEXT = 'streaming' -LONG_RUNNING_REQUEST_TEXT = 'long-running' -DATA_TYPES_REQUEST_TEXT = 'data-types' -REQUEST_DATA_KIND = 'structured' -REQUEST_DATA_SCOPE = 'interop' -REQUEST_METADATA_KEY = 'csit' -REQUEST_METADATA_VALUE = 'multipart' -EXTENDED_CARD_SCHEME_ID = 'bearer_token' - - -def build_text_part(text: str) -> a2a_pb2.Part: - return a2a_pb2.Part(text=text) - - -def build_data_part(data: dict[str, object]) -> a2a_pb2.Part: - part = a2a_pb2.Part() - ParseDict({'data': data}, part) - return part - - -def new_request( - text: str, - return_immediately: bool, - task_id: str = '', - context_id: str = '', -) -> a2a_pb2.SendMessageRequest: - message = a2a_pb2.Message( - role=a2a_pb2.Role.ROLE_USER, - message_id=str(uuid4()), - ) - if task_id: - message.task_id = task_id - if context_id: - message.context_id = context_id - message.parts.append(build_text_part(text)) - message.parts.append(build_data_part({'kind': REQUEST_DATA_KIND, 'scope': REQUEST_DATA_SCOPE})) - message.metadata.update({REQUEST_METADATA_KEY: REQUEST_METADATA_VALUE}) - - return a2a_pb2.SendMessageRequest( - message=message, - configuration=a2a_pb2.SendMessageConfiguration( - return_immediately=return_immediately, - ), - ) +# ── error helpers ──────────────────────────────────────────────────────────── +def fail(code: int, message: str) -> None: + print(json.dumps({'code': code, 'message': message}), file=sys.stderr) + sys.exit(1) -def first_message_text(message: a2a_pb2.Message) -> str: - for part in message.parts: - if part.text: - return part.text - raise AssertionError('message did not include a text part') +def extract_a2a_error(exc: Exception) -> tuple[int, str]: + """Return (code, message) from any A2A / gRPC / HTTP exception.""" + if isinstance(exc, grpc.aio.AioRpcError): + code_value = exc.code().value + num = code_value[0] if isinstance(code_value, tuple) else int(code_value) + return num, exc.details() or str(exc) + code = getattr(exc, 'code', None) + if isinstance(code, int): + return code, str(exc) + status_code = getattr(exc, 'status_code', None) + if isinstance(status_code, int): + return status_code, str(exc) + return -32000, str(exc) -def task_status_text(task: a2a_pb2.Task) -> str: - if not task.HasField('status') or not task.status.HasField('message'): - raise AssertionError('task status did not include a message') - return first_message_text(task.status.message) +# ── serialization helpers ──────────────────────────────────────────────────── -def value_to_dict(part: a2a_pb2.Part) -> dict[str, object]: - return MessageToDict(part.data) +def to_json_str(msg: Any) -> str: + return json.dumps(MessageToDict(msg, preserving_proto_field_name=False, + including_default_value_fields=False)) -def metadata_to_dict(message: a2a_pb2.Message) -> dict[str, object]: - return MessageToDict(message.metadata) +def event_envelope(event_type: str, msg: Any) -> str: + data = MessageToDict(msg, preserving_proto_field_name=False, + including_default_value_fields=False) + return json.dumps({'type': event_type, 'data': data}) -def assert_message_payload(message: a2a_pb2.Message, expected_text: str, kind: str) -> None: - if first_message_text(message) != expected_text: - raise AssertionError(f'{kind}: unexpected text payload') - if len(message.parts) != 2: - raise AssertionError(f'{kind}: expected 2 message parts, got {len(message.parts)}') - data_part = value_to_dict(message.parts[1]) - if data_part.get('kind') != REQUEST_DATA_KIND or data_part.get('scope') != REQUEST_DATA_SCOPE: - raise AssertionError(f'{kind}: unexpected structured data payload {data_part!r}') - metadata = metadata_to_dict(message) - if metadata.get(REQUEST_METADATA_KEY) != REQUEST_METADATA_VALUE: - raise AssertionError(f'{kind}: unexpected metadata payload {metadata!r}') +def push_config_to_dict(cfg: a2a_pb2.TaskPushNotificationConfig) -> dict: + """Serialize push config to the JSON format expected by the Go probe client.""" + obj: dict[str, Any] = {'taskId': cfg.task_id, 'url': cfg.url} + if cfg.id: + obj['id'] = cfg.id + if cfg.token: + obj['token'] = cfg.token + if cfg.HasField('authentication'): + auth: dict[str, str] = {'scheme': cfg.authentication.scheme} + if cfg.authentication.credentials: + auth['credentials'] = cfg.authentication.credentials + obj['authentication'] = auth + return obj -def assert_task_history_payloads(task: a2a_pb2.Task, expected_texts: list[str], kind: str) -> None: - if len(task.history) != len(expected_texts): - raise AssertionError(f'{kind}: expected {len(expected_texts)} history entries, got {len(task.history)}') - for index, expected_text in enumerate(expected_texts): - assert_message_payload(task.history[index], expected_text, kind) +# ── client creation ────────────────────────────────────────────────────────── + +def grpc_target(address: str) -> str: + if '://' not in address: + return address + return urlparse(address).netloc or address -def assert_task_push_config( - config: a2a_pb2.TaskPushNotificationConfig, - task_id: str, - expected: a2a_pb2.TaskPushNotificationConfig, - kind: str, -) -> None: - if config.task_id != task_id: - raise AssertionError(f'{kind}: unexpected task_id {config.task_id!r}') - if config.id != expected.id or config.url != expected.url or config.token != expected.token: - raise AssertionError(f'{kind}: unexpected push config payload') - if config.authentication.scheme != expected.authentication.scheme: - raise AssertionError(f'{kind}: unexpected auth scheme {config.authentication.scheme!r}') - if config.authentication.credentials != expected.authentication.credentials: - raise AssertionError(f'{kind}: unexpected auth credentials') - - -def assert_data_types_task(task: a2a_pb2.Task) -> None: - if len(task.artifacts) != 1: - raise AssertionError(f'data-types: expected 1 artifact, got {len(task.artifacts)}') - artifact = task.artifacts[0] - if len(artifact.parts) != 3: - raise AssertionError(f'data-types: expected 3 artifact parts, got {len(artifact.parts)}') - if artifact.parts[0].text != 'structured summary': - raise AssertionError('data-types: unexpected artifact summary text') - data_part = value_to_dict(artifact.parts[1]) - if data_part != {'kind': 'report', 'items': 2.0}: - raise AssertionError(f'data-types: unexpected structured artifact payload {data_part!r}') - if artifact.parts[2].url != 'https://example.invalid/diagram.svg': - raise AssertionError('data-types: unexpected artifact URL') - if artifact.parts[2].media_type != 'image/svg+xml': - raise AssertionError('data-types: unexpected artifact media type') - - -def assert_extended_card_metadata(card: a2a_pb2.AgentCard) -> None: - if not card.capabilities.extended_agent_card: - raise AssertionError('extended-card: capability flag was not set') - if '(extended)' not in card.description: - raise AssertionError('extended-card: description did not include extended marker') - if card.security_schemes: - if EXTENDED_CARD_SCHEME_ID not in card.security_schemes: - raise AssertionError('extended-card: bearer_token security scheme missing') - scheme = card.security_schemes[EXTENDED_CARD_SCHEME_ID] - if scheme.http_auth_security_scheme.scheme != 'Bearer': - raise AssertionError('extended-card: unexpected HTTP auth scheme') - skill_ids = {skill.id for skill in card.skills} - expected_skills = { - 'message-only', - 'task-lifecycle', - 'task-failure', - 'task-cancel', - 'multi-turn', - 'streaming', - 'long-running', - 'data-types', - } - if skill_ids != expected_skills: - raise AssertionError(f'extended-card: unexpected skills {sorted(skill_ids)!r}') +async def make_client(card_url: str, *, streaming: bool) -> Client: + config = ClientConfig( + streaming=streaming, + grpc_channel_factory=lambda addr: grpc.aio.insecure_channel(grpc_target(addr)), + supported_protocol_bindings=[ + TransportProtocol.GRPC, + TransportProtocol.JSONRPC, + TransportProtocol.HTTP_JSON, + ], + httpx_client=httpx.AsyncClient(timeout=5.0), + ) + return await create_client(card_url, client_config=config) + +# ── push-config transport dispatch ─────────────────────────────────────────── class JsonRpcCompatError(RuntimeError): - def __init__(self, code: int, message: str, data: Any = None) -> None: + def __init__(self, code: int, message: str) -> None: super().__init__(message) self.code = code - self.data = data class RestCompatError(RuntimeError): - def __init__(self, status_code: int, message: str, data: Any = None) -> None: + def __init__(self, status_code: int, message: str) -> None: super().__init__(message) self.status_code = status_code - self.data = data def resolve_card_url(base_url: str) -> str: @@ -204,738 +123,318 @@ def resolve_card_url(base_url: str) -> str: return f"{base_url.rstrip('/')}/.well-known/agent-card.json" -def grpc_target(address: str) -> str: - if '://' not in address: - return address - - parsed = urlparse(address) - return parsed.netloc or address - - async def load_agent_card(card_url: str) -> a2a_pb2.AgentCard: - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.get(resolve_card_url(card_url)) - response.raise_for_status() - payload = response.json() - return ParseDict(payload, a2a_pb2.AgentCard()) + async with httpx.AsyncClient(timeout=5.0) as http: + r = await http.get(resolve_card_url(card_url)) + r.raise_for_status() + return ParseDict(r.json(), a2a_pb2.AgentCard()) def primary_interface(card: a2a_pb2.AgentCard) -> a2a_pb2.AgentInterface: if not card.supported_interfaces: - raise AssertionError('agent card did not expose any supported interfaces') - - for protocol_binding in (TransportProtocol.JSONRPC.value, TransportProtocol.HTTP_JSON.value): - for interface in card.supported_interfaces: - if interface.protocol_binding == protocol_binding: - return interface - + raise RuntimeError('agent card did not expose any supported interfaces') + for binding in (TransportProtocol.JSONRPC.value, TransportProtocol.HTTP_JSON.value): + for iface in card.supported_interfaces: + if iface.protocol_binding == binding: + return iface return card.supported_interfaces[0] -def build_create_push_config_params( - config: a2a_pb2.TaskPushNotificationConfig, -) -> dict[str, Any]: - return MessageToDict(config, preserving_proto_field_name=False) - - -def build_rest_create_push_config_payload( - config: a2a_pb2.TaskPushNotificationConfig, -) -> dict[str, Any]: - flat_payload = MessageToDict(config, preserving_proto_field_name=False) - flat_payload.pop('taskId', None) - return flat_payload - - -def parse_push_config_entry(payload: dict[str, Any]) -> a2a_pb2.TaskPushNotificationConfig: - return ParseDict( - payload, - a2a_pb2.TaskPushNotificationConfig(), - ) - - -def parse_push_config_list_result(result: Any) -> list[a2a_pb2.TaskPushNotificationConfig]: - if not isinstance(result, dict): - raise AssertionError(f'list push configs: unexpected result {result!r}') - entries = result.get('configs', []) - if not isinstance(entries, list): - raise AssertionError(f'list push configs: unexpected configs field {result!r}') - - configs: list[a2a_pb2.TaskPushNotificationConfig] = [] - for entry in entries: - if not isinstance(entry, dict): - raise AssertionError(f'list push configs: unexpected JSON-RPC entry {entry!r}') - configs.append(parse_push_config_entry(entry)) - return configs - - -async def send_jsonrpc_request( - rpc_url: str, - method: str, - params: dict[str, Any], - *, - allow_missing_result: bool = False, -) -> Any: - payload = { - 'jsonrpc': '2.0', - 'id': str(uuid4()), - 'method': method, - 'params': params, - } - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.post( - rpc_url, - json=payload, - headers={VERSION_HEADER: PROTOCOL_VERSION_1_0}, - ) - response.raise_for_status() - response_payload = response.json() - - error = response_payload.get('error') - if isinstance(error, dict): - code = error.get('code') - if not isinstance(code, int): - code = 0 - raise JsonRpcCompatError( - code, - str(error.get('message', 'JSON-RPC request failed')), - error.get('data'), - ) - - if 'result' not in response_payload: - if allow_missing_result: +async def _jsonrpc(url: str, method: str, params: dict, + allow_no_result: bool = False) -> Any: + from uuid import uuid4 + body = {'jsonrpc': '2.0', 'id': str(uuid4()), 'method': method, 'params': params} + async with httpx.AsyncClient(timeout=5.0) as http: + r = await http.post(url, json=body, headers={VERSION_HEADER: PROTOCOL_VERSION_1_0}) + r.raise_for_status() + resp = r.json() + if 'error' in resp and isinstance(resp['error'], dict): + err = resp['error'] + raise JsonRpcCompatError(err.get('code', 0), str(err.get('message', 'error'))) + if 'result' not in resp: + if allow_no_result: return None - raise AssertionError(f'JSON-RPC response for {method} did not include a result') - - return response_payload['result'] - - -async def send_rest_request( - method: str, - url: str, - *, - json_body: dict[str, Any] | None = None, -) -> Any: - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.request( - method, - url, - json=json_body, - headers={VERSION_HEADER: PROTOCOL_VERSION_1_0}, - ) + raise RuntimeError(f'JSON-RPC {method}: missing result') + return resp['result'] - if response.status_code >= 400: - try: - payload = response.json() - except json.JSONDecodeError: - payload = None - raise RestCompatError(response.status_code, response.text, payload) - if response.status_code == 204 or not response.content: +async def _rest(method: str, url: str, json_body: dict | None = None) -> Any: + async with httpx.AsyncClient(timeout=5.0) as http: + r = await http.request(method, url, json=json_body, + headers={VERSION_HEADER: PROTOCOL_VERSION_1_0}) + if r.status_code >= 400: + raise RestCompatError(r.status_code, r.text) + if r.status_code == 204 or not r.content: return None - return response.json() - - -async def create_task_push_config_jsonrpc( - rpc_url: str, - config: a2a_pb2.TaskPushNotificationConfig, -) -> a2a_pb2.TaskPushNotificationConfig: - result = await send_jsonrpc_request( - rpc_url, - 'CreateTaskPushNotificationConfig', - build_create_push_config_params(config), - ) - if not isinstance(result, dict): - raise AssertionError(f'create push config: unexpected JSON-RPC result {result!r}') - return parse_push_config_entry(result) - - -async def get_task_push_config_jsonrpc( - rpc_url: str, - request: a2a_pb2.GetTaskPushNotificationConfigRequest, -) -> a2a_pb2.TaskPushNotificationConfig: - result = await send_jsonrpc_request( - rpc_url, - 'GetTaskPushNotificationConfig', - MessageToDict(request, preserving_proto_field_name=False), - ) - if not isinstance(result, dict): - raise AssertionError(f'get push config: unexpected JSON-RPC result {result!r}') - return parse_push_config_entry(result) - - -async def list_task_push_configs_jsonrpc( - rpc_url: str, - request: a2a_pb2.ListTaskPushNotificationConfigsRequest, -) -> list[a2a_pb2.TaskPushNotificationConfig]: - result = await send_jsonrpc_request( - rpc_url, - 'ListTaskPushNotificationConfigs', - MessageToDict(request, preserving_proto_field_name=False), - ) - return parse_push_config_list_result(result) + return r.json() -async def delete_task_push_config_jsonrpc( - rpc_url: str, - request: a2a_pb2.DeleteTaskPushNotificationConfigRequest, -) -> None: - await send_jsonrpc_request( - rpc_url, - 'DeleteTaskPushNotificationConfig', - MessageToDict(request, preserving_proto_field_name=False), - allow_missing_result=True, - ) - - -async def create_task_push_config_rest( - base_url: str, +async def push_create( + card: a2a_pb2.AgentCard, + client: Client, config: a2a_pb2.TaskPushNotificationConfig, ) -> a2a_pb2.TaskPushNotificationConfig: - result = await send_rest_request( - 'POST', - f"{base_url.rstrip('/')}/tasks/{config.task_id}/pushNotificationConfigs", - json_body=build_rest_create_push_config_payload(config), - ) - if not isinstance(result, dict): - raise AssertionError(f'create push config: unexpected REST result {result!r}') - return parse_push_config_entry(result) - - -async def get_task_push_config_rest( - base_url: str, - request: a2a_pb2.GetTaskPushNotificationConfigRequest, + iface = primary_interface(card) + if iface.protocol_binding == TransportProtocol.GRPC.value: + return await client.create_task_push_notification_config(config) + payload = MessageToDict(config, preserving_proto_field_name=False) + if iface.protocol_binding == TransportProtocol.JSONRPC.value: + result = await _jsonrpc(iface.url, 'CreateTaskPushNotificationConfig', payload) + return ParseDict(result, a2a_pb2.TaskPushNotificationConfig()) + payload.pop('taskId', None) + result = await _rest('POST', + f"{iface.url.rstrip('/')}/tasks/{config.task_id}/pushNotificationConfigs", + payload) + return ParseDict(result, a2a_pb2.TaskPushNotificationConfig()) + + +async def push_get( + card: a2a_pb2.AgentCard, + client: Client, + req: a2a_pb2.GetTaskPushNotificationConfigRequest, ) -> a2a_pb2.TaskPushNotificationConfig: - result = await send_rest_request( + iface = primary_interface(card) + if iface.protocol_binding == TransportProtocol.GRPC.value: + return await client.get_task_push_notification_config(req) + payload = MessageToDict(req, preserving_proto_field_name=False) + if iface.protocol_binding == TransportProtocol.JSONRPC.value: + result = await _jsonrpc(iface.url, 'GetTaskPushNotificationConfig', payload) + return ParseDict(result, a2a_pb2.TaskPushNotificationConfig()) + result = await _rest( 'GET', - f"{base_url.rstrip('/')}/tasks/{request.task_id}/pushNotificationConfigs/{request.id}", + f"{iface.url.rstrip('/')}/tasks/{req.task_id}/pushNotificationConfigs/{req.id}", ) - if not isinstance(result, dict): - raise AssertionError(f'get push config: unexpected REST result {result!r}') - return parse_push_config_entry(result) + return ParseDict(result, a2a_pb2.TaskPushNotificationConfig()) -async def list_task_push_configs_rest( - base_url: str, - request: a2a_pb2.ListTaskPushNotificationConfigsRequest, +async def push_list( + card: a2a_pb2.AgentCard, + client: Client, + req: a2a_pb2.ListTaskPushNotificationConfigsRequest, ) -> list[a2a_pb2.TaskPushNotificationConfig]: - result = await send_rest_request( + iface = primary_interface(card) + if iface.protocol_binding == TransportProtocol.GRPC.value: + resp = await client.list_task_push_notification_configs(req) + return list(resp.configs) + payload = MessageToDict(req, preserving_proto_field_name=False) + if iface.protocol_binding == TransportProtocol.JSONRPC.value: + result = await _jsonrpc(iface.url, 'ListTaskPushNotificationConfigs', payload) + entries = result.get('configs', []) if isinstance(result, dict) else [] + return [ParseDict(e, a2a_pb2.TaskPushNotificationConfig()) for e in entries] + result = await _rest( 'GET', - f"{base_url.rstrip('/')}/tasks/{request.task_id}/pushNotificationConfigs", + f"{iface.url.rstrip('/')}/tasks/{req.task_id}/pushNotificationConfigs", ) - return parse_push_config_list_result(result) + entries = result.get('configs', []) if isinstance(result, dict) else [] + return [ParseDict(e, a2a_pb2.TaskPushNotificationConfig()) for e in entries] -async def delete_task_push_config_rest( - base_url: str, - request: a2a_pb2.DeleteTaskPushNotificationConfigRequest, +async def push_delete( + card: a2a_pb2.AgentCard, + client: Client, + req: a2a_pb2.DeleteTaskPushNotificationConfigRequest, ) -> None: - await send_rest_request( + iface = primary_interface(card) + if iface.protocol_binding == TransportProtocol.GRPC.value: + await client.delete_task_push_notification_config(req) + return + payload = MessageToDict(req, preserving_proto_field_name=False) + if iface.protocol_binding == TransportProtocol.JSONRPC.value: + await _jsonrpc(iface.url, 'DeleteTaskPushNotificationConfig', payload, + allow_no_result=True) + return + await _rest( 'DELETE', - f"{base_url.rstrip('/')}/tasks/{request.task_id}/pushNotificationConfigs/{request.id}", + f"{iface.url.rstrip('/')}/tasks/{req.task_id}/pushNotificationConfigs/{req.id}", ) -async def create_probe_client(card_url: str, streaming: bool) -> Client: - config = ClientConfig( - streaming=streaming, - grpc_channel_factory=lambda address: grpc.aio.insecure_channel(grpc_target(address)), - supported_protocol_bindings=[ - TransportProtocol.GRPC, - TransportProtocol.JSONRPC, - TransportProtocol.HTTP_JSON, - ], - httpx_client=httpx.AsyncClient(timeout=5.0), - ) - return await create_client(card_url, client_config=config) +# ── subcommand handlers ────────────────────────────────────────────────────── +async def run(args: argparse.Namespace) -> None: + card_url: str = args.card_url + subcommand: str = args.subcommand -async def create_task_push_config_grpc( - client: Client, - config: a2a_pb2.TaskPushNotificationConfig, -) -> a2a_pb2.TaskPushNotificationConfig: - return await client.create_task_push_notification_config(config) - + if subcommand == 'send-message': + req = ParseDict(json.loads(args.message_json), a2a_pb2.SendMessageRequest()) + client = await make_client(card_url, streaming=False) + try: + responses = [r async for r in client.send_message(req)] + finally: + await client.close() + if not responses: + fail(-32000, 'send-message: no response received') + resp = responses[0] + if resp.HasField('task'): + print(event_envelope('task', resp.task)) + elif resp.HasField('message'): + print(event_envelope('message', resp.message)) + else: + fail(-32000, 'send-message: unexpected response variant') + return -async def get_task_push_config_grpc( - client: Client, - request: a2a_pb2.GetTaskPushNotificationConfigRequest, -) -> a2a_pb2.TaskPushNotificationConfig: - return await client.get_task_push_notification_config(request) + if subcommand == 'send-streaming-message': + req = ParseDict(json.loads(args.message_json), a2a_pb2.SendMessageRequest()) + client = await make_client(card_url, streaming=True) + try: + responses = [r async for r in client.send_message(req)] + finally: + await client.close() + for resp in responses: + if resp.HasField('task'): + print(event_envelope('task', resp.task)) + elif resp.HasField('message'): + print(event_envelope('message', resp.message)) + elif resp.HasField('status_update'): + print(event_envelope('task-status-update', resp.status_update)) + elif resp.HasField('artifact_update'): + print(event_envelope('task-artifact-update', resp.artifact_update)) + return + if subcommand == 'get-task': + client = await make_client(card_url, streaming=False) + try: + task = await client.get_task(a2a_pb2.GetTaskRequest(id=args.task_id)) + finally: + await client.close() + print(to_json_str(task)) + return -async def list_task_push_configs_grpc( - client: Client, - request: a2a_pb2.ListTaskPushNotificationConfigsRequest, -) -> list[a2a_pb2.TaskPushNotificationConfig]: - response = await client.list_task_push_notification_configs(request) - return list(response.configs) + if subcommand == 'cancel-task': + client = await make_client(card_url, streaming=False) + try: + task = await client.cancel_task(a2a_pb2.CancelTaskRequest(id=args.task_id)) + finally: + await client.close() + print(to_json_str(task)) + return + if subcommand == 'list-tasks': + client = await make_client(card_url, streaming=False) + try: + resp = await client.list_tasks( + a2a_pb2.ListTasksRequest(context_id=args.context_id) + ) + finally: + await client.close() + print(to_json_str(resp)) + return -async def delete_task_push_config_grpc( - client: Client, - request: a2a_pb2.DeleteTaskPushNotificationConfigRequest, -) -> None: - await client.delete_task_push_notification_config(request) + if subcommand == 'create-push-config': + config = ParseDict(json.loads(args.config_json), + a2a_pb2.TaskPushNotificationConfig()) + card = await load_agent_card(card_url) + client = await make_client(card_url, streaming=False) + try: + result = await push_create(card, client, config) + finally: + await client.close() + print(json.dumps(push_config_to_dict(result))) + return + if subcommand == 'get-push-config': + req = a2a_pb2.GetTaskPushNotificationConfigRequest( + task_id=args.task_id, id=args.config_id + ) + card = await load_agent_card(card_url) + client = await make_client(card_url, streaming=False) + try: + result = await push_get(card, client, req) + finally: + await client.close() + print(json.dumps(push_config_to_dict(result))) + return -async def collect_responses( - client: Client, - request: a2a_pb2.SendMessageRequest, -) -> list[a2a_pb2.StreamResponse]: - responses: list[a2a_pb2.StreamResponse] = [] - async for response in client.send_message(request): - responses.append(response) - return responses + if subcommand == 'list-push-configs': + req = a2a_pb2.ListTaskPushNotificationConfigsRequest(task_id=args.task_id) + card = await load_agent_card(card_url) + client = await make_client(card_url, streaming=False) + try: + configs = await push_list(card, client, req) + finally: + await client.close() + print(json.dumps({'configs': [push_config_to_dict(c) for c in configs]})) + return + if subcommand == 'delete-push-config': + req = a2a_pb2.DeleteTaskPushNotificationConfigRequest( + task_id=args.task_id, id=args.config_id + ) + card = await load_agent_card(card_url) + client = await make_client(card_url, streaming=False) + try: + await push_delete(card, client, req) + finally: + await client.close() + return -def expect_task_response(response: a2a_pb2.StreamResponse, kind: str) -> a2a_pb2.Task: - if not response.HasField('task'): - raise AssertionError(f'{kind}: expected task response') - return response.task + if subcommand == 'get-extended-card': + client = await make_client(card_url, streaming=False) + try: + card = await client.get_extended_agent_card( + a2a_pb2.GetExtendedAgentCardRequest() + ) + finally: + await client.close() + print(to_json_str(card)) + return + fail(-32000, f'unknown subcommand: {subcommand}') -def expect_message_response(response: a2a_pb2.StreamResponse, kind: str) -> a2a_pb2.Message: - if not response.HasField('message'): - raise AssertionError(f'{kind}: expected message response') - return response.message +def make_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description='A2A Python probe adapter.') + parser.add_argument('--card-url', required=True) + sub = parser.add_subparsers(dest='subcommand', required=True) -async def expect_error( - awaitable: asyncio.Future | asyncio.Task | object, - expected_substring: str | tuple[str, ...], -) -> None: - expected_substrings = ( - (expected_substring,) - if isinstance(expected_substring, str) - else expected_substring - ) - try: - await awaitable # type: ignore[arg-type] - except Exception as exc: # noqa: BLE001 - message = str(exc).lower() - if not any(candidate.lower() in message for candidate in expected_substrings): - raise AssertionError( - f'expected error containing one of {expected_substrings!r}, got {exc!r}' - ) from exc - return - raise AssertionError(f'expected error containing one of {expected_substrings!r}') - - -async def wait_for_task_state(client: Client, task_id: str, expected_state: int) -> a2a_pb2.Task: - deadline = asyncio.get_running_loop().time() + 2.0 - while asyncio.get_running_loop().time() < deadline: - task = await client.get_task(a2a_pb2.GetTaskRequest(id=task_id)) - if task.status.state == expected_state: - return task - await asyncio.sleep(0.05) - raise AssertionError(f'timed out waiting for task {task_id} to reach state {expected_state}') - - -async def assert_task_streaming(card_url: str, server_prefix: str) -> None: - unary_client = await create_probe_client(card_url, streaming=False) - try: - unary_responses = await collect_responses(unary_client, new_request(REQUEST_TEXT, False)) - if len(unary_responses) != 1: - raise AssertionError(f'unary: expected exactly one response, got {len(unary_responses)}') - unary_task = expect_task_response(unary_responses[0], 'unary') - if task_status_text(unary_task) != f'{server_prefix} server received: {REQUEST_TEXT}': - raise AssertionError('unary: unexpected response text') - finally: - await unary_client.close() - - streaming_client = await create_probe_client(card_url, streaming=True) - try: - streaming_responses = await collect_responses(streaming_client, new_request(REQUEST_TEXT, False)) - texts: list[str] = [] - for response in streaming_responses: - if response.HasField('task'): - texts.append(task_status_text(response.task)) - elif response.HasField('message'): - texts.append(first_message_text(response.message)) - elif response.HasField('status_update') and response.status_update.status.HasField('message'): - texts.append(first_message_text(response.status_update.status.message)) - if f'{server_prefix} server received: {REQUEST_TEXT}' not in texts: - raise AssertionError(f'streaming: expected response text in {texts!r}') - finally: - await streaming_client.close() - - -async def assert_task_lifecycle(card_url: str, server_prefix: str) -> None: - client = await create_probe_client(card_url, streaming=False) - try: - completed_task = expect_task_response( - (await collect_responses(client, new_request(REQUEST_TEXT, False)))[0], - 'completed task', - ) - if completed_task.status.state != a2a_pb2.TaskState.TASK_STATE_COMPLETED: - raise AssertionError('completed task: expected completed state') - if task_status_text(completed_task) != f'{server_prefix} server received: {REQUEST_TEXT}': - raise AssertionError('completed task: unexpected status text') - assert_task_history_payloads(completed_task, [REQUEST_TEXT], 'completed task history') - - fetched_task = await client.get_task(a2a_pb2.GetTaskRequest(id=completed_task.id)) - if fetched_task.status.state != a2a_pb2.TaskState.TASK_STATE_COMPLETED: - raise AssertionError('fetched task: expected completed state') - if task_status_text(fetched_task) != f'{server_prefix} server received: {REQUEST_TEXT}': - raise AssertionError('fetched task: unexpected status text') - assert_task_history_payloads(fetched_task, [REQUEST_TEXT], 'fetched task history') - - listed_tasks = await client.list_tasks( - a2a_pb2.ListTasksRequest(context_id=completed_task.context_id) - ) - if not any(task.id == completed_task.id for task in listed_tasks.tasks): - raise AssertionError('list tasks: completed task id was not returned') + m = sub.add_parser('send-message') + m.add_argument('--message-json', required=True) - pending_task = expect_task_response( - (await collect_responses(client, new_request(PENDING_REQUEST_TEXT, True)))[0], - 'pending task', - ) - if pending_task.status.state != a2a_pb2.TaskState.TASK_STATE_WORKING: - raise AssertionError('pending task: expected working state') - if task_status_text(pending_task) != f'{server_prefix} server received: {PENDING_REQUEST_TEXT}': - raise AssertionError('pending task: unexpected status text') - - canceled_task = await client.cancel_task(a2a_pb2.CancelTaskRequest(id=pending_task.id)) - if canceled_task.status.state != a2a_pb2.TaskState.TASK_STATE_CANCELED: - raise AssertionError('canceled task: expected canceled state') - if task_status_text(canceled_task) != f'{server_prefix} server canceled task': - raise AssertionError('canceled task: unexpected status text') - - fetched_canceled_task = await client.get_task(a2a_pb2.GetTaskRequest(id=pending_task.id)) - if fetched_canceled_task.status.state != a2a_pb2.TaskState.TASK_STATE_CANCELED: - raise AssertionError('fetched canceled task: expected canceled state') - if task_status_text(fetched_canceled_task) != f'{server_prefix} server canceled task': - raise AssertionError('fetched canceled task: unexpected status text') - - await expect_error( - client.get_task(a2a_pb2.GetTaskRequest(id=str(uuid4()))), - 'task', - ) - await expect_error( - client.cancel_task(a2a_pb2.CancelTaskRequest(id=completed_task.id)), - ('cancel', 'terminal state', 'not cancelable'), - ) - finally: - await client.close() + sm = sub.add_parser('send-streaming-message') + sm.add_argument('--message-json', required=True) + gt = sub.add_parser('get-task') + gt.add_argument('--task-id', required=True) -async def assert_push_config( - card_url: str, - server_prefix: str, - expect_push_supported: bool, - expected_push_error_code: int, - relaxed_error_checks: bool, -) -> None: - client = await create_probe_client(card_url, streaming=False) - try: - card = getattr(client, '_card', None) - if not isinstance(card, a2a_pb2.AgentCard): - card = await load_agent_card(card_url) - agent_interface = primary_interface(card) - uses_grpc = agent_interface.protocol_binding == TransportProtocol.GRPC.value - uses_jsonrpc = agent_interface.protocol_binding == TransportProtocol.JSONRPC.value - - completed_task = expect_task_response( - (await collect_responses(client, new_request(REQUEST_TEXT, False)))[0], - 'push-config task', - ) - if completed_task.status.state != a2a_pb2.TaskState.TASK_STATE_COMPLETED: - raise AssertionError('push-config task: expected completed state') - if task_status_text(completed_task) != f'{server_prefix} server received: {REQUEST_TEXT}': - raise AssertionError('push-config task: unexpected status text') - assert_task_history_payloads(completed_task, [REQUEST_TEXT], 'push-config task history') - - push_config = a2a_pb2.TaskPushNotificationConfig( - task_id=completed_task.id, - id='interop-config', - url='https://example.invalid/webhook', - token='interop-token', - authentication=a2a_pb2.AuthenticationInfo( - scheme='Bearer', - credentials='interop-credential', - ), - ) + ct = sub.add_parser('cancel-task') + ct.add_argument('--task-id', required=True) - if not expect_push_supported: - try: - if uses_grpc: - await create_task_push_config_grpc(client, push_config) - elif uses_jsonrpc: - await create_task_push_config_jsonrpc(agent_interface.url, push_config) - else: - await create_task_push_config_rest( - agent_interface.url, - push_config, - ) - except Exception as exc: # noqa: BLE001 - if not relaxed_error_checks and expected_push_error_code != 0: - if isinstance(exc, JsonRpcCompatError): - error_text = f'{exc.code} {exc}' - elif isinstance(exc, RestCompatError): - error_text = f'{exc.status_code} {exc}' - else: - error_text = str(exc) - if str(expected_push_error_code) not in error_text: - normalized_error_text = error_text.upper() - accepts_rest_unsupported = ( - isinstance(exc, RestCompatError) - and expected_push_error_code == -32003 - and ( - '501' in error_text - or 'UNIMPLEMENTED' in normalized_error_text - or 'PUSH_NOTIFICATION_NOT_SUPPORTED' in normalized_error_text - ) - ) - accepts_grpc_unsupported = ( - uses_grpc - and expected_push_error_code == -32003 - and 'UNIMPLEMENTED' in normalized_error_text - ) - if accepts_rest_unsupported or accepts_grpc_unsupported: - return - raise AssertionError( - f'expected push error code {expected_push_error_code}, got {error_text!r}' - ) from exc - return - raise AssertionError('expected push configuration to be unsupported') - - if uses_grpc: - created_config = await create_task_push_config_grpc(client, push_config) - elif uses_jsonrpc: - created_config = await create_task_push_config_jsonrpc(agent_interface.url, push_config) - else: - created_config = await create_task_push_config_rest( - agent_interface.url, - push_config, - ) - assert_task_push_config(created_config, completed_task.id, push_config, 'created push config') + lt = sub.add_parser('list-tasks') + lt.add_argument('--context-id', required=True) - get_request = a2a_pb2.GetTaskPushNotificationConfigRequest( - task_id=completed_task.id, - id=push_config.id, - ) - if uses_grpc: - fetched_config = await get_task_push_config_grpc(client, get_request) - elif uses_jsonrpc: - fetched_config = await get_task_push_config_jsonrpc(agent_interface.url, get_request) - else: - fetched_config = await get_task_push_config_rest(agent_interface.url, get_request) - assert_task_push_config(fetched_config, completed_task.id, push_config, 'fetched push config') - - list_request = a2a_pb2.ListTaskPushNotificationConfigsRequest(task_id=completed_task.id) - if uses_grpc: - listed_configs = await list_task_push_configs_grpc(client, list_request) - elif uses_jsonrpc: - listed_configs = await list_task_push_configs_jsonrpc(agent_interface.url, list_request) - else: - listed_configs = await list_task_push_configs_rest(agent_interface.url, list_request) + cpc = sub.add_parser('create-push-config') + cpc.add_argument('--config-json', required=True) - if len(listed_configs) != 1: - raise AssertionError('list push configs: expected one config') - assert_task_push_config(listed_configs[0], completed_task.id, push_config, 'listed push config') + gpc = sub.add_parser('get-push-config') + gpc.add_argument('--task-id', required=True) + gpc.add_argument('--config-id', required=True) - delete_request = a2a_pb2.DeleteTaskPushNotificationConfigRequest( - task_id=completed_task.id, - id=push_config.id, - ) - if uses_grpc: - await delete_task_push_config_grpc(client, delete_request) - listed_configs = await list_task_push_configs_grpc(client, list_request) - elif uses_jsonrpc: - await delete_task_push_config_jsonrpc(agent_interface.url, delete_request) - listed_configs = await list_task_push_configs_jsonrpc(agent_interface.url, list_request) - else: - await delete_task_push_config_rest(agent_interface.url, delete_request) - listed_configs = await list_task_push_configs_rest(agent_interface.url, list_request) - - if listed_configs: - raise AssertionError('list push configs after delete: expected no configs') - finally: - await client.close() - - -async def assert_scenario_parity(card_url: str, server_prefix: str) -> None: - unary_client = await create_probe_client(card_url, streaming=False) - streaming_client = await create_probe_client(card_url, streaming=True) - try: - message_only = expect_message_response( - (await collect_responses(unary_client, new_request(MESSAGE_ONLY_REQUEST_TEXT, False)))[0], - 'message-only', - ) - if first_message_text(message_only) != f'{server_prefix} server message-only response': - raise AssertionError('message-only: unexpected response text') + lpc = sub.add_parser('list-push-configs') + lpc.add_argument('--task-id', required=True) - failed_task = expect_task_response( - (await collect_responses(unary_client, new_request(TASK_FAILURE_REQUEST_TEXT, False)))[0], - 'task-failure', - ) - if failed_task.status.state != a2a_pb2.TaskState.TASK_STATE_FAILED: - raise AssertionError('task-failure: expected failed state') - if task_status_text(failed_task) != f'{server_prefix} server failed task': - raise AssertionError('task-failure: unexpected status text') - - input_required_task = expect_task_response( - (await collect_responses(unary_client, new_request(MULTI_TURN_START_REQUEST_TEXT, False)))[0], - 'multi-turn start', - ) - if input_required_task.status.state != a2a_pb2.TaskState.TASK_STATE_INPUT_REQUIRED: - raise AssertionError('multi-turn start: expected input-required state') - if task_status_text(input_required_task) != f'{server_prefix} server needs more input': - raise AssertionError('multi-turn start: unexpected status text') - assert_task_history_payloads(input_required_task, [MULTI_TURN_START_REQUEST_TEXT], 'multi-turn start') - - continued_task = expect_task_response( - ( - await collect_responses( - unary_client, - new_request( - MULTI_TURN_CONTINUE_REQUEST_TEXT, - False, - task_id=input_required_task.id, - context_id=input_required_task.context_id, - ), - ) - )[0], - 'multi-turn continue', - ) - if continued_task.status.state != a2a_pb2.TaskState.TASK_STATE_COMPLETED: - raise AssertionError('multi-turn continue: expected completed state') - if task_status_text(continued_task) != f'{server_prefix} server multi-turn completed': - raise AssertionError('multi-turn continue: unexpected status text') - assert_task_history_payloads( - continued_task, - [MULTI_TURN_START_REQUEST_TEXT, MULTI_TURN_CONTINUE_REQUEST_TEXT], - 'multi-turn continue', - ) + dpc = sub.add_parser('delete-push-config') + dpc.add_argument('--task-id', required=True) + dpc.add_argument('--config-id', required=True) - streaming_responses = await collect_responses( - streaming_client, - new_request(STREAMING_REQUEST_TEXT, False), - ) - saw_streaming_start = False - saw_streaming_complete = False - saw_append = False - streaming_chunks: list[str] = [] - for response in streaming_responses: - if response.HasField('task'): - saw_streaming_start = True - if response.task.status.state != a2a_pb2.TaskState.TASK_STATE_WORKING: - raise AssertionError('streaming: expected initial working task') - if task_status_text(response.task) != f'{server_prefix} server streaming started': - raise AssertionError('streaming: unexpected start text') - elif response.HasField('artifact_update'): - artifact = response.artifact_update.artifact - if not artifact.parts or not artifact.parts[0].text: - raise AssertionError('streaming: artifact update missing text part') - streaming_chunks.append(artifact.parts[0].text) - if response.artifact_update.append: - saw_append = True - elif response.HasField('status_update'): - saw_streaming_complete = True - if response.status_update.status.state != a2a_pb2.TaskState.TASK_STATE_COMPLETED: - raise AssertionError('streaming: expected completed status update') - if first_message_text(response.status_update.status.message) != f'{server_prefix} server streaming complete': - raise AssertionError('streaming: unexpected completion text') - else: - raise AssertionError('streaming: unexpected response variant') - if not saw_streaming_start or not saw_streaming_complete or not saw_append: - raise AssertionError('streaming: missing expected task/artifact/status events') - if streaming_chunks != ['streaming chunk 1', 'streaming chunk 2']: - raise AssertionError(f'streaming: unexpected chunks {streaming_chunks!r}') - - long_running_task = expect_task_response( - (await collect_responses(unary_client, new_request(LONG_RUNNING_REQUEST_TEXT, True)))[0], - 'long-running', - ) - if long_running_task.status.state == a2a_pb2.TaskState.TASK_STATE_WORKING: - if task_status_text(long_running_task) != f'{server_prefix} server long-running started': - raise AssertionError('long-running: unexpected start text') - long_running_completed = await wait_for_task_state( - unary_client, - long_running_task.id, - a2a_pb2.TaskState.TASK_STATE_COMPLETED, - ) - elif long_running_task.status.state == a2a_pb2.TaskState.TASK_STATE_COMPLETED: - long_running_completed = long_running_task - else: - raise AssertionError( - f'long-running: unexpected initial state {long_running_task.status.state}' - ) - if task_status_text(long_running_completed) != f'{server_prefix} server long-running complete': - raise AssertionError('long-running: unexpected completion text') + sub.add_parser('get-extended-card') - data_types_task = expect_task_response( - (await collect_responses(unary_client, new_request(DATA_TYPES_REQUEST_TEXT, False)))[0], - 'data-types', - ) - if data_types_task.status.state != a2a_pb2.TaskState.TASK_STATE_COMPLETED: - raise AssertionError('data-types: expected completed state') - if task_status_text(data_types_task) != f'{server_prefix} server data-types ready': - raise AssertionError('data-types: unexpected status text') - assert_data_types_task(data_types_task) - - extended_card = await unary_client.get_extended_agent_card( - a2a_pb2.GetExtendedAgentCardRequest() - ) - assert_extended_card_metadata(extended_card) - finally: - await unary_client.close() - await streaming_client.close() - - -async def run_scenario(args: argparse.Namespace) -> None: - if args.scenario == 'core': - await assert_task_streaming(args.card_url, args.server_prefix) - await assert_task_lifecycle(args.card_url, args.server_prefix) - await assert_push_config( - args.card_url, - args.server_prefix, - not args.expect_push_unsupported, - args.expected_push_error_code, - args.relaxed_error_checks, - ) - return - if args.scenario == 'task-streaming': - await assert_task_streaming(args.card_url, args.server_prefix) - return - if args.scenario == 'task-lifecycle': - await assert_task_lifecycle(args.card_url, args.server_prefix) - return - if args.scenario == 'push-config': - await assert_push_config( - args.card_url, - args.server_prefix, - not args.expect_push_unsupported, - args.expected_push_error_code, - args.relaxed_error_checks, - ) - return - if args.scenario == 'parity': - await assert_scenario_parity(args.card_url, args.server_prefix) - return - raise AssertionError(f'unsupported scenario {args.scenario!r}') + return parser -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description='Run the Python A2A interop probe.') - parser.add_argument('--card-url', required=True) - parser.add_argument('--server-prefix', required=True) - parser.add_argument( - '--scenario', - default='core', - choices=('core', 'task-streaming', 'task-lifecycle', 'push-config', 'parity'), - ) - parser.add_argument('--expect-push-supported', action='store_true') - parser.add_argument('--expect-push-unsupported', action='store_true') - parser.add_argument('--expected-push-error-code', type=int, default=0) - parser.add_argument('--relaxed-error-checks', action='store_true') - return parser.parse_args() +def main() -> None: + args = make_parser().parse_args() + async def _run() -> None: + try: + await run(args) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 + code, message = extract_a2a_error(exc) + fail(code, message) -def main() -> None: - args = parse_args() - asyncio.run(run_scenario(args)) + asyncio.run(_run()) if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/integrations/agntcy-a2a/fixtures/rust/src/bin/interop-rust-probe.rs b/integrations/agntcy-a2a/fixtures/rust/src/bin/interop-rust-probe.rs index 1c6716de..2235672f 100644 --- a/integrations/agntcy-a2a/fixtures/rust/src/bin/interop-rust-probe.rs +++ b/integrations/agntcy-a2a/fixtures/rust/src/bin/interop-rust-probe.rs @@ -1,605 +1,149 @@ // Copyright AGNTCY Contributors (https://github.com/agntcy) // SPDX-License-Identifier: Apache-2.0 +// Thin A2A probe adapter: executes a single A2A operation and writes the raw +// JSON result to stdout. On A2A error, writes {"code":…,"message":"…"} to +// stderr and exits 1. No assertions or orchestration live here — all test +// logic is in the Go/Ginkgo spec tree. + use std::env; use std::process; use std::sync::Arc; -use std::time::Duration; use a2a::*; use a2a_client::A2AClientFactory; use a2a_client::agent_card::AgentCardResolver; use a2a_grpc::GrpcTransportFactory; use futures::StreamExt; -use futures::stream::BoxStream; use serde_json::{Value, json}; -use tokio::time::sleep; - -// This probe is the Rust harness implementation behind the shared Go/Ginkgo interop behaviors. -// It resolves a fixture card, runs one shared behavior scenario at a time, and reports failures -// through its process exit code so the Go suite can treat it like any other harness. -// When adding a new shared behavior, add or extend the scenario mapping here if that behavior -// should be filterable on its own, then keep the assertions aligned with interop_behaviors_test.go. -const REQUEST_TEXT: &str = "ping"; -const PENDING_REQUEST_TEXT: &str = "pending"; -const MESSAGE_ONLY_REQUEST_TEXT: &str = "message-only"; -const TASK_FAILURE_REQUEST_TEXT: &str = "task-failure"; -const MULTI_TURN_START_REQUEST_TEXT: &str = "multi-turn start"; -const MULTI_TURN_CONTINUE_REQUEST_TEXT: &str = "multi-turn continue"; -const STREAMING_REQUEST_TEXT: &str = "streaming"; -const LONG_RUNNING_REQUEST_TEXT: &str = "long-running"; -const DATA_TYPES_REQUEST_TEXT: &str = "data-types"; -const REQUEST_DATA_KIND: &str = "structured"; -const REQUEST_DATA_SCOPE: &str = "interop"; -const REQUEST_METADATA_KEY: &str = "csit"; -const REQUEST_METADATA_VALUE: &str = "multipart"; -const EXTENDED_CARD_SCHEME_ID: &str = "bearer_token"; -const EXPECTED_SKILL_IDS: &[&str] = &[ - "message-only", - "task-lifecycle", - "task-failure", - "task-cancel", - "multi-turn", - "streaming", - "long-running", - "data-types", -]; +// ── error helpers ────────────────────────────────────────────────────────── -struct Args { - card_url: String, - server_prefix: String, - scenario: Scenario, - expect_subscribe_unsupported: bool, - expect_push_supported: bool, - expect_push_unsupported: bool, - relaxed_error_checks: bool, - expected_push_error_code: i32, +fn exit_error(code: i32, message: impl std::fmt::Display) -> ! { + eprintln!("{}", json!({"code": code, "message": message.to_string()})); + process::exit(1); } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Scenario { - All, - Core, - UnaryStreaming, - TaskLifecycle, - PushConfig, - Parity, +fn fail_a2a(error: A2AError) -> ! { + eprintln!("{}", json!({"code": error.code, "message": error.to_string()})); + process::exit(1); } -impl Scenario { - fn parse(value: &str) -> Result { - match value { - "all" => Ok(Self::All), - "core" => Ok(Self::Core), - "task-streaming" => Ok(Self::UnaryStreaming), - "task-lifecycle" => Ok(Self::TaskLifecycle), - "push-config" => Ok(Self::PushConfig), - "parity" => Ok(Self::Parity), - _ => Err(format!( - "--scenario must be one of all, core, task-streaming, task-lifecycle, push-config, or parity; got {value}" - )), - } - } - - fn runs_task_streaming(self) -> bool { - matches!(self, Self::All | Self::Core | Self::UnaryStreaming) - } - - fn runs_task_lifecycle(self) -> bool { - matches!(self, Self::All | Self::Core | Self::TaskLifecycle) - } - - fn runs_push_config(self) -> bool { - matches!(self, Self::All | Self::Core | Self::PushConfig) - } +fn fail(message: impl std::fmt::Display) -> ! { + exit_error(-32000, message); +} - fn runs_parity(self) -> bool { - matches!(self, Self::All | Self::Parity) - } +// ── argument parsing ─────────────────────────────────────────────────────── - fn as_str(self) -> &'static str { - match self { - Self::All => "all", - Self::Core => "core", - Self::UnaryStreaming => "task-streaming", - Self::TaskLifecycle => "task-lifecycle", - Self::PushConfig => "push-config", - Self::Parity => "parity", - } - } +struct ParsedArgs { + card_url: String, + subcommand: String, + rest: Vec, } -fn parse_args() -> Result { - let mut args = env::args().skip(1); - let mut card_url = None; - let mut server_prefix = None; - let mut scenario = Scenario::All; - let mut expect_subscribe_unsupported = false; - let mut expect_push_supported = false; - let mut expect_push_unsupported = false; - let mut relaxed_error_checks = false; - let mut expected_push_error_code = a2a::error_code::PUSH_NOTIFICATION_NOT_SUPPORTED; - - while let Some(arg) = args.next() { - match arg.as_str() { +fn parse_args() -> ParsedArgs { + let all: Vec = env::args().skip(1).collect(); + let mut card_url: Option = None; + let mut i = 0; + while i < all.len() { + match all[i].as_str() { "--card-url" => { - card_url = Some( - args.next() - .ok_or_else(|| "--card-url requires a value".to_string())?, - ); - } - "--server-prefix" => { - server_prefix = Some( - args.next() - .ok_or_else(|| "--server-prefix requires a value".to_string())?, - ); - } - "--scenario" => { - let value = args - .next() - .ok_or_else(|| "--scenario requires a value".to_string())?; - scenario = Scenario::parse(&value)?; - } - "--expect-subscribe-unsupported" => { - expect_subscribe_unsupported = true; - } - "--expect-push-supported" => { - expect_push_supported = true; - } - "--expect-push-unsupported" => { - expect_push_unsupported = true; - } - "--expected-push-error-code" => { - let value = args - .next() - .ok_or_else(|| "--expected-push-error-code requires a value".to_string())?; - expected_push_error_code = value - .parse::() - .map_err(|_| "--expected-push-error-code must be a valid i32".to_string())?; - } - "--relaxed-error-checks" => { - relaxed_error_checks = true; - } - other => { - return Err(format!("unknown argument: {other}")); + i += 1; + if i >= all.len() { + fail("--card-url requires a value"); + } + card_url = Some(all[i].clone()); + i += 1; } + _ => break, } } - - if expect_push_supported && expect_push_unsupported { - return Err( - "--expect-push-supported and --expect-push-unsupported are mutually exclusive" - .to_string(), - ); - } - - Ok(Args { - card_url: card_url.ok_or_else(|| "missing --card-url".to_string())?, - server_prefix: server_prefix.ok_or_else(|| "missing --server-prefix".to_string())?, - scenario, - expect_subscribe_unsupported, - expect_push_supported, - expect_push_unsupported, - relaxed_error_checks, - expected_push_error_code, - }) -} - -fn first_text(message: &Message) -> Result { - message - .parts - .iter() - .find_map(Part::as_text) - .map(ToString::to_string) - .ok_or_else(|| "message contained no text parts".to_string()) -} - -fn assert_text(actual: String, expected: &str, kind: &str) -> Result<(), String> { - if actual == expected { - Ok(()) - } else { - Err(format!( - "unexpected {kind} response text: got {actual:?}, want {expected:?}" - )) - } -} - -fn assert_state(actual: &TaskState, expected: TaskState, kind: &str) -> Result<(), String> { - if *actual == expected { - Ok(()) - } else { - Err(format!( - "unexpected {kind} task state: got {actual:?}, want {expected:?}" - )) - } -} - -fn assert_error_code( - result: Result, - expected_code: i32, - kind: &str, -) -> Result<(), String> { - match result { - Ok(_) => Err(format!( - "expected {kind} to fail with code {expected_code}, but it succeeded" - )), - Err(error) if error.code == expected_code => Ok(()), - Err(error) => Err(format!( - "unexpected {kind} error code: got {}, want {} ({error})", - error.code, expected_code - )), + let card_url = card_url.unwrap_or_else(|| fail("missing --card-url")); + if i >= all.len() { + fail("missing subcommand"); } + let subcommand = all[i].clone(); + let rest = all[(i + 1)..].to_vec(); + ParsedArgs { card_url, subcommand, rest } } -fn assert_failed(result: Result, kind: &str) -> Result<(), String> { - match result { - Ok(_) => Err(format!("expected {kind} to fail, but it succeeded")), - Err(_) => Ok(()), - } -} - -fn assert_push_config( - actual: &TaskPushNotificationConfig, - task_id: &str, - expected: &TaskPushNotificationConfig, - kind: &str, -) -> Result<(), String> { - if actual.task_id != task_id { - return Err(format!( - "unexpected {kind} task id: got {:?}, want {:?}", - actual.task_id, task_id - )); - } - - if actual.url != expected.url || actual.id != expected.id || actual.token != expected.token || actual.authentication != expected.authentication { - return Err(format!( - "unexpected {kind} push config: got {:?}, want {:?}", - actual, expected - )); - } - - Ok(()) -} - -async fn assert_stream_error_code( - result: Result>, A2AError>, - expected_code: i32, - kind: &str, -) -> Result<(), String> { - match result { - Err(error) if error.code == expected_code => Ok(()), - Err(error) => Err(format!( - "unexpected {kind} error code: got {}, want {} ({error})", - error.code, expected_code - )), - Ok(mut stream) => match stream.next().await { - Some(Err(error)) if error.code == expected_code => Ok(()), - Some(Err(error)) => Err(format!( - "unexpected {kind} stream error code: got {}, want {} ({error})", - error.code, expected_code - )), - Some(Ok(_)) => Err(format!( - "expected {kind} to fail with code {expected_code}, but it yielded an event" - )), - None => Err(format!( - "expected {kind} to fail with code {expected_code}, but the stream ended cleanly" - )), - }, - } -} - -fn expected_response_text(server_prefix: &str, request_text: &str) -> String { - format!("{server_prefix} server received: {request_text}") -} - -fn expected_cancel_text(server_prefix: &str) -> String { - format!("{server_prefix} server canceled task") -} - -fn expected_scenario_text(server_prefix: &str, suffix: &str) -> String { - format!("{server_prefix} server {suffix}") -} - -fn request_with_payload(text: &str, return_immediately: bool) -> SendMessageRequest { - request_with_payload_ids(text, return_immediately, None, None) -} - -fn request_with_payload_ids( - text: &str, - return_immediately: bool, - task_id: Option, - context_id: Option, -) -> SendMessageRequest { - let mut message = Message::new( - Role::User, - vec![ - Part::text(text), - Part::data(json!({ - "kind": REQUEST_DATA_KIND, - "scope": REQUEST_DATA_SCOPE, - })), - ], - ); - message.task_id = task_id; - message.context_id = context_id; - message.metadata = Some(std::collections::HashMap::from([( - REQUEST_METADATA_KEY.to_string(), - json!(REQUEST_METADATA_VALUE), - )])); - - SendMessageRequest { - message, - configuration: Some(SendMessageConfiguration { - accepted_output_modes: None, - task_push_notification_config: None, - history_length: None, - return_immediately: Some(return_immediately), - }), - metadata: None, - tenant: None, - } -} - -fn task_text(task: &Task) -> Result { - task.status - .message - .as_ref() - .ok_or_else(|| "task response contained no message".to_string()) - .and_then(first_text) -} - -fn task_from_response(response: SendMessageResponse, kind: &str) -> Result { - match response { - SendMessageResponse::Task(task) => Ok(task), - SendMessageResponse::Message(_) => Err(format!("unexpected {kind} response type: Message")), - } -} - -fn message_from_response(response: SendMessageResponse, kind: &str) -> Result { - match response { - SendMessageResponse::Task(_) => Err(format!("unexpected {kind} response type: Task")), - SendMessageResponse::Message(message) => Ok(message), - } -} - -fn stream_response_text(response: StreamResponse) -> Result, String> { - match response { - StreamResponse::Message(message) => first_text(&message).map(Some), - StreamResponse::Task(task) => task_text(&task).map(Some), - StreamResponse::StatusUpdate(update) => { - update.status.message.as_ref().map(first_text).transpose() - } - StreamResponse::ArtifactUpdate(_) => Ok(None), - } -} - -fn assert_message_payload(message: &Message, expected_text: &str, kind: &str) -> Result<(), String> { - if message.parts.len() != 2 { - return Err(format!( - "{kind} message had {} parts, want 2", - message.parts.len() - )); - } - - let actual = first_text(message)?; - assert_text(actual, expected_text, kind)?; - - let part_data = match &message.parts[1].content { - PartContent::Data(value) => value, - _ => { - return Err(format!( - "{kind} message second part was not a structured data part" - )); +fn flag_value(args: &[String], flag: &str) -> Option { + let mut it = args.iter(); + while let Some(arg) = it.next() { + if arg.as_str() == flag { + return it.next().cloned(); } - }; - let kind_value = part_data - .get("kind") - .and_then(Value::as_str) - .ok_or_else(|| format!("{kind} message data part was missing kind"))?; - let scope_value = part_data - .get("scope") - .and_then(Value::as_str) - .ok_or_else(|| format!("{kind} message data part was missing scope"))?; - - if kind_value != REQUEST_DATA_KIND || scope_value != REQUEST_DATA_SCOPE { - return Err(format!( - "{kind} message data part mismatch: got kind={kind_value:?} scope={scope_value:?}" - )); - } - - let metadata = message - .metadata - .as_ref() - .ok_or_else(|| format!("{kind} message was missing metadata"))?; - let metadata_value = metadata - .get(REQUEST_METADATA_KEY) - .and_then(Value::as_str) - .ok_or_else(|| format!("{kind} message metadata was missing {REQUEST_METADATA_KEY}"))?; - - if metadata_value != REQUEST_METADATA_VALUE { - return Err(format!( - "{kind} message metadata mismatch: got {metadata_value:?}, want {REQUEST_METADATA_VALUE:?}" - )); - } - - Ok(()) -} - -fn assert_task_history(task: &Task, expected_text: &str, kind: &str) -> Result<(), String> { - assert_task_history_entries(task, &[expected_text], kind) -} - -fn assert_task_history_entries(task: &Task, expected_texts: &[&str], kind: &str) -> Result<(), String> { - let history = task - .history - .as_ref() - .ok_or_else(|| format!("{kind} task did not include history"))?; - if history.len() != expected_texts.len() { - return Err(format!( - "{kind} task history length mismatch: got {}, want {}", - history.len(), - expected_texts.len() - )); } - - for (message, expected_text) in history.iter().zip(expected_texts.iter()) { - assert_message_payload(message, expected_text, kind)?; - } - - Ok(()) + None } -fn first_artifact_text(artifact: &Artifact) -> Result { - artifact - .parts - .iter() - .find_map(Part::as_text) - .map(ToString::to_string) - .ok_or_else(|| "artifact contained no text parts".to_string()) +fn require_flag(args: &[String], flag: &str) -> String { + flag_value(args, flag).unwrap_or_else(|| fail(format!("missing {flag}"))) } -fn assert_data_types_task(task: &Task, kind: &str) -> Result<(), String> { - let artifacts = task - .artifacts - .as_ref() - .ok_or_else(|| format!("{kind} task did not include artifacts"))?; - if artifacts.len() != 1 { - return Err(format!( - "{kind} task artifact count mismatch: got {}, want 1", - artifacts.len() - )); - } - - let artifact = &artifacts[0]; - if artifact.parts.len() != 3 { - return Err(format!( - "{kind} artifact part count mismatch: got {}, want 3", - artifact.parts.len() - )); - } - - assert_text(first_artifact_text(artifact)?, "structured summary", kind)?; - - match &artifact.parts[1].content { - PartContent::Data(value) => { - if value.get("kind").and_then(Value::as_str) != Some("report") { - return Err(format!("{kind} data-types artifact was missing kind=report")); - } - let items_matches = value - .get("items") - .map(|items| { - items.as_i64() == Some(2) - || items.as_u64() == Some(2) - || items.as_f64() == Some(2.0) - }) - .unwrap_or(false); - if !items_matches { - return Err(format!("{kind} data-types artifact was missing items=2")); - } - } - _ => return Err(format!("{kind} data-types artifact second part was not data")), - } - - match &artifact.parts[2].content { - PartContent::Url(url) => { - if url != "https://example.invalid/diagram.svg" { - return Err(format!("{kind} data-types artifact URL mismatch: got {url:?}")); - } +// ── push config helpers ──────────────────────────────────────────────────── + +// Serialize a TaskPushNotificationConfig to the JSON format expected by the +// Go probe client (camelCase field names, "authentication" for the auth block). +fn push_config_to_json(cfg: &TaskPushNotificationConfig) -> Value { + let mut obj = json!({ + "url": cfg.url, + "taskId": cfg.task_id, + }); + if let Some(id) = &cfg.id { + obj["id"] = json!(id); + } + if let Some(token) = &cfg.token { + obj["token"] = json!(token); + } + if let Some(auth) = &cfg.authentication { + let mut auth_obj = json!({"scheme": auth.scheme}); + if let Some(creds) = &auth.credentials { + auth_obj["credentials"] = json!(creds); } - _ => return Err(format!("{kind} data-types artifact third part was not a URL")), + obj["authentication"] = auth_obj; } - - if artifact.parts[2].media_type.as_deref() != Some("image/svg+xml") { - return Err(format!("{kind} data-types artifact media type mismatch")); - } - if artifact.parts[2].filename.as_deref() != Some("diagram.svg") { - return Err(format!("{kind} data-types artifact filename mismatch")); - } - - Ok(()) + obj } -fn assert_extended_card_metadata(card: &AgentCard, kind: &str) -> Result<(), String> { - if card.capabilities.extended_agent_card != Some(true) { - return Err(format!("{kind} card did not advertise extendedAgentCard support")); - } - if !card.description.contains("(extended)") { - return Err(format!("{kind} card did not include extended description metadata")); - } - - if let Some(schemes) = card.security_schemes.as_ref() { - let scheme = schemes - .get(EXTENDED_CARD_SCHEME_ID) - .ok_or_else(|| format!("{kind} card did not include {EXTENDED_CARD_SCHEME_ID}"))?; - - match scheme { - SecurityScheme::HttpAuth(http) => { - if http.scheme != "Bearer" { - return Err(format!("{kind} bearer scheme mismatch: got {:?}", http.scheme)); - } - } - _ => return Err(format!("{kind} card security scheme was not HTTP auth")), - } - } - - for expected_skill in EXPECTED_SKILL_IDS { - if !card.skills.iter().any(|skill| skill.id == *expected_skill) { - return Err(format!("{kind} card was missing skill {expected_skill}")); - } +// Parse a PushConfig JSON (as produced by the Go SDK) into a Rust +// TaskPushNotificationConfig. +fn push_config_from_json(val: &Value) -> TaskPushNotificationConfig { + let authentication = if val["authentication"].is_object() { + Some(AuthenticationInfo { + scheme: val["authentication"]["scheme"] + .as_str() + .unwrap_or("") + .to_string(), + credentials: val["authentication"]["credentials"] + .as_str() + .map(|s| s.to_string()), + }) + } else { + None + }; + TaskPushNotificationConfig { + task_id: val["taskId"].as_str().unwrap_or("").to_string(), + id: val["id"].as_str().map(|s| s.to_string()), + url: val["url"].as_str().unwrap_or("").to_string(), + token: val["token"].as_str().map(|s| s.to_string()), + authentication, + tenant: None, } - - Ok(()) } -async fn wait_for_task_state( - client: &a2a_client::client::A2AClient, - task_id: &str, - expected_state: TaskState, - kind: &str, -) -> Result { - for _ in 0..40 { - let task = client - .get_task(&GetTaskRequest { - id: task_id.to_string(), - history_length: None, - tenant: None, - }) - .await - .map_err(|error| format!("{kind} get_task failed: {error}"))?; - - if task.status.state == expected_state { - return Ok(task); - } - - sleep(Duration::from_millis(50)).await; - } - - Err(format!("timed out waiting for {kind} to reach state {expected_state:?}")) -} +// ── main ─────────────────────────────────────────────────────────────────── #[tokio::main] async fn main() { - let args = match parse_args() { - Ok(args) => args, - Err(error) => { - eprintln!("{error}"); - process::exit(2); - } - }; - - if let Err(error) = run(args).await { - eprintln!("{error}"); - process::exit(1); - } -} + let args = parse_args(); -async fn run(args: Args) -> Result<(), String> { let resolver = AgentCardResolver::new(None); let card = resolver .resolve(&args.card_url) .await - .map_err(|error| format!("agent card resolution failed: {error}"))?; + .unwrap_or_else(|e| exit_error(-32000, format!("agent card resolution failed: {e}"))); + let client = A2AClientFactory::builder() .register(Arc::new(GrpcTransportFactory {})) .preferred_bindings(vec![ @@ -610,614 +154,224 @@ async fn run(args: Args) -> Result<(), String> { .build() .create_from_card(&card) .await - .map_err(|error| format!("client creation failed: {error}"))?; - - let expected_ping_text = expected_response_text(&args.server_prefix, REQUEST_TEXT); - let expected_pending_text = expected_response_text(&args.server_prefix, PENDING_REQUEST_TEXT); - let expected_cancel_text = expected_cancel_text(&args.server_prefix); - let expected_message_only_text = expected_scenario_text(&args.server_prefix, "message-only response"); - let expected_failed_text = expected_scenario_text(&args.server_prefix, "failed task"); - let expected_input_required_text = expected_scenario_text(&args.server_prefix, "needs more input"); - let expected_multi_turn_complete_text = expected_scenario_text(&args.server_prefix, "multi-turn completed"); - let expected_streaming_start_text = expected_scenario_text(&args.server_prefix, "streaming started"); - let expected_streaming_complete_text = expected_scenario_text(&args.server_prefix, "streaming complete"); - let expected_long_running_start_text = expected_scenario_text(&args.server_prefix, "long-running started"); - let expected_long_running_complete_text = expected_scenario_text(&args.server_prefix, "long-running complete"); - let expected_data_types_text = expected_scenario_text(&args.server_prefix, "data-types ready"); - - if args.scenario.runs_task_streaming() { - let request = request_with_payload(REQUEST_TEXT, false); - - let response = client - .send_message(&request) - .await - .map_err(|error| format!("unary request failed: {error}"))?; - let completed_task = task_from_response(response, "unary")?; - assert_state(&completed_task.status.state, TaskState::Completed, "unary")?; - assert_text(task_text(&completed_task)?, &expected_ping_text, "unary")?; - assert_task_history(&completed_task, REQUEST_TEXT, "unary")?; - - let mut stream = client - .send_streaming_message(&request) - .await - .map_err(|error| format!("streaming request failed: {error}"))?; - - let streaming_text = loop { - match stream.next().await { - Some(Ok(event)) => match stream_response_text(event)? { - Some(text) => break text, - None => continue, - }, - Some(Err(error)) => { - return Err(format!("streaming event failed: {error}")); + .unwrap_or_else(|e| exit_error(-32000, format!("client creation failed: {e}"))); + + match args.subcommand.as_str() { + // ── send-message ──────────────────────────────────────────────────── + "send-message" => { + let msg_json = require_flag(&args.rest, "--message-json"); + let req: SendMessageRequest = serde_json::from_str(&msg_json) + .unwrap_or_else(|e| fail(format!("parse send-message request: {e}"))); + + match client.send_message(&req).await { + Err(e) => fail_a2a(e), + Ok(SendMessageResponse::Task(task)) => { + let data = serde_json::to_value(&task) + .unwrap_or_else(|e| fail(format!("serialize task: {e}"))); + println!("{}", json!({"type": "task", "data": data})); } - None => { - return Err("stream completed without a terminal response event".to_string()); + Ok(SendMessageResponse::Message(message)) => { + let data = serde_json::to_value(&message) + .unwrap_or_else(|e| fail(format!("serialize message: {e}"))); + println!("{}", json!({"type": "message", "data": data})); } } - }; - assert_text(streaming_text, &expected_ping_text, "streaming")?; - } - - if args.scenario.runs_task_lifecycle() { - let completed_task = task_from_response( - client - .send_message(&request_with_payload(REQUEST_TEXT, false)) - .await - .map_err(|error| format!("task lifecycle setup failed: {error}"))?, - "task lifecycle setup", - )?; - assert_state( - &completed_task.status.state, - TaskState::Completed, - "task lifecycle setup", - )?; - assert_text( - task_text(&completed_task)?, - &expected_ping_text, - "task lifecycle setup", - )?; - assert_task_history(&completed_task, REQUEST_TEXT, "task lifecycle setup")?; - - let fetched_task = client - .get_task(&GetTaskRequest { - id: completed_task.id.clone(), - history_length: Some(1), - tenant: None, - }) - .await - .map_err(|error| format!("get_task failed: {error}"))?; - assert_state(&fetched_task.status.state, TaskState::Completed, "get_task")?; - assert_text(task_text(&fetched_task)?, &expected_ping_text, "get_task")?; - assert_task_history(&fetched_task, REQUEST_TEXT, "get_task")?; - - let listed_tasks = client - .list_tasks(&ListTasksRequest { - context_id: Some(completed_task.context_id.clone()), - status: None, - page_size: None, - page_token: None, - history_length: None, - status_timestamp_after: None, - include_artifacts: None, - tenant: None, - }) - .await - .map_err(|error| format!("list_tasks failed: {error}"))?; - if !listed_tasks - .tasks - .iter() - .any(|task| task.id == completed_task.id) - { - return Err(format!( - "list_tasks did not include expected task {}", - completed_task.id - )); } - let pending_task = task_from_response( - client - .send_message(&request_with_payload(PENDING_REQUEST_TEXT, true)) - .await - .map_err(|error| format!("pending unary request failed: {error}"))?, - "pending unary", - )?; - assert_state( - &pending_task.status.state, - TaskState::Working, - "pending unary", - )?; - assert_text( - task_text(&pending_task)?, - &expected_pending_text, - "pending unary", - )?; + // ── send-streaming-message ────────────────────────────────────────── + "send-streaming-message" => { + let msg_json = require_flag(&args.rest, "--message-json"); + let req: SendMessageRequest = serde_json::from_str(&msg_json) + .unwrap_or_else(|e| fail(format!("parse send-streaming-message request: {e}"))); - let canceled_task = client - .cancel_task(&CancelTaskRequest { - id: pending_task.id.clone(), - metadata: None, - tenant: None, - }) - .await - .map_err(|error| format!("cancel_task failed: {error}"))?; - assert_state( - &canceled_task.status.state, - TaskState::Canceled, - "cancel_task", - )?; - assert_text( - task_text(&canceled_task)?, - &expected_cancel_text, - "cancel_task", - )?; - - let fetched_canceled_task = client - .get_task(&GetTaskRequest { - id: pending_task.id.clone(), - history_length: None, - tenant: None, - }) - .await - .map_err(|error| format!("get_task after cancel failed: {error}"))?; - assert_state( - &fetched_canceled_task.status.state, - TaskState::Canceled, - "get_task after cancel", - )?; - assert_text( - task_text(&fetched_canceled_task)?, - &expected_cancel_text, - "get_task after cancel", - )?; - - if args.relaxed_error_checks { - assert_failed( - client - .get_task(&GetTaskRequest { - id: new_task_id(), - history_length: None, - tenant: None, - }) - .await, - "get missing task", - )?; - - assert_failed( - client - .cancel_task(&CancelTaskRequest { - id: completed_task.id.clone(), - metadata: None, - tenant: None, - }) - .await, - "cancel completed task", - )?; - } else { - assert_error_code( - client - .get_task(&GetTaskRequest { - id: new_task_id(), - history_length: None, - tenant: None, - }) - .await, - a2a::error_code::TASK_NOT_FOUND, - "get missing task", - )?; - - assert_error_code( - client - .cancel_task(&CancelTaskRequest { - id: completed_task.id.clone(), - metadata: None, - tenant: None, - }) - .await, - a2a::error_code::TASK_NOT_CANCELABLE, - "cancel completed task", - )?; - } - - if args.expect_subscribe_unsupported { - assert_stream_error_code( - client - .subscribe_to_task(&SubscribeToTaskRequest { - id: completed_task.id.clone(), - tenant: None, - }) - .await, - a2a::error_code::UNSUPPORTED_OPERATION, - "subscribe_to_task", - ) - .await?; - } - } - - if args.scenario.runs_push_config() { - let completed_task = task_from_response( - client - .send_message(&request_with_payload(REQUEST_TEXT, false)) - .await - .map_err(|error| format!("push-config setup failed: {error}"))?, - "push-config setup", - )?; - assert_state( - &completed_task.status.state, - TaskState::Completed, - "push-config setup", - )?; - assert_text( - task_text(&completed_task)?, - &expected_ping_text, - "push-config setup", - )?; - assert_task_history(&completed_task, REQUEST_TEXT, "push-config setup")?; - - if args.expect_push_unsupported { - let push_config = TaskPushNotificationConfig { - task_id: completed_task.id.clone(), - url: "https://example.invalid/webhook".to_string(), - id: Some("interop-config".to_string()), - token: None, - authentication: None, - tenant: None, + let mut stream = match client.send_streaming_message(&req).await { + Ok(s) => s, + Err(e) => fail_a2a(e), }; - if args.relaxed_error_checks { - assert_failed( - client - .create_push_config(&push_config) - .await, - "create_push_config", - )?; - - assert_failed( - client - .get_push_config(&GetTaskPushNotificationConfigRequest { - task_id: completed_task.id.clone(), - id: "interop-config".to_string(), - tenant: None, - }) - .await, - "get_push_config", - )?; - - assert_failed( - client - .list_push_configs(&ListTaskPushNotificationConfigsRequest { - task_id: completed_task.id.clone(), - page_size: None, - page_token: None, - tenant: None, - }) - .await, - "list_push_configs", - )?; - - assert_failed( - client - .delete_push_config(&DeleteTaskPushNotificationConfigRequest { - task_id: completed_task.id.clone(), - id: "interop-config".to_string(), - tenant: None, - }) - .await, - "delete_push_config", - )?; - } else { - assert_error_code( - client - .create_push_config(&push_config) - .await, - args.expected_push_error_code, - "create_push_config", - )?; - - assert_error_code( - client - .get_push_config(&GetTaskPushNotificationConfigRequest { - task_id: completed_task.id.clone(), - id: "interop-config".to_string(), - tenant: None, - }) - .await, - args.expected_push_error_code, - "get_push_config", - )?; - - assert_error_code( - client - .list_push_configs(&ListTaskPushNotificationConfigsRequest { - task_id: completed_task.id.clone(), - page_size: None, - page_token: None, - tenant: None, - }) - .await, - args.expected_push_error_code, - "list_push_configs", - )?; - - assert_error_code( - client - .delete_push_config(&DeleteTaskPushNotificationConfigRequest { - task_id: completed_task.id.clone(), - id: "interop-config".to_string(), - tenant: None, - }) - .await, - args.expected_push_error_code, - "delete_push_config", - )?; + while let Some(event) = stream.next().await { + let (event_type, data) = match event { + Err(e) => fail_a2a(e), + Ok(StreamResponse::Task(task)) => { + let data = serde_json::to_value(&task) + .unwrap_or_else(|e| fail(format!("serialize task event: {e}"))); + ("task", data) + } + Ok(StreamResponse::StatusUpdate(update)) => { + let data = serde_json::to_value(&update) + .unwrap_or_else(|e| fail(format!("serialize status-update event: {e}"))); + ("task-status-update", data) + } + Ok(StreamResponse::ArtifactUpdate(update)) => { + let data = serde_json::to_value(&update) + .unwrap_or_else(|e| fail(format!("serialize artifact-update event: {e}"))); + ("task-artifact-update", data) + } + Ok(StreamResponse::Message(message)) => { + let data = serde_json::to_value(&message) + .unwrap_or_else(|e| fail(format!("serialize message event: {e}"))); + ("message", data) + } + }; + println!("{}", json!({"type": event_type, "data": data})); } - } else if args.expect_push_supported { - let push_config = TaskPushNotificationConfig { - task_id: completed_task.id.clone(), - url: "https://example.invalid/webhook".to_string(), - id: Some("interop-config".to_string()), - token: Some("interop-token".to_string()), - authentication: Some(AuthenticationInfo { - scheme: "Bearer".to_string(), - credentials: Some("interop-credential".to_string()), - }), - tenant: None, - }; + } - let created_push_config = client - .create_push_config(&push_config) + // ── get-task ──────────────────────────────────────────────────────── + "get-task" => { + let task_id = require_flag(&args.rest, "--task-id"); + match client + .get_task(&GetTaskRequest { + id: task_id, + history_length: None, + tenant: None, + }) .await - .map_err(|error| format!("create_push_config failed: {error}"))?; - assert_push_config( - &created_push_config, - &completed_task.id, - &push_config, - "create_push_config", - )?; + { + Err(e) => fail_a2a(e), + Ok(task) => { + println!( + "{}", + serde_json::to_string(&task) + .unwrap_or_else(|e| fail(format!("serialize get-task response: {e}"))) + ); + } + } + } - let fetched_push_config = client - .get_push_config(&GetTaskPushNotificationConfigRequest { - task_id: completed_task.id.clone(), - id: "interop-config".to_string(), + // ── cancel-task ───────────────────────────────────────────────────── + "cancel-task" => { + let task_id = require_flag(&args.rest, "--task-id"); + match client + .cancel_task(&CancelTaskRequest { + id: task_id, + metadata: None, tenant: None, }) .await - .map_err(|error| format!("get_push_config failed: {error}"))?; - assert_push_config( - &fetched_push_config, - &completed_task.id, - &push_config, - "get_push_config", - )?; + { + Err(e) => fail_a2a(e), + Ok(task) => { + println!( + "{}", + serde_json::to_string(&task) + .unwrap_or_else(|e| fail(format!("serialize cancel-task response: {e}"))) + ); + } + } + } - let listed_push_configs = client - .list_push_configs(&ListTaskPushNotificationConfigsRequest { - task_id: completed_task.id.clone(), + // ── list-tasks ────────────────────────────────────────────────────── + "list-tasks" => { + let context_id = require_flag(&args.rest, "--context-id"); + match client + .list_tasks(&ListTasksRequest { + context_id: Some(context_id), + status: None, page_size: None, page_token: None, + history_length: None, + status_timestamp_after: None, + include_artifacts: None, tenant: None, }) .await - .map_err(|error| format!("list_push_configs failed: {error}"))?; - if listed_push_configs.configs.len() != 1 { - return Err(format!( - "unexpected list_push_configs result count: got {}, want 1", - listed_push_configs.configs.len() - )); + { + Err(e) => fail_a2a(e), + Ok(resp) => { + println!( + "{}", + serde_json::to_string(&resp) + .unwrap_or_else(|e| fail(format!("serialize list-tasks response: {e}"))) + ); + } } - assert_push_config( - &listed_push_configs.configs[0], - &completed_task.id, - &push_config, - "list_push_configs", - )?; + } - client - .delete_push_config(&DeleteTaskPushNotificationConfigRequest { - task_id: completed_task.id.clone(), - id: "interop-config".to_string(), + // ── create-push-config ────────────────────────────────────────────── + "create-push-config" => { + let config_json = require_flag(&args.rest, "--config-json"); + let val: Value = serde_json::from_str(&config_json) + .unwrap_or_else(|e| fail(format!("parse push config: {e}"))); + let config = push_config_from_json(&val); + match client.create_push_config(&config).await { + Err(e) => fail_a2a(e), + Ok(result) => println!("{}", push_config_to_json(&result)), + } + } + + // ── get-push-config ───────────────────────────────────────────────── + "get-push-config" => { + let task_id = require_flag(&args.rest, "--task-id"); + let config_id = require_flag(&args.rest, "--config-id"); + match client + .get_push_config(&GetTaskPushNotificationConfigRequest { + task_id, + id: config_id, tenant: None, }) .await - .map_err(|error| format!("delete_push_config failed: {error}"))?; + { + Err(e) => fail_a2a(e), + Ok(result) => println!("{}", push_config_to_json(&result)), + } + } - let listed_after_delete = client + // ── list-push-configs ─────────────────────────────────────────────── + "list-push-configs" => { + let task_id = require_flag(&args.rest, "--task-id"); + match client .list_push_configs(&ListTaskPushNotificationConfigsRequest { - task_id: completed_task.id.clone(), + task_id, page_size: None, page_token: None, tenant: None, }) .await - .map_err(|error| format!("list_push_configs after delete failed: {error}"))?; - if !listed_after_delete.configs.is_empty() { - return Err(format!( - "expected list_push_configs after delete to be empty, got {:?}", - listed_after_delete.configs - )); + { + Err(e) => fail_a2a(e), + Ok(resp) => { + let configs: Vec = + resp.configs.iter().map(push_config_to_json).collect(); + println!("{}", json!({"configs": configs})); + } } } - } - - if args.scenario.runs_parity() { - let message_only = message_from_response( - client - .send_message(&request_with_payload(MESSAGE_ONLY_REQUEST_TEXT, false)) - .await - .map_err(|error| format!("message-only request failed: {error}"))?, - "message-only", - )?; - assert_text( - first_text(&message_only)?, - &expected_message_only_text, - "message-only", - )?; - - let failed_task = task_from_response( - client - .send_message(&request_with_payload(TASK_FAILURE_REQUEST_TEXT, false)) - .await - .map_err(|error| format!("task-failure request failed: {error}"))?, - "task-failure", - )?; - assert_state(&failed_task.status.state, TaskState::Failed, "task-failure")?; - assert_text(task_text(&failed_task)?, &expected_failed_text, "task-failure")?; - - let input_required_task = task_from_response( - client - .send_message(&request_with_payload(MULTI_TURN_START_REQUEST_TEXT, false)) - .await - .map_err(|error| format!("multi-turn start failed: {error}"))?, - "multi-turn start", - )?; - assert_state( - &input_required_task.status.state, - TaskState::InputRequired, - "multi-turn start", - )?; - assert_text( - task_text(&input_required_task)?, - &expected_input_required_text, - "multi-turn start", - )?; - assert_task_history(&input_required_task, MULTI_TURN_START_REQUEST_TEXT, "multi-turn start")?; - let multi_turn_completed = task_from_response( - client - .send_message(&request_with_payload_ids( - MULTI_TURN_CONTINUE_REQUEST_TEXT, - false, - Some(input_required_task.id.clone()), - Some(input_required_task.context_id.clone()), - )) + // ── delete-push-config ────────────────────────────────────────────── + "delete-push-config" => { + let task_id = require_flag(&args.rest, "--task-id"); + let config_id = require_flag(&args.rest, "--config-id"); + match client + .delete_push_config(&DeleteTaskPushNotificationConfigRequest { + task_id, + id: config_id, + tenant: None, + }) .await - .map_err(|error| format!("multi-turn continuation failed: {error}"))?, - "multi-turn continuation", - )?; - assert_state( - &multi_turn_completed.status.state, - TaskState::Completed, - "multi-turn continuation", - )?; - assert_text( - task_text(&multi_turn_completed)?, - &expected_multi_turn_complete_text, - "multi-turn continuation", - )?; - assert_task_history_entries( - &multi_turn_completed, - &[MULTI_TURN_START_REQUEST_TEXT, MULTI_TURN_CONTINUE_REQUEST_TEXT], - "multi-turn continuation", - )?; - - let mut scenario_stream = client - .send_streaming_message(&request_with_payload(STREAMING_REQUEST_TEXT, false)) - .await - .map_err(|error| format!("streaming scenario request failed: {error}"))?; - let mut streaming_chunks = Vec::new(); - let mut saw_append = false; - let mut saw_stream_start = false; - let mut saw_stream_complete = false; - while let Some(event) = scenario_stream.next().await { - let event = event.map_err(|error| format!("streaming scenario event failed: {error}"))?; - match event { - StreamResponse::Task(task) => { - saw_stream_start = true; - assert_state(&task.status.state, TaskState::Working, "streaming scenario task")?; - assert_text(task_text(&task)?, &expected_streaming_start_text, "streaming scenario task")?; - } - StreamResponse::ArtifactUpdate(update) => { - streaming_chunks.push(first_artifact_text(&update.artifact)?); - if update.append == Some(true) { - saw_append = true; - } - } - StreamResponse::StatusUpdate(update) => { - assert_state(&update.status.state, TaskState::Completed, "streaming scenario status")?; - assert_text( - first_text(update.status.message.as_ref().ok_or_else(|| "streaming scenario completion was missing a message".to_string())?)?, - &expected_streaming_complete_text, - "streaming scenario status", - )?; - saw_stream_complete = true; - } - StreamResponse::Message(_) => return Err("streaming scenario yielded an unexpected message event".to_string()), + { + Err(e) => fail_a2a(e), + Ok(()) => {} } } - if !saw_stream_start || !saw_stream_complete { - return Err("streaming scenario did not emit the expected task/status events".to_string()); - } - if streaming_chunks != vec!["streaming chunk 1".to_string(), "streaming chunk 2".to_string()] { - return Err(format!("streaming scenario artifact chunks mismatch: got {streaming_chunks:?}")); - } - if !saw_append { - return Err("streaming scenario did not emit an append artifact update".to_string()); - } - let long_running_task = task_from_response( - client - .send_message(&request_with_payload(LONG_RUNNING_REQUEST_TEXT, true)) + // ── get-extended-card ─────────────────────────────────────────────── + "get-extended-card" => { + match client + .get_extended_agent_card(&GetExtendedAgentCardRequest { tenant: None }) .await - .map_err(|error| format!("long-running request failed: {error}"))?, - "long-running", - )?; - let long_running_completed = match long_running_task.status.state { - TaskState::Working => { - assert_text( - task_text(&long_running_task)?, - &expected_long_running_start_text, - "long-running", - )?; - wait_for_task_state( - &client, - &long_running_task.id, - TaskState::Completed, - "long-running", - ) - .await? - } - TaskState::Completed => long_running_task, - ref other => { - return Err(format!( - "unexpected long-running task state: got {other:?}, want Working or Completed" - )); + { + Err(e) => fail_a2a(e), + Ok(card) => { + println!( + "{}", + serde_json::to_string(&card) + .unwrap_or_else(|e| fail(format!("serialize agent card: {e}"))) + ); + } } - }; - assert_text( - task_text(&long_running_completed)?, - &expected_long_running_complete_text, - "long-running completion", - )?; - - let data_types_task = task_from_response( - client - .send_message(&request_with_payload(DATA_TYPES_REQUEST_TEXT, false)) - .await - .map_err(|error| format!("data-types request failed: {error}"))?, - "data-types", - )?; - assert_state(&data_types_task.status.state, TaskState::Completed, "data-types")?; - assert_text(task_text(&data_types_task)?, &expected_data_types_text, "data-types")?; - assert_data_types_task(&data_types_task, "data-types")?; + } - let extended_card = client - .get_extended_agent_card(&GetExtendedAgentCardRequest { tenant: None }) - .await - .map_err(|error| format!("get_extended_agent_card failed: {error}"))?; - assert_extended_card_metadata(&extended_card, "extended-card")?; + other => fail(format!("unknown subcommand: {other}")), } - - let protocol = card - .supported_interfaces - .first() - .map(|iface| iface.protocol_binding.clone()) - .unwrap_or_else(|| "unknown".to_string()); - println!( - "validated {} {} {} against {}", - args.server_prefix, - protocol, - args.scenario.as_str(), - args.card_url - ); - Ok(()) } diff --git a/integrations/agntcy-a2a/tests/interop_behaviors_test.go b/integrations/agntcy-a2a/tests/interop_behaviors_test.go index 569c7575..a73d8acc 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -3,332 +3,682 @@ package tests -// This file is the shared behavior layer for the interop suite. It defines the behavior slices, -// the harness interface each SDK must satisfy, and the helpers that expand client/server matrices -// into labeled Ginkgo specs. -// To add a new cross-SDK test, add a behavior entry here and implement it for each harness that -// should participate. Use the wrapper files only to declare which clients, servers, and overrides -// are in a suite. +// This file contains the full explicit Ginkgo spec tree for all A2A interop behaviors, +// along with the assertion helpers shared across the native Go path and the external +// probe path. Every spec is a readable When/Context/It block — no matrix loops. +// +// registerBehaviors registers all specs against a probeClient factory and a server +// target. The containing Context must be Ordered so the outer BeforeAll (which +// creates the client) runs before any It block. import ( "context" + "errors" "fmt" + "strings" + "time" + "github.com/a2aproject/a2a-go/v2/a2a" ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) -type interopTarget struct { - baseURL string - serverPrefix string - expectPushSupported bool -} +// ─── helpers ───────────────────────────────────────────────────────────────── -type interopHarness interface { - AssertTaskStreaming(ctx context.Context, target interopTarget) - AssertTaskLifecycle(ctx context.Context, target interopTarget) - AssertPushConfig(ctx context.Context, target interopTarget) - AssertScenarioParity(ctx context.Context, target interopTarget) +func newInteropRequest(text string, returnImmediately bool) *a2a.SendMessageRequest { + return newInteropRequestWithIDs(text, returnImmediately, "", "") } -type interopSpecCase struct { - name string - labels []string - harness interopHarness - target func() interopTarget +func newInteropRequestWithIDs(text string, returnImmediately bool, taskID a2a.TaskID, contextID string) *a2a.SendMessageRequest { + message := a2a.NewMessage( + a2a.MessageRoleUser, + a2a.NewTextPart(text), + a2a.NewDataPart(map[string]any{ + "kind": requestDataKind, + "scope": requestDataScope, + }), + ) + if taskID != "" { + message.TaskID = taskID + } + if contextID != "" { + message.ContextID = contextID + } + message.Metadata = map[string]any{ + requestMetadataKey: requestMetadataValue, + } + + return &a2a.SendMessageRequest{ + Message: message, + Config: &a2a.SendMessageConfig{ + ReturnImmediately: returnImmediately, + }, + } } -type interopBehaviorSpec struct { - name string - labels []string - run func(ctx context.Context, harness interopHarness, target interopTarget) +func newInteropPushConfig() *a2a.PushConfig { + return &a2a.PushConfig{ + ID: "interop-config", + URL: "https://example.invalid/webhook", + Token: "interop-token", + Auth: &a2a.PushAuthInfo{ + Scheme: "Bearer", + Credentials: "interop-credential", + }, + } } -type interopClientMatrixSpec struct { - label string - displayName string - harness interopHarness +func firstMessageText(message *a2a.Message) (string, error) { + for _, part := range message.Parts { + if text := part.Text(); text != "" { + return text, nil + } + } + return "", errors.New("message did not include a text part") } -type interopServerMatrixSpec struct { - label string - displayName string - serverPrefix string - expectPushSupported bool - urls map[transportProtocol]func() string +func taskStatusText(task *a2a.Task) (string, error) { + if task == nil || task.Status.Message == nil { + return "", errors.New("task status did not include a message") + } + return firstMessageText(task.Status.Message) } -var sharedInteropBehaviorSpecs = []interopBehaviorSpec{ - { - name: "task-streaming", - labels: []string{"behavior-core", "behavior-task-streaming"}, - run: func(ctx context.Context, harness interopHarness, target interopTarget) { - harness.AssertTaskStreaming(ctx, target) - }, - }, - { - name: "task-lifecycle", - labels: []string{"behavior-core", "behavior-lifecycle"}, - run: func(ctx context.Context, harness interopHarness, target interopTarget) { - harness.AssertTaskLifecycle(ctx, target) - }, - }, - { - name: "push-config", - labels: []string{"behavior-core", "behavior-push-config"}, - run: func(ctx context.Context, harness interopHarness, target interopTarget) { - harness.AssertPushConfig(ctx, target) - }, - }, - { - name: "scenario-parity", - labels: []string{"behavior-parity"}, - run: func(ctx context.Context, harness interopHarness, target interopTarget) { - harness.AssertScenarioParity(ctx, target) - }, - }, +func eventText(event a2a.Event) (string, bool, error) { + switch value := event.(type) { + case *a2a.Message: + text, err := firstMessageText(value) + return text, true, err + case *a2a.Task: + text, err := taskStatusText(value) + return text, true, err + case *a2a.TaskStatusUpdateEvent: + if value.Status.Message == nil { + return "", false, nil + } + text, err := firstMessageText(value.Status.Message) + return text, true, err + default: + return "", false, nil + } } -func interopTargetFor(getBaseURL func() string, serverPrefix string, expectPushSupported bool) func() interopTarget { - return func() interopTarget { - return interopTarget{ - baseURL: getBaseURL(), - serverPrefix: serverPrefix, - expectPushSupported: expectPushSupported, +func firstArtifactText(artifact *a2a.Artifact) (string, error) { + if artifact == nil { + return "", errors.New("artifact was nil") + } + for _, part := range artifact.Parts { + if text := part.Text(); text != "" { + return text, nil } } + return "", errors.New("artifact did not include a text part") } -func runInteropBehavior( - harness interopHarness, - target func() interopTarget, - behavior interopBehaviorSpec, -) func(ctx ginkgo.SpecContext) { - return func(ctx ginkgo.SpecContext) { - requestCtx, cancel := context.WithTimeout(ctx, probeTimeout) - defer cancel() +func assertMessageInteropPayload(message *a2a.Message, expectedText string, kind string) { + gomega.Expect(message).NotTo(gomega.BeNil(), kind) - behavior.run(requestCtx, harness, target()) - } + text, err := firstMessageText(message) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), kind) + gomega.Expect(text).To(gomega.Equal(expectedText), kind) + gomega.Expect(message.Parts).To(gomega.HaveLen(2), kind) + + dataPart, ok := message.Parts[1].Data().(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), kind) + gomega.Expect(dataPart).To(gomega.HaveKeyWithValue("kind", requestDataKind), kind) + gomega.Expect(dataPart).To(gomega.HaveKeyWithValue("scope", requestDataScope), kind) + gomega.Expect(message.Metadata).To(gomega.HaveKeyWithValue(requestMetadataKey, requestMetadataValue), kind) } -func registerInteropCaseContexts(cases ...interopSpecCase) { - for _, specCase := range cases { - specCase := specCase - ginkgo.Context(specCase.name, func() { - for _, behavior := range sharedInteropBehaviorSpecs { - behavior := behavior - labels := append(append([]string{}, specCase.labels...), behavior.labels...) - ginkgo.It( - behavior.name, - ginkgo.Label(labels...), - runInteropBehavior(specCase.harness, specCase.target, behavior), - ) - } - }) +func assertTaskHistoryPayload(task *a2a.Task, expectedText string, kind string) { + assertTaskHistoryPayloads(task, []string{expectedText}, kind) +} + +func assertTaskHistoryPayloads(task *a2a.Task, expectedTexts []string, kind string) { + gomega.Expect(task.History).To(gomega.HaveLen(len(expectedTexts)), kind) + for index, expectedText := range expectedTexts { + assertMessageInteropPayload(task.History[index], expectedText, kind) } } -func registerInteropTransportMatrix( - protocols []transportProtocol, - clients []interopClientMatrixSpec, - servers []interopServerMatrixSpec, - pairLabelOverrides map[string]string, -) { - registerInteropTransportMatrixFull(protocols, clients, servers, pairLabelOverrides, nil, true) +func assertTaskPushConfig(config *a2a.PushConfig, taskID a2a.TaskID, expected *a2a.PushConfig, kind string) { + gomega.Expect(config).NotTo(gomega.BeNil(), kind) + gomega.Expect(config.TaskID).To(gomega.Equal(taskID), kind) + gomega.Expect(config.ID).To(gomega.Equal(expected.ID), kind) + gomega.Expect(config.URL).To(gomega.Equal(expected.URL), kind) + gomega.Expect(config.Token).To(gomega.Equal(expected.Token), kind) + gomega.Expect(config.Auth).To(gomega.Equal(expected.Auth), kind) } -func registerInteropTransportMatrixWithOverrides( - protocols []transportProtocol, - clients []interopClientMatrixSpec, - servers []interopServerMatrixSpec, - pairLabelOverrides map[string]string, - harnessOverrides map[string]interopHarness, -) { - registerInteropTransportMatrixFull(protocols, clients, servers, pairLabelOverrides, harnessOverrides, true) +func assertTaskNotFoundError(err error, kind string) { + gomega.Expect(err).To(gomega.HaveOccurred(), kind) + gomega.Expect(strings.ToLower(err.Error())).To(gomega.Or( + gomega.ContainSubstring("task not found"), + gomega.ContainSubstring("not found"), + ), kind) } -func registerInteropSelfTestMatrix( - protocols []transportProtocol, - clients []interopClientMatrixSpec, - servers []interopServerMatrixSpec, -) { - registerInteropTransportMatrixFull(protocols, clients, servers, nil, nil, false) +func assertTaskNotCancelableError(err error, kind string) { + gomega.Expect(err).To(gomega.HaveOccurred(), kind) + gomega.Expect(strings.ToLower(err.Error())).To(gomega.Or( + gomega.ContainSubstring("cancel"), + gomega.ContainSubstring("terminal state"), + gomega.ContainSubstring("not cancelable"), + ), kind) } -func registerInteropTransportMatrixFull( - protocols []transportProtocol, - clients []interopClientMatrixSpec, - servers []interopServerMatrixSpec, - pairLabelOverrides map[string]string, - harnessOverrides map[string]interopHarness, - skipSelfPairs bool, -) { - for _, protocol := range protocols { - protocol := protocol - cases := interopCasesForProtocol(protocol, clients, servers, pairLabelOverrides, harnessOverrides, skipSelfPairs) - if len(cases) == 0 { - continue - } +func assertPushUnsupportedError(err error, kind string) { + gomega.Expect(err).To(gomega.HaveOccurred(), kind) + gomega.Expect(strings.ToLower(err.Error())).To(gomega.Or( + gomega.ContainSubstring("push notification not supported"), + gomega.ContainSubstring("push_notification_not_supported"), + gomega.ContainSubstring("not supported"), + gomega.ContainSubstring("unsupported"), + gomega.ContainSubstring("unimplemented"), + gomega.ContainSubstring("server error"), + ), kind) +} - ginkgo.Context(interopTransportContextName(protocol), func() { - registerInteropCaseContexts(cases...) - }) - } +func assertDataTypesTask(task *a2a.Task, kind string) { + gomega.Expect(task.Artifacts).To(gomega.HaveLen(1), kind) + artifact := task.Artifacts[0] + gomega.Expect(artifact.Parts).To(gomega.HaveLen(3), kind) + gomega.Expect(artifact.Parts[0].Text()).To(gomega.Equal("structured summary"), kind) + dataPart, ok := artifact.Parts[1].Data().(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue(), kind) + gomega.Expect(dataPart).To(gomega.HaveKeyWithValue("kind", "report"), kind) + gomega.Expect(dataPart).To(gomega.HaveKeyWithValue("items", float64(2)), kind) + gomega.Expect(string(artifact.Parts[2].URL())).To(gomega.Equal("https://example.invalid/diagram.svg"), kind) + gomega.Expect(artifact.Parts[2].MediaType).To(gomega.Equal("image/svg+xml"), kind) } -func interopCasesForProtocol( - protocol transportProtocol, - clients []interopClientMatrixSpec, - servers []interopServerMatrixSpec, - pairLabelOverrides map[string]string, - harnessOverrides map[string]interopHarness, - skipSelfPairs bool, -) []interopSpecCase { - cases := make([]interopSpecCase, 0, len(clients)*len(servers)) - for _, client := range clients { - for _, server := range servers { - if skipSelfPairs && client.label == server.label { - continue - } - pairKey := interopPairKey(client.label, server.label) - getBaseURL := server.urls[protocol] - if getBaseURL == nil { - continue - } +func assertExtendedCardMetadata(card *a2a.AgentCard, kind string) { + gomega.Expect(card).NotTo(gomega.BeNil(), kind) + gomega.Expect(card.Capabilities.ExtendedAgentCard).To(gomega.BeTrue(), kind) + gomega.Expect(card.Description).To(gomega.ContainSubstring("(extended)"), kind) + + if len(card.SecuritySchemes) > 0 { + scheme, ok := card.SecuritySchemes[extendedCardSchemeID] + gomega.Expect(ok).To(gomega.BeTrue(), kind) + httpScheme, ok := scheme.(a2a.HTTPAuthSecurityScheme) + gomega.Expect(ok).To(gomega.BeTrue(), kind) + gomega.Expect(httpScheme.Scheme).To(gomega.Equal("Bearer"), kind) + } - harness := client.harness - if override, ok := harnessOverrides[pairKey]; ok { - harness = override - } + for _, expectedSkill := range expectedSkillIDs { + gomega.Expect(card.Skills).To(gomega.ContainElement(gomega.HaveField("ID", expectedSkill)), kind) + } +} - cases = append(cases, interopSpecCase{ - name: interopCaseName(protocol, client.displayName, server.displayName), - labels: []string{string(protocol), interopPairLabel(client.label, server.label, pairLabelOverrides)}, - harness: harness, - target: interopTargetFor(getBaseURL, server.serverPrefix, server.expectPushSupported), - }) +func waitForProbeTaskState(ctx context.Context, client probeClient, taskID a2a.TaskID, expectedState a2a.TaskState) (*a2a.Task, error) { + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + task, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: taskID}) + if err != nil { + return nil, err } + if task.Status.State == expectedState { + return task, nil + } + time.Sleep(50 * time.Millisecond) } - - return cases + return nil, fmt.Errorf("timed out waiting for task %s to reach state %s", taskID, expectedState) } -func interopTransportContextName(protocol transportProtocol) string { - return string(protocol) -} +// ─── registerBehaviors ─────────────────────────────────────────────────────── + +// registerBehaviors registers the full interop spec tree against the given client factory +// and server target. The containing Context MUST be Ordered so that the outer BeforeAll +// (which creates the client) executes before any It block. +func registerBehaviors(newClient newClientFn, target func() interopTarget) { + var client probeClient + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + t := target() + var err error + client, err = newClient(ctx, t.baseURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + // ── send message (unary) ───────────────────────────────────────────────── + + ginkgo.When("the client sends a message", ginkgo.Ordered, func() { + ginkgo.Context("with return_immediately=false", ginkgo.Ordered, func() { + var task *a2a.Task + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + task, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue(), "expected Task result from SendMessage") + }) -func interopCaseName(_ transportProtocol, clientDisplayName string, serverDisplayName string) string { - return fmt.Sprintf("%s→%s", clientDisplayName, serverDisplayName) -} + ginkgo.It("receives a completed Task", func() { + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + }) -func interopPairLabel(clientLabel string, serverLabel string, overrides map[string]string) string { - if label, ok := overrides[interopPairKey(clientLabel, serverLabel)]; ok { - return label - } + ginkgo.It("includes the request payload in task history", func() { + assertTaskHistoryPayload(task, requestText, "task history") + }) - return clientLabel + "-" + serverLabel -} + ginkgo.It("includes the response text from the server", func() { + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(expectedServerText(target().serverPrefix, requestText))) + }) + }) + }) -func interopPairKey(clientLabel string, serverLabel string) string { - return clientLabel + ":" + serverLabel -} + // ── streaming send message ─────────────────────────────────────────────── -type probeHarnessRunner func( - ctx context.Context, - baseURL string, - serverPrefix string, - options rustProbeOptions, -) (string, error) + ginkgo.When("the client streams a message", ginkgo.Ordered, func() { + var events []a2a.Event -type externalProbeHarness struct { - options rustProbeOptions - run probeHarnessRunner -} + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + var err error + events, err = client.SendStreamingMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) -func (harness externalProbeHarness) optionsForScenario(scenario probeScenario) rustProbeOptions { - options := harness.options - options.scenario = scenario - return options -} + ginkgo.It("receives at least one event with the server response text", func() { + gomega.Expect(events).NotTo(gomega.BeEmpty()) + var texts []string + for _, event := range events { + text, hasText, err := eventText(event) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + if hasText { + texts = append(texts, text) + } + } + gomega.Expect(texts).To(gomega.ContainElement( + expectedServerText(target().serverPrefix, requestText), + )) + }) + }) -func (harness externalProbeHarness) assertScenario( - ctx context.Context, - target interopTarget, - scenario probeScenario, -) { - output, err := harness.run( - ctx, - target.baseURL, - target.serverPrefix, - harness.optionsForScenario(scenario), - ) - gomega.Expect(err).NotTo(gomega.HaveOccurred(), output) -} + // ── get task ───────────────────────────────────────────────────────────── -func (harness externalProbeHarness) AssertTaskStreaming(ctx context.Context, target interopTarget) { - harness.assertScenario(ctx, target, probeScenarioTaskStreaming) -} + ginkgo.When("the client fetches a task by ID", ginkgo.Ordered, func() { + var completedTask, fetchedTask *a2a.Task -func (harness externalProbeHarness) AssertTaskLifecycle(ctx context.Context, target interopTarget) { - harness.assertScenario(ctx, target, probeScenarioTaskLifecycle) -} + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) -func (harness externalProbeHarness) AssertPushConfig(ctx context.Context, target interopTarget) { - harness.assertScenario(ctx, target, probeScenarioPushConfig) -} + fetchedTask, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: completedTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) -func (harness externalProbeHarness) AssertScenarioParity(ctx context.Context, target interopTarget) { - harness.assertScenario(ctx, target, probeScenarioParity) -} + ginkgo.It("returns the task with completed state", func() { + gomega.Expect(fetchedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + }) -func newRustProbeHarness( - getBinaries func() fixtureBinaries, - options rustProbeOptions, -) interopHarness { - return externalProbeHarness{ - options: options, - run: func( - ctx context.Context, - baseURL string, - serverPrefix string, - options rustProbeOptions, - ) (string, error) { - return runRustProbe(ctx, getBinaries(), baseURL, serverPrefix, options) - }, - } -} + ginkgo.It("returns the correct response text", func() { + text, err := taskStatusText(fetchedTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(expectedServerText(target().serverPrefix, requestText))) + }) -func newDotNetProbeHarness( - getBinaries func() dotNetFixtureBinaries, - options dotNetProbeOptions, -) interopHarness { - return externalProbeHarness{ - options: options, - run: func( - ctx context.Context, - baseURL string, - serverPrefix string, - options rustProbeOptions, - ) (string, error) { - return runDotNetProbe(ctx, getBinaries(), baseURL, serverPrefix, options) - }, - } -} + ginkgo.It("returns the history payload", func() { + assertTaskHistoryPayload(fetchedTask, requestText, "fetched task history") + }) + }) -func newPythonProbeHarness( - getAssets func() pythonFixtureAssets, - options rustProbeOptions, -) interopHarness { - return externalProbeHarness{ - options: options, - run: func( - ctx context.Context, - baseURL string, - serverPrefix string, - options rustProbeOptions, - ) (string, error) { - return runPythonProbe(ctx, getAssets(), baseURL, serverPrefix, options) - }, + // ── list tasks ─────────────────────────────────────────────────────────── + + ginkgo.When("the client lists tasks by context ID", ginkgo.Ordered, func() { + var completedTask *a2a.Task + var listedTasks *a2a.ListTasksResponse + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + + listedTasks, err = client.ListTasks(ctx, &a2a.ListTasksRequest{ContextID: completedTask.ContextID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("includes the sent task in the list", func() { + gomega.Expect(listedTasks.Tasks).NotTo(gomega.BeEmpty()) + gomega.Expect(listedTasks.Tasks).To(gomega.ContainElement(gomega.HaveField("ID", completedTask.ID))) + }) + }) + + // ── cancel task ────────────────────────────────────────────────────────── + + ginkgo.When("the client cancels a working task", ginkgo.Ordered, func() { + var canceledTask, fetchedCanceledTask *a2a.Task + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(pendingRequestText, true)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + workingTask, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(workingTask.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) + + canceledTask, err = client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: workingTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + fetchedCanceledTask, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: workingTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("returns a canceled task", func() { + gomega.Expect(canceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) + }) + + ginkgo.It("returns the cancel text in the task status", func() { + text, err := taskStatusText(canceledTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(expectedCancelText(target().serverPrefix))) + }) + + ginkgo.It("the canceled state persists on fetch", func() { + gomega.Expect(fetchedCanceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) + }) + }) + + // ── missing task error ─────────────────────────────────────────────────── + + ginkgo.When("the client requests a task that does not exist", func() { + ginkgo.It("returns a not-found error", func(ctx ginkgo.SpecContext) { + _, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: a2a.NewTaskID()}) + assertTaskNotFoundError(err, "missing task") + }) + }) + + // ── cancel completed task error ────────────────────────────────────────── + + ginkgo.When("the client attempts to cancel a completed task", ginkgo.Ordered, func() { + var completedTask *a2a.Task + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + }) + + ginkgo.It("returns an error", func(ctx ginkgo.SpecContext) { + _, err := client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: completedTask.ID}) + assertTaskNotCancelableError(err, "canceling a terminal task should fail") + }) + }) + + // ── push notification config ───────────────────────────────────────────── + + if target().expectPushSupported { + ginkgo.When("the client manages push notification config", ginkgo.Ordered, func() { + var completedTask *a2a.Task + var createdConfig *a2a.PushConfig + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(completedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + + cfg := newInteropPushConfig() + cfg.TaskID = completedTask.ID + createdConfig, err = client.CreatePushConfig(ctx, cfg) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("creates a push config with expected fields", func() { + assertTaskPushConfig(createdConfig, completedTask.ID, newInteropPushConfig(), "created push config") + }) + + ginkgo.It("fetches the push config by ID", func(ctx ginkgo.SpecContext) { + fetched, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: createdConfig.ID, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + assertTaskPushConfig(fetched, completedTask.ID, newInteropPushConfig(), "fetched push config") + }) + + ginkgo.It("lists exactly one push config for the task", func(ctx ginkgo.SpecContext) { + listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(listed).To(gomega.HaveLen(1)) + assertTaskPushConfig(listed[0], completedTask.ID, newInteropPushConfig(), "listed push config") + }) + + ginkgo.It("deletes the push config and the list is empty afterward", func(ctx ginkgo.SpecContext) { + err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: createdConfig.ID, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(listed).To(gomega.BeEmpty()) + }) + }) + } else { + ginkgo.When("the server does not support push notifications", ginkgo.Ordered, func() { + var completedTask *a2a.Task + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + }) + + ginkgo.It("returns an error for CreatePushConfig", func(ctx ginkgo.SpecContext) { + cfg := newInteropPushConfig() + cfg.TaskID = completedTask.ID + _, err := client.CreatePushConfig(ctx, cfg) + assertPushUnsupportedError(err, "create push config unsupported") + }) + + ginkgo.It("returns an error for GetPushConfig", func(ctx ginkgo.SpecContext) { + _, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: "interop-config", + }) + assertPushUnsupportedError(err, "get push config unsupported") + }) + + ginkgo.It("returns an error for ListPushConfigs", func(ctx ginkgo.SpecContext) { + _, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + assertPushUnsupportedError(err, "list push config unsupported") + }) + + ginkgo.It("returns an error for DeletePushConfig", func(ctx ginkgo.SpecContext) { + err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: "interop-config", + }) + assertPushUnsupportedError(err, "delete push config unsupported") + }) + }) } + + // ── scenario parity ────────────────────────────────────────────────────── + + ginkgo.When("the server returns a message-only response", func() { + ginkgo.It("the client receives a Message result", func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(messageOnlyRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + message, ok := result.(*a2a.Message) + gomega.Expect(ok).To(gomega.BeTrue(), "expected Message result from SendMessage") + text, err := firstMessageText(message) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server message-only response", target().serverPrefix))) + }) + }) + + ginkgo.When("the server fails the task", func() { + ginkgo.It("the client receives a failed task", func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(taskFailureRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateFailed)) + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server failed task", target().serverPrefix))) + }) + }) + + ginkgo.When("the agent requires multi-turn input", ginkgo.Ordered, func() { + var inputRequiredTask, continuedTask *a2a.Task + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(multiTurnStartRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + inputRequiredTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(inputRequiredTask.Status.State).To(gomega.Equal(a2a.TaskStateInputRequired)) + + continueResult, err := client.SendMessage(ctx, newInteropRequestWithIDs( + multiTurnContinueRequestText, false, + inputRequiredTask.ID, inputRequiredTask.ContextID, + )) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + continuedTask, ok = continueResult.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + }) + + ginkgo.It("the first request returns input-required state", func() { + text, err := taskStatusText(inputRequiredTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", target().serverPrefix))) + assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") + }) + + ginkgo.It("the continued request completes the task", func() { + gomega.Expect(continuedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + text, err := taskStatusText(continuedTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server multi-turn completed", target().serverPrefix))) + assertTaskHistoryPayloads(continuedTask, + []string{multiTurnStartRequestText, multiTurnContinueRequestText}, + "multi-turn continuation", + ) + }) + }) + + ginkgo.When("the server streams artifacts", ginkgo.Ordered, func() { + var streamingEvents []a2a.Event + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + var err error + streamingEvents, err = client.SendStreamingMessage(ctx, newInteropRequest(streamingRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("emits a working task at the start", func() { + sawStart := false + for _, event := range streamingEvents { + if task, ok := event.(*a2a.Task); ok { + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming started", target().serverPrefix))) + sawStart = true + break + } + } + gomega.Expect(sawStart).To(gomega.BeTrue(), "expected a working Task streaming-start event") + }) + + ginkgo.It("emits artifact chunks in order with at least one append", func() { + var chunks []string + sawAppend := false + for _, event := range streamingEvents { + if update, ok := event.(*a2a.TaskArtifactUpdateEvent); ok { + text, err := firstArtifactText(update.Artifact) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + chunks = append(chunks, text) + if update.Append { + sawAppend = true + } + } + } + gomega.Expect(sawAppend).To(gomega.BeTrue(), "expected an append artifact update") + gomega.Expect(chunks).To(gomega.Equal([]string{"streaming chunk 1", "streaming chunk 2"})) + }) + + ginkgo.It("emits a completed status update at the end", func() { + sawComplete := false + for _, event := range streamingEvents { + if update, ok := event.(*a2a.TaskStatusUpdateEvent); ok { + gomega.Expect(update.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + text, err := firstMessageText(update.Status.Message) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming complete", target().serverPrefix))) + sawComplete = true + } + } + gomega.Expect(sawComplete).To(gomega.BeTrue(), "expected a completed TaskStatusUpdateEvent") + }) + }) + + ginkgo.When("the server handles a long-running request", ginkgo.Ordered, func() { + var completedTask *a2a.Task + + ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(longRunningRequestText, true)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + + switch task.Status.State { + case a2a.TaskStateWorking: + text, textErr := taskStatusText(task) + gomega.Expect(textErr).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running started", target().serverPrefix))) + completedTask, err = waitForProbeTaskState(ctx, client, task.ID, a2a.TaskStateCompleted) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + case a2a.TaskStateCompleted: + completedTask = task + default: + ginkgo.Fail(fmt.Sprintf("unexpected long-running task initial state: %s", task.Status.State)) + } + }) + + ginkgo.It("completes with the expected text", func() { + text, err := taskStatusText(completedTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", target().serverPrefix))) + }) + }) + + ginkgo.When("the server returns rich data types", func() { + ginkgo.It("the completed task includes structured artifact data", func(ctx ginkgo.SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(dataTypesRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server data-types ready", target().serverPrefix))) + assertDataTypesTask(task, "data-types") + }) + }) + + ginkgo.When("the client fetches the extended agent card", func() { + ginkgo.It("returns a valid extended card", func(ctx ginkgo.SpecContext) { + card, err := client.GetExtendedCard(ctx, &a2a.GetExtendedAgentCardRequest{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + assertExtendedCardMetadata(card, "extended-card") + }) + }) } diff --git a/integrations/agntcy-a2a/tests/interop_client_test.go b/integrations/agntcy-a2a/tests/interop_client_test.go new file mode 100644 index 00000000..f185c66a --- /dev/null +++ b/integrations/agntcy-a2a/tests/interop_client_test.go @@ -0,0 +1,107 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tests + +// This file defines the probeClient interface and the native Go SDK implementation. +// All interop behavior specs are written against probeClient so the same spec tree +// exercises Go, Rust, Python, and .NET clients without duplication. + +import ( + "context" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" + a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// probeClient is the unified A2A client interface used by all behavior specs. +// Both the native Go SDK and external language probes implement this. +type probeClient interface { + SendMessage(ctx context.Context, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) + SendStreamingMessage(ctx context.Context, req *a2a.SendMessageRequest) ([]a2a.Event, error) + GetTask(ctx context.Context, req *a2a.GetTaskRequest) (*a2a.Task, error) + CancelTask(ctx context.Context, req *a2a.CancelTaskRequest) (*a2a.Task, error) + ListTasks(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) + CreatePushConfig(ctx context.Context, cfg *a2a.PushConfig) (*a2a.PushConfig, error) + GetPushConfig(ctx context.Context, req *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) + ListPushConfigs(ctx context.Context, req *a2a.ListTaskPushConfigRequest) ([]*a2a.PushConfig, error) + DeletePushConfig(ctx context.Context, req *a2a.DeleteTaskPushConfigRequest) error + GetExtendedCard(ctx context.Context, req *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) +} + +// newClientFn creates a probeClient bound to a specific baseURL. +type newClientFn func(ctx context.Context, baseURL string) (probeClient, error) + +// goProbeClient wraps the native Go A2A SDK client. +type goProbeClient struct { + client *a2aclient.Client +} + +func newGoProbeClient(ctx context.Context, baseURL string) (probeClient, error) { + card, err := agentcard.DefaultResolver.Resolve(ctx, baseURL) + if err != nil { + return nil, err + } + + client, err := a2aclient.NewFromCard( + ctx, + card, + a2agrpc.WithGRPCTransport(grpc.WithTransportCredentials(insecure.NewCredentials())), + ) + if err != nil { + return nil, err + } + + return &goProbeClient{client: client}, nil +} + +func (c *goProbeClient) SendMessage(ctx context.Context, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) { + return c.client.SendMessage(ctx, req) +} + +func (c *goProbeClient) SendStreamingMessage(ctx context.Context, req *a2a.SendMessageRequest) ([]a2a.Event, error) { + var events []a2a.Event + for event, err := range c.client.SendStreamingMessage(ctx, req) { + if err != nil { + return nil, err + } + events = append(events, event) + } + return events, nil +} + +func (c *goProbeClient) GetTask(ctx context.Context, req *a2a.GetTaskRequest) (*a2a.Task, error) { + return c.client.GetTask(ctx, req) +} + +func (c *goProbeClient) CancelTask(ctx context.Context, req *a2a.CancelTaskRequest) (*a2a.Task, error) { + return c.client.CancelTask(ctx, req) +} + +func (c *goProbeClient) ListTasks(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) { + return c.client.ListTasks(ctx, req) +} + +func (c *goProbeClient) CreatePushConfig(ctx context.Context, cfg *a2a.PushConfig) (*a2a.PushConfig, error) { + return c.client.CreateTaskPushConfig(ctx, cfg) +} + +func (c *goProbeClient) GetPushConfig(ctx context.Context, req *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) { + return c.client.GetTaskPushConfig(ctx, req) +} + +func (c *goProbeClient) ListPushConfigs(ctx context.Context, req *a2a.ListTaskPushConfigRequest) ([]*a2a.PushConfig, error) { + return c.client.ListTaskPushConfigs(ctx, req) +} + +func (c *goProbeClient) DeletePushConfig(ctx context.Context, req *a2a.DeleteTaskPushConfigRequest) error { + return c.client.DeleteTaskPushConfig(ctx, req) +} + +func (c *goProbeClient) GetExtendedCard(ctx context.Context, req *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) { + return c.client.GetExtendedAgentCard(ctx, req) +} diff --git a/integrations/agntcy-a2a/tests/interop_go_dotnet_test.go b/integrations/agntcy-a2a/tests/interop_go_dotnet_test.go index 93f97f3f..ab99ca9d 100644 --- a/integrations/agntcy-a2a/tests/interop_go_dotnet_test.go +++ b/integrations/agntcy-a2a/tests/interop_go_dotnet_test.go @@ -3,10 +3,12 @@ package tests -// This file is the Go/.NET suite wrapper. It keeps the client/server matrix local to the Go/C# -// slice while reusing the shared behavior layer and the existing Go and .NET harnesses. +// This file is the Go/.NET suite wrapper. Tests cover all four client/server +// pairings of Go and .NET over the transports both SDKs support (JSON-RPC and REST). import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -17,86 +19,93 @@ var _ = ginkgo.Describe("Go+.NET", ginkgo.Ordered, ginkgo.ContinueOnFailure, gin dotNetAssets dotNetFixtureBinaries ) - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST} - fixtures := []interopSuiteFixtureSpec{ - { - label: "go", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startGoFixture(goAssets, findFreePort(), transportJSONRPC) - }, - }, - { - label: "dotnet", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(dotNetAssets, findFreePort(), transportJSONRPC) - }, - }, - { - label: "go", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startGoFixture(goAssets, findFreePort(), transportREST) - }, - }, - { - label: "dotnet", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(dotNetAssets, findFreePort(), transportREST) - }, - }, - } - - dotNetPushUnsupported := newDotNetProbeHarness( - func() dotNetFixtureBinaries { return dotNetAssets }, - dotNetProbeOptions{ - expectPushUnsupported: true, - expectedPushErrorCode: dotNetPushUnsupportedCode, - }, - ) - dotNetPushSupported := newDotNetProbeHarness( - func() dotNetFixtureBinaries { return dotNetAssets }, - dotNetProbeOptions{expectPushSupported: true}, - ) - clients := []interopClientMatrixSpec{ - {label: "go", displayName: "Go", harness: goSDKHarness{}}, - {label: "dotnet", displayName: ".NET", harness: dotNetPushUnsupported}, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "go", "Go", "go", true, protocols...), - newInteropServerSpec(runtime, "dotnet", ".NET", "dotnet", false, protocols...), - } - clientHarnessOverrides := map[string]interopHarness{ - interopPairKey("dotnet", "go"): dotNetPushSupported, - } - ginkgo.BeforeAll(func() { var err error - goAssets, err = buildGoFixtureBinaryOnly() gomega.Expect(err).NotTo(gomega.HaveOccurred()) dotNetAssets, err = buildDotNetFixtureBinaryOnly() gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - err = startInteropSuiteFixtures(runtime, fixtures) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - removeTempDir(dotNetAssets.tempDir) removeTempDir(goAssets.tempDir) + removeTempDir(dotNetAssets.tempDir) }) - registerInteropTransportMatrixWithOverrides( - protocols, - clients, - servers, - nil, - clientHarnessOverrides, - ) + ginkgo.When("a Go client calls a Go server", ginkgo.Ordered, func() { + server := newGoServer(func() fixtureBinaries { return goAssets }, true, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + goClient := func(ctx context.Context, url string) (probeClient, error) { + return newGoProbeClient(ctx, url) + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-go"), func() { + registerBehaviors(goClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-go"), func() { + registerBehaviors(goClient, server.targetFor(transportREST)) + }) + }) + + ginkgo.When("a Go client calls a .NET server", ginkgo.Ordered, func() { + server := newDotNetServer(func() dotNetFixtureBinaries { return dotNetAssets }, false, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + goClient := func(ctx context.Context, url string) (probeClient, error) { + return newGoProbeClient(ctx, url) + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-dotnet"), func() { + registerBehaviors(goClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-dotnet"), func() { + registerBehaviors(goClient, server.targetFor(transportREST)) + }) + }) + + ginkgo.When("a .NET client calls a Go server", ginkgo.Ordered, func() { + server := newGoServer(func() fixtureBinaries { return goAssets }, true, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + dotNetClient := func(ctx context.Context, url string) (probeClient, error) { + return newDotNetProbeClient(func() dotNetFixtureBinaries { return dotNetAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-go"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-go"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportREST)) + }) + }) + + ginkgo.When("a .NET client calls a .NET server", ginkgo.Ordered, func() { + server := newDotNetServer(func() dotNetFixtureBinaries { return dotNetAssets }, false, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + dotNetClient := func(ctx context.Context, url string) (probeClient, error) { + return newDotNetProbeClient(func() dotNetFixtureBinaries { return dotNetAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-dotnet"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-dotnet"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportREST)) + }) + }) }) diff --git a/integrations/agntcy-a2a/tests/interop_launchers_test.go b/integrations/agntcy-a2a/tests/interop_launchers_test.go index 3c22d0a5..ed41fc4b 100644 --- a/integrations/agntcy-a2a/tests/interop_launchers_test.go +++ b/integrations/agntcy-a2a/tests/interop_launchers_test.go @@ -3,11 +3,10 @@ package tests -// This file builds non-Go fixture assets, launches shared transport processes, and runs the -// Rust, .NET, and Python probe executables used by the shared Ginkgo behaviors. +// This file builds non-Go fixture assets, launches shared transport processes, +// and provides fixtureServer factory functions for Rust, .NET, and Python. // Extend this file for shared process orchestration and non-Go external runtime support. -// The native Go fixture path lives in native_go_launchers_test.go so this layer stays focused -// on generic launcher plumbing. +// The native Go fixture path lives in native_go_launchers_test.go. import ( "context" @@ -86,7 +85,6 @@ func buildFixtureBinaries() (fixtureBinaries, error) { return binaries, nil } - func resolveUvCommand() (string, error) { if configured := os.Getenv("UV"); configured != "" { return configured, nil @@ -368,100 +366,62 @@ func startPythonFixture(assets pythonFixtureAssets, port int, protocol transport return process, baseURL, nil } -func appendProbeOptions(args []string, options rustProbeOptions) []string { - if options.scenario != "" { - args = append(args, "--scenario", string(options.scenario)) - } - if options.expectSubscribeUnsupported { - args = append(args, "--expect-subscribe-unsupported") - } - if options.expectPushSupported { - args = append(args, "--expect-push-supported") - } - if options.expectPushUnsupported { - args = append(args, "--expect-push-unsupported") - if options.expectedPushErrorCode != 0 { - args = append(args, "--expected-push-error-code", fmt.Sprintf("%d", options.expectedPushErrorCode)) - } - } - if options.relaxedErrorChecks { - args = append(args, "--relaxed-error-checks") - } - - return args +// newRustServer returns a fixtureServer that starts a Rust server for each of the given protocols. +func newRustServer(getBinaries func() fixtureBinaries, expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { + s := &fixtureServer{ + serverPrefix: "rust", + expectPushSupported: expectPushSupported, + runtime: newInteropSuiteRuntime(), + } + for _, p := range protocols { + p := p + s.fixtures = append(s.fixtures, interopSuiteFixtureSpec{ + label: "rust", + protocol: p, + start: func() (*fixtureProcess, string, error) { + return startRustFixture(getBinaries(), findFreePort(), p) + }, + }) + } + return s } -func runRustProbe( - ctx context.Context, - binaries fixtureBinaries, - baseURL string, - serverPrefix string, - options rustProbeOptions, -) (string, error) { - args := appendProbeOptions([]string{ - "--card-url", - baseURL, - "--server-prefix", - serverPrefix, - }, options) - - cmd := exec.CommandContext(ctx, binaries.rustProbe, args...) - cmd.Dir = componentRoot() - - output, err := cmd.CombinedOutput() - if err != nil { - return string(output), fmt.Errorf("rust probe failed: %w\n%s", err, string(output)) - } - - return string(output), nil +// newDotNetServer returns a fixtureServer that starts a .NET server for each of the given protocols. +func newDotNetServer(getBinaries func() dotNetFixtureBinaries, expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { + s := &fixtureServer{ + serverPrefix: "dotnet", + expectPushSupported: expectPushSupported, + runtime: newInteropSuiteRuntime(), + } + for _, p := range protocols { + p := p + s.fixtures = append(s.fixtures, interopSuiteFixtureSpec{ + label: "dotnet", + protocol: p, + start: func() (*fixtureProcess, string, error) { + return startDotNetFixture(getBinaries(), findFreePort(), p) + }, + }) + } + return s } -func runDotNetProbe( - ctx context.Context, - binaries dotNetFixtureBinaries, - baseURL string, - serverPrefix string, - options dotNetProbeOptions, -) (string, error) { - args := append([]string{binaries.dotnetProbeDL}, appendProbeOptions([]string{ - "--card-url", - baseURL, - "--server-prefix", - serverPrefix, - }, options)...) - - cmd := exec.CommandContext(ctx, binaries.dotnetCommand, args...) - cmd.Dir = componentRoot() - - output, err := cmd.CombinedOutput() - if err != nil { - return string(output), fmt.Errorf("dotnet probe failed: %w\n%s", err, string(output)) - } - - return string(output), nil -} - -func runPythonProbe( - ctx context.Context, - assets pythonFixtureAssets, - baseURL string, - serverPrefix string, - options rustProbeOptions, -) (string, error) { - args := append([]string{"run", assets.probeScript}, appendProbeOptions([]string{ - "--card-url", - baseURL, - "--server-prefix", - serverPrefix, - }, options)...) - - cmd := exec.CommandContext(ctx, assets.uvCommand, args...) - cmd.Dir = assets.fixtureDir - - output, err := cmd.CombinedOutput() - if err != nil { - return string(output), fmt.Errorf("python probe failed: %w\n%s", err, string(output)) - } - - return string(output), nil +// newPythonServer returns a fixtureServer that starts a Python server for each of the given protocols. +func newPythonServer(getAssets func() pythonFixtureAssets, expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { + s := &fixtureServer{ + serverPrefix: "python", + expectPushSupported: expectPushSupported, + runtime: newInteropSuiteRuntime(), + } + for _, p := range protocols { + p := p + s.fixtures = append(s.fixtures, interopSuiteFixtureSpec{ + label: "python", + protocol: p, + start: func() (*fixtureProcess, string, error) { + return startPythonFixture(getAssets(), findFreePort(), p) + }, + }) + } + return s } diff --git a/integrations/agntcy-a2a/tests/interop_probe_clients_test.go b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go new file mode 100644 index 00000000..fcf463b1 --- /dev/null +++ b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go @@ -0,0 +1,335 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tests + +// This file implements the external language probe clients: Rust, Python, and .NET. +// Each client invokes the corresponding probe binary as a subprocess with the new +// subcommand CLI, deserializes the JSON response from stdout, and converts stderr +// error JSON to a Go error on non-zero exit. + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/a2aproject/a2a-go/v2/a2a" +) + +// probeError is returned when an external probe exits with code 1. +type probeError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *probeError) Error() string { + return e.Message +} + +func parseProbeError(stderr []byte) error { + var pe probeError + if err := json.Unmarshal(bytes.TrimSpace(stderr), &pe); err == nil && pe.Message != "" { + return &pe + } + if len(stderr) > 0 { + return fmt.Errorf("%s", strings.TrimSpace(string(stderr))) + } + return fmt.Errorf("probe exited with non-zero status") +} + +// probeInvoker is a function that runs the probe binary and returns its stdout/stderr. +type probeInvoker func(ctx context.Context, args ...string) (stdout []byte, stderr []byte, err error) + +// externalProbeClient dispatches A2A operations to an external language probe binary. +type externalProbeClient struct { + baseURL string + invoke probeInvoker +} + +// probeMsgEnvelope wraps a send-message result from the probe. +type probeMsgEnvelope struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// probeEventEnvelope wraps a streaming event from the probe. +type probeEventEnvelope = probeMsgEnvelope + +func (c *externalProbeClient) SendMessage(ctx context.Context, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) { + reqJSON, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal send-message request: %w", err) + } + + stdout, stderr, err := c.invoke(ctx, "send-message", "--message-json", string(reqJSON)) + if err != nil { + return nil, parseProbeError(stderr) + } + + var env probeMsgEnvelope + if err := json.Unmarshal(stdout, &env); err != nil { + return nil, fmt.Errorf("parse send-message response: %w\n%s", err, stdout) + } + + switch env.Type { + case "task": + var task a2a.Task + if err := json.Unmarshal(env.Data, &task); err != nil { + return nil, fmt.Errorf("parse task result: %w", err) + } + return &task, nil + case "message": + var msg a2a.Message + if err := json.Unmarshal(env.Data, &msg); err != nil { + return nil, fmt.Errorf("parse message result: %w", err) + } + return &msg, nil + default: + return nil, fmt.Errorf("unknown send-message result type %q", env.Type) + } +} + +func (c *externalProbeClient) SendStreamingMessage(ctx context.Context, req *a2a.SendMessageRequest) ([]a2a.Event, error) { + reqJSON, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal send-streaming-message request: %w", err) + } + + stdout, stderr, err := c.invoke(ctx, "send-streaming-message", "--message-json", string(reqJSON)) + if err != nil { + return nil, parseProbeError(stderr) + } + + var events []a2a.Event + scanner := bufio.NewScanner(bytes.NewReader(stdout)) + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + + var env probeEventEnvelope + if err := json.Unmarshal(line, &env); err != nil { + return nil, fmt.Errorf("parse streaming event line: %w\n%s", err, line) + } + + event, err := parseStreamEvent(env) + if err != nil { + return nil, err + } + events = append(events, event) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read streaming events: %w", err) + } + + return events, nil +} + +func parseStreamEvent(env probeEventEnvelope) (a2a.Event, error) { + switch env.Type { + case "task": + var task a2a.Task + if err := json.Unmarshal(env.Data, &task); err != nil { + return nil, fmt.Errorf("parse task event: %w", err) + } + return &task, nil + case "task-status-update": + var event a2a.TaskStatusUpdateEvent + if err := json.Unmarshal(env.Data, &event); err != nil { + return nil, fmt.Errorf("parse task-status-update event: %w", err) + } + return &event, nil + case "task-artifact-update": + var event a2a.TaskArtifactUpdateEvent + if err := json.Unmarshal(env.Data, &event); err != nil { + return nil, fmt.Errorf("parse task-artifact-update event: %w", err) + } + return &event, nil + case "message": + var msg a2a.Message + if err := json.Unmarshal(env.Data, &msg); err != nil { + return nil, fmt.Errorf("parse message event: %w", err) + } + return &msg, nil + default: + return nil, fmt.Errorf("unknown stream event type %q", env.Type) + } +} + +func (c *externalProbeClient) GetTask(ctx context.Context, req *a2a.GetTaskRequest) (*a2a.Task, error) { + stdout, stderr, err := c.invoke(ctx, "get-task", "--task-id", string(req.ID)) + if err != nil { + return nil, parseProbeError(stderr) + } + + var task a2a.Task + if err := json.Unmarshal(stdout, &task); err != nil { + return nil, fmt.Errorf("parse get-task response: %w\n%s", err, stdout) + } + return &task, nil +} + +func (c *externalProbeClient) CancelTask(ctx context.Context, req *a2a.CancelTaskRequest) (*a2a.Task, error) { + stdout, stderr, err := c.invoke(ctx, "cancel-task", "--task-id", string(req.ID)) + if err != nil { + return nil, parseProbeError(stderr) + } + + var task a2a.Task + if err := json.Unmarshal(stdout, &task); err != nil { + return nil, fmt.Errorf("parse cancel-task response: %w\n%s", err, stdout) + } + return &task, nil +} + +func (c *externalProbeClient) ListTasks(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) { + stdout, stderr, err := c.invoke(ctx, "list-tasks", "--context-id", req.ContextID) + if err != nil { + return nil, parseProbeError(stderr) + } + + var resp a2a.ListTasksResponse + if err := json.Unmarshal(stdout, &resp); err != nil { + return nil, fmt.Errorf("parse list-tasks response: %w\n%s", err, stdout) + } + return &resp, nil +} + +func (c *externalProbeClient) CreatePushConfig(ctx context.Context, cfg *a2a.PushConfig) (*a2a.PushConfig, error) { + cfgJSON, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal push config: %w", err) + } + + stdout, stderr, err := c.invoke(ctx, "create-push-config", "--config-json", string(cfgJSON)) + if err != nil { + return nil, parseProbeError(stderr) + } + + var result a2a.PushConfig + if err := json.Unmarshal(stdout, &result); err != nil { + return nil, fmt.Errorf("parse create-push-config response: %w\n%s", err, stdout) + } + return &result, nil +} + +func (c *externalProbeClient) GetPushConfig(ctx context.Context, req *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) { + stdout, stderr, err := c.invoke(ctx, "get-push-config", + "--task-id", string(req.TaskID), + "--config-id", req.ID, + ) + if err != nil { + return nil, parseProbeError(stderr) + } + + var result a2a.PushConfig + if err := json.Unmarshal(stdout, &result); err != nil { + return nil, fmt.Errorf("parse get-push-config response: %w\n%s", err, stdout) + } + return &result, nil +} + +func (c *externalProbeClient) ListPushConfigs(ctx context.Context, req *a2a.ListTaskPushConfigRequest) ([]*a2a.PushConfig, error) { + stdout, stderr, err := c.invoke(ctx, "list-push-configs", "--task-id", string(req.TaskID)) + if err != nil { + return nil, parseProbeError(stderr) + } + + var resp a2a.ListTaskPushConfigResponse + if err := json.Unmarshal(stdout, &resp); err != nil { + return nil, fmt.Errorf("parse list-push-configs response: %w\n%s", err, stdout) + } + return resp.Configs, nil +} + +func (c *externalProbeClient) DeletePushConfig(ctx context.Context, req *a2a.DeleteTaskPushConfigRequest) error { + _, stderr, err := c.invoke(ctx, "delete-push-config", + "--task-id", string(req.TaskID), + "--config-id", req.ID, + ) + if err != nil { + return parseProbeError(stderr) + } + return nil +} + +func (c *externalProbeClient) GetExtendedCard(ctx context.Context, req *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) { + stdout, stderr, err := c.invoke(ctx, "get-extended-card") + if err != nil { + return nil, parseProbeError(stderr) + } + + var card a2a.AgentCard + if err := json.Unmarshal(stdout, &card); err != nil { + return nil, fmt.Errorf("parse get-extended-card response: %w\n%s", err, stdout) + } + return &card, nil +} + +func makeProbeInvoker(binary string, globalArgs ...string) probeInvoker { + return func(ctx context.Context, args ...string) ([]byte, []byte, error) { + allArgs := append(globalArgs, args...) + cmd := exec.CommandContext(ctx, binary, allArgs...) + cmd.Dir = componentRoot() + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err := cmd.Run() + return stdoutBuf.Bytes(), stderrBuf.Bytes(), err + } +} + +func newRustProbeClient(getBinaries func() fixtureBinaries, baseURL string) probeClient { + return &externalProbeClient{ + baseURL: baseURL, + invoke: func(ctx context.Context, args ...string) ([]byte, []byte, error) { + invoke := makeProbeInvoker(getBinaries().rustProbe, "--card-url", baseURL) + return invoke(ctx, args...) + }, + } +} + +func newPythonProbeClient(getAssets func() pythonFixtureAssets, baseURL string) probeClient { + return &externalProbeClient{ + baseURL: baseURL, + invoke: func(ctx context.Context, args ...string) ([]byte, []byte, error) { + assets := getAssets() + allArgs := append([]string{"run", assets.probeScript, "--card-url", baseURL}, args...) + cmd := exec.CommandContext(ctx, assets.uvCommand, allArgs...) + cmd.Dir = assets.fixtureDir + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err := cmd.Run() + return stdoutBuf.Bytes(), stderrBuf.Bytes(), err + }, + } +} + +func newDotNetProbeClient(getBinaries func() dotNetFixtureBinaries, baseURL string) probeClient { + return &externalProbeClient{ + baseURL: baseURL, + invoke: func(ctx context.Context, args ...string) ([]byte, []byte, error) { + binaries := getBinaries() + allArgs := append([]string{binaries.dotnetProbeDL, "--card-url", baseURL}, args...) + cmd := exec.CommandContext(ctx, binaries.dotnetCommand, allArgs...) + cmd.Dir = componentRoot() + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err := cmd.Run() + return stdoutBuf.Bytes(), stderrBuf.Bytes(), err + }, + } +} diff --git a/integrations/agntcy-a2a/tests/interop_python_dotnet_test.go b/integrations/agntcy-a2a/tests/interop_python_dotnet_test.go index 55833905..9c7cb9a1 100644 --- a/integrations/agntcy-a2a/tests/interop_python_dotnet_test.go +++ b/integrations/agntcy-a2a/tests/interop_python_dotnet_test.go @@ -3,11 +3,12 @@ package tests -// This file is the Python/.NET suite wrapper. It composes the existing Python v1.0 fixture and -// probe with the reusable .NET fixture/probe binaries and keeps pair-specific expectations local -// to this slice. +// This file is the Python/.NET suite wrapper. Tests cover all four client/server +// pairings of Python and .NET over the transports both SDKs support (JSON-RPC and REST). import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -18,97 +19,90 @@ var _ = ginkgo.Describe("Python+.NET", ginkgo.Ordered, ginkgo.ContinueOnFailure, dotNetAssets dotNetFixtureBinaries ) - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST} - fixtures := []interopSuiteFixtureSpec{ - { - label: "python", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportJSONRPC) - }, - }, - { - label: "dotnet", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(dotNetAssets, findFreePort(), transportJSONRPC) - }, - }, - { - label: "python", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportREST) - }, - }, - { - label: "dotnet", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(dotNetAssets, findFreePort(), transportREST) - }, - }, - } - - pythonPushUnsupported := newPythonProbeHarness( - func() pythonFixtureAssets { return pythonAssets }, - rustProbeOptions{ - expectPushUnsupported: true, - expectedPushErrorCode: dotNetPushUnsupportedCode, - }, - ) - pythonPushSupported := newPythonProbeHarness( - func() pythonFixtureAssets { return pythonAssets }, - rustProbeOptions{expectPushSupported: true}, - ) - dotNetPushUnsupported := newDotNetProbeHarness( - func() dotNetFixtureBinaries { return dotNetAssets }, - dotNetProbeOptions{ - expectPushUnsupported: true, - expectedPushErrorCode: dotNetPushUnsupportedCode, - }, - ) - dotNetPushSupported := newDotNetProbeHarness( - func() dotNetFixtureBinaries { return dotNetAssets }, - dotNetProbeOptions{expectPushSupported: true}, - ) - clients := []interopClientMatrixSpec{ - {label: "python", displayName: "Python", harness: pythonPushUnsupported}, - {label: "dotnet", displayName: ".NET", harness: dotNetPushUnsupported}, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "python", "Python", "python", true, protocols...), - newInteropServerSpec(runtime, "dotnet", ".NET", "dotnet", false, protocols...), - } - clientHarnessOverrides := map[string]interopHarness{ - interopPairKey("python", "python"): pythonPushSupported, - interopPairKey("dotnet", "python"): dotNetPushSupported, - } - ginkgo.BeforeAll(func() { var err error - pythonAssets, err = buildPythonFixtureAssets() gomega.Expect(err).NotTo(gomega.HaveOccurred()) dotNetAssets, err = buildDotNetFixtureBinaryOnly() gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) - err = startInteropSuiteFixtures(runtime, fixtures) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) + ginkgo.AfterAll(func() { removeTempDir(dotNetAssets.tempDir) }) + + ginkgo.When("a Python client calls a Python server", ginkgo.Ordered, func() { + server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + pythonClient := func(ctx context.Context, url string) (probeClient, error) { + return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportREST)) + }) }) - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - removeTempDir(dotNetAssets.tempDir) + ginkgo.When("a Python client calls a .NET server", ginkgo.Ordered, func() { + server := newDotNetServer(func() dotNetFixtureBinaries { return dotNetAssets }, false, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + pythonClient := func(ctx context.Context, url string) (probeClient, error) { + return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-dotnet"), func() { + registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-dotnet"), func() { + registerBehaviors(pythonClient, server.targetFor(transportREST)) + }) }) - registerInteropTransportMatrixWithOverrides( - protocols, - clients, - servers, - nil, - clientHarnessOverrides, - ) + ginkgo.When("a .NET client calls a Python server", ginkgo.Ordered, func() { + server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + dotNetClient := func(ctx context.Context, url string) (probeClient, error) { + return newDotNetProbeClient(func() dotNetFixtureBinaries { return dotNetAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-python"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-python"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportREST)) + }) + }) + + ginkgo.When("a .NET client calls a .NET server", ginkgo.Ordered, func() { + server := newDotNetServer(func() dotNetFixtureBinaries { return dotNetAssets }, false, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + dotNetClient := func(ctx context.Context, url string) (probeClient, error) { + return newDotNetProbeClient(func() dotNetFixtureBinaries { return dotNetAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-dotnet"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-dotnet"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportREST)) + }) + }) }) diff --git a/integrations/agntcy-a2a/tests/interop_python_go_test.go b/integrations/agntcy-a2a/tests/interop_python_go_test.go index 6e20f980..045dcfab 100644 --- a/integrations/agntcy-a2a/tests/interop_python_go_test.go +++ b/integrations/agntcy-a2a/tests/interop_python_go_test.go @@ -3,13 +3,12 @@ package tests -// This file is the Python/Go suite wrapper. It declares the Python v1.0 fixture environment, -// the Go fixture binaries reused in this slice, and the client/server matrix for JSON-RPC and -// HTTP+JSON coverage. -// To expand Python coverage, change the matrix data here and keep the cross-SDK behavior logic in -// interop_behaviors_test.go so the shared slices remain authored once. +// This file is the Python/Go suite wrapper. Tests cover all four client/server +// pairings of Python and Go over JSON-RPC, REST, and gRPC. import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -20,91 +19,102 @@ var _ = ginkgo.Describe("Python+Go", ginkgo.Ordered, ginkgo.ContinueOnFailure, g pythonAssets pythonFixtureAssets ) - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST, transportGRPC} - fixtures := []interopSuiteFixtureSpec{ - { - label: "go", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startGoFixture(goAssets, findFreePort(), transportJSONRPC) - }, - }, - { - label: "python", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportJSONRPC) - }, - }, - { - label: "go", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startGoFixture(goAssets, findFreePort(), transportREST) - }, - }, - { - label: "python", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportREST) - }, - }, - { - label: "go", - protocol: transportGRPC, - start: func() (*fixtureProcess, string, error) { - return startGoFixture(goAssets, findFreePort(), transportGRPC) - }, - }, - { - label: "python", - protocol: transportGRPC, - start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportGRPC) - }, - }, - } - - clients := []interopClientMatrixSpec{ - {label: "go", displayName: "Go", harness: goSDKHarness{}}, - { - label: "python", - displayName: "Python", - harness: newPythonProbeHarness( - func() pythonFixtureAssets { return pythonAssets }, - rustProbeOptions{expectPushSupported: true}, - ), - }, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "go", "Go", "go", true, protocols...), - newInteropServerSpec(runtime, "python", "Python", "python", true, protocols...), - } - ginkgo.BeforeAll(func() { var err error - goAssets, err = buildGoFixtureBinaryOnly() gomega.Expect(err).NotTo(gomega.HaveOccurred()) pythonAssets, err = buildPythonFixtureAssets() gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) - err = startInteropSuiteFixtures(runtime, fixtures) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) + ginkgo.AfterAll(func() { removeTempDir(goAssets.tempDir) }) + + ginkgo.When("a Go client calls a Go server", ginkgo.Ordered, func() { + server := newGoServer(func() fixtureBinaries { return goAssets }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + goClient := func(ctx context.Context, url string) (probeClient, error) { + return newGoProbeClient(ctx, url) + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-go"), func() { + registerBehaviors(goClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-go"), func() { + registerBehaviors(goClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "go-go"), func() { + registerBehaviors(goClient, server.targetFor(transportGRPC)) + }) + }) + + ginkgo.When("a Go client calls a Python server", ginkgo.Ordered, func() { + server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + goClient := func(ctx context.Context, url string) (probeClient, error) { + return newGoProbeClient(ctx, url) + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-python"), func() { + registerBehaviors(goClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-python"), func() { + registerBehaviors(goClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "go-python"), func() { + registerBehaviors(goClient, server.targetFor(transportGRPC)) + }) }) - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - removeTempDir(goAssets.tempDir) + ginkgo.When("a Python client calls a Go server", ginkgo.Ordered, func() { + server := newGoServer(func() fixtureBinaries { return goAssets }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + pythonClient := func(ctx context.Context, url string) (probeClient, error) { + return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-go"), func() { + registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-go"), func() { + registerBehaviors(pythonClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-go"), func() { + registerBehaviors(pythonClient, server.targetFor(transportGRPC)) + }) }) - registerInteropTransportMatrix( - protocols, - clients, - servers, - nil, - ) + ginkgo.When("a Python client calls a Python server", ginkgo.Ordered, func() { + server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + pythonClient := func(ctx context.Context, url string) (probeClient, error) { + return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportGRPC)) + }) + }) }) diff --git a/integrations/agntcy-a2a/tests/interop_rust_dotnet_test.go b/integrations/agntcy-a2a/tests/interop_rust_dotnet_test.go index b9a926dd..2f14dbb6 100644 --- a/integrations/agntcy-a2a/tests/interop_rust_dotnet_test.go +++ b/integrations/agntcy-a2a/tests/interop_rust_dotnet_test.go @@ -3,112 +3,100 @@ package tests -// This file is the Rust/.NET suite wrapper. It declares the Rust and .NET fixtures used in this -// slice, the client/server matrix, and any pair-specific harness overrides such as push-config -// expectations. -// To add a new Rust/.NET leg, change the matrix data here. If a new behavior applies to every leg, -// add it in interop_behaviors_test.go instead of introducing suite-local assertions here. +// This file is the Rust/.NET suite wrapper. Tests cover all four client/server +// pairings of Rust and .NET over the transports both SDKs support (JSON-RPC and REST). import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) -const dotNetPushUnsupportedCode = -32003 - var _ = ginkgo.Describe("Rust+.NET", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-rust-dotnet"), func() { var binaries rustDotNetFixtureBinaries - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST} - fixtures := []interopSuiteFixtureSpec{ - { - label: "dotnet", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(binaries.dotNetAssets(), findFreePort(), transportJSONRPC) - }, - }, - { - label: "rust", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startRustFixture(binaries.rustAssets(), findFreePort(), transportJSONRPC) - }, - }, - { - label: "dotnet", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(binaries.dotNetAssets(), findFreePort(), transportREST) - }, - }, - { - label: "rust", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startRustFixture(binaries.rustAssets(), findFreePort(), transportREST) - }, - }, - } - - rustAssets := func() fixtureBinaries { return binaries.rustAssets() } - dotNetAssets := func() dotNetFixtureBinaries { return binaries.dotNetAssets() } - dotNetPushUnsupported := newDotNetProbeHarness( - dotNetAssets, - dotNetProbeOptions{ - expectPushUnsupported: true, - expectedPushErrorCode: dotNetPushUnsupportedCode, - }, - ) - dotNetPushSupported := newDotNetProbeHarness( - dotNetAssets, - dotNetProbeOptions{expectPushSupported: true}, - ) - rustPushUnsupported := newRustProbeHarness( - rustAssets, - rustProbeOptions{ - expectPushUnsupported: true, - expectedPushErrorCode: dotNetPushUnsupportedCode, - }, - ) - rustPushSupported := newRustProbeHarness( - rustAssets, - rustProbeOptions{expectPushSupported: true}, - ) - clients := []interopClientMatrixSpec{ - {label: "dotnet", displayName: ".NET", harness: dotNetPushUnsupported}, - {label: "rust", displayName: "Rust", harness: rustPushUnsupported}, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "dotnet", ".NET", "dotnet", false, protocols...), - newInteropServerSpec(runtime, "rust", "Rust", "rust", true, protocols...), - } - clientHarnessOverrides := map[string]interopHarness{ - interopPairKey("dotnet", "rust"): dotNetPushSupported, - interopPairKey("rust", "rust"): rustPushSupported, - } - ginkgo.BeforeAll(func() { var err error - binaries, err = buildRustDotNetFixtureBinaries() gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) - err = startInteropSuiteFixtures(runtime, fixtures) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) + ginkgo.AfterAll(func() { removeTempDir(binaries.tempDir) }) + + ginkgo.When("a Rust client calls a Rust server", ginkgo.Ordered, func() { + server := newRustServer(func() fixtureBinaries { return binaries.rustAssets() }, true, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + rustClient := func(ctx context.Context, url string) (probeClient, error) { + return newRustProbeClient(func() fixtureBinaries { return binaries.rustAssets() }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-rust"), func() { + registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-rust"), func() { + registerBehaviors(rustClient, server.targetFor(transportREST)) + }) + }) + + ginkgo.When("a Rust client calls a .NET server", ginkgo.Ordered, func() { + server := newDotNetServer(func() dotNetFixtureBinaries { return binaries.dotNetAssets() }, false, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + rustClient := func(ctx context.Context, url string) (probeClient, error) { + return newRustProbeClient(func() fixtureBinaries { return binaries.rustAssets() }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-dotnet"), func() { + registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-dotnet"), func() { + registerBehaviors(rustClient, server.targetFor(transportREST)) + }) }) - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - removeTempDir(binaries.tempDir) + ginkgo.When("a .NET client calls a Rust server", ginkgo.Ordered, func() { + server := newRustServer(func() fixtureBinaries { return binaries.rustAssets() }, true, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + dotNetClient := func(ctx context.Context, url string) (probeClient, error) { + return newDotNetProbeClient(func() dotNetFixtureBinaries { return binaries.dotNetAssets() }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-rust"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-rust"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportREST)) + }) }) - registerInteropTransportMatrixWithOverrides( - protocols, - clients, - servers, - map[string]string{interopPairKey("rust", "rust"): "rust-rust-dotnet"}, - clientHarnessOverrides, - ) + ginkgo.When("a .NET client calls a .NET server", ginkgo.Ordered, func() { + server := newDotNetServer(func() dotNetFixtureBinaries { return binaries.dotNetAssets() }, false, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + dotNetClient := func(ctx context.Context, url string) (probeClient, error) { + return newDotNetProbeClient(func() dotNetFixtureBinaries { return binaries.dotNetAssets() }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-dotnet"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-dotnet"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportREST)) + }) + }) }) diff --git a/integrations/agntcy-a2a/tests/interop_rust_go_test.go b/integrations/agntcy-a2a/tests/interop_rust_go_test.go index 36610dab..e1371e27 100644 --- a/integrations/agntcy-a2a/tests/interop_rust_go_test.go +++ b/integrations/agntcy-a2a/tests/interop_rust_go_test.go @@ -3,13 +3,9 @@ package tests -// This file is the Rust/Go suite wrapper. It owns only the fixture lifecycle and the client/server -// matrix for this suite, then delegates spec generation to the shared behavior layer. -// To add a new Rust/Go leg, update the clients or servers declared here and let the shared matrix -// registration create the specs. Do not add one-off It blocks unless the suite truly needs a -// behavior that cannot be expressed through the shared harness model. - import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -17,88 +13,55 @@ import ( var _ = ginkgo.Describe("Rust+Go", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-rust-go"), func() { var binaries fixtureBinaries - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST, transportGRPC} - fixtures := []interopSuiteFixtureSpec{ - { - label: "go", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startGoFixture(binaries, findFreePort(), transportJSONRPC) - }, - }, - { - label: "rust", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startRustFixture(binaries, findFreePort(), transportJSONRPC) - }, - }, - { - label: "go", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startGoFixture(binaries, findFreePort(), transportREST) - }, - }, - { - label: "rust", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startRustFixture(binaries, findFreePort(), transportREST) - }, - }, - { - label: "go", - protocol: transportGRPC, - start: func() (*fixtureProcess, string, error) { - return startGoFixture(binaries, findFreePort(), transportGRPC) - }, - }, - { - label: "rust", - protocol: transportGRPC, - start: func() (*fixtureProcess, string, error) { - return startRustFixture(binaries, findFreePort(), transportGRPC) - }, - }, - } - - clients := []interopClientMatrixSpec{ - {label: "go", displayName: "Go", harness: goSDKHarness{}}, - { - label: "rust", - displayName: "Rust", - harness: newRustProbeHarness( - func() fixtureBinaries { return binaries }, - rustProbeOptions{expectPushSupported: true}, - ), - }, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "go", "Go", "go", true, protocols...), - newInteropServerSpec(runtime, "rust", "Rust", "rust", true, protocols...), - } - ginkgo.BeforeAll(func() { var err error - binaries, err = buildFixtureBinaries() gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - err = startInteropSuiteFixtures(runtime, fixtures) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - removeTempDir(binaries.tempDir) + ginkgo.AfterAll(func() { removeTempDir(binaries.tempDir) }) + + ginkgo.When("a Go client calls a Rust server", ginkgo.Ordered, func() { + server := newRustServer(func() fixtureBinaries { return binaries }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + goClient := func(ctx context.Context, url string) (probeClient, error) { + return newGoProbeClient(ctx, url) + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-rust"), func() { + registerBehaviors(goClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-rust"), func() { + registerBehaviors(goClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "go-rust"), func() { + registerBehaviors(goClient, server.targetFor(transportGRPC)) + }) }) - registerInteropTransportMatrix( - protocols, - clients, - servers, - nil, - ) + ginkgo.When("a Rust client calls a Go server", ginkgo.Ordered, func() { + server := newGoServer(func() fixtureBinaries { return binaries }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + rustClient := func(ctx context.Context, url string) (probeClient, error) { + return newRustProbeClient(func() fixtureBinaries { return binaries }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-go"), func() { + registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-go"), func() { + registerBehaviors(rustClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "rust-go"), func() { + registerBehaviors(rustClient, server.targetFor(transportGRPC)) + }) + }) }) diff --git a/integrations/agntcy-a2a/tests/interop_rust_python_test.go b/integrations/agntcy-a2a/tests/interop_rust_python_test.go index 266fa5ac..9b24485b 100644 --- a/integrations/agntcy-a2a/tests/interop_rust_python_test.go +++ b/integrations/agntcy-a2a/tests/interop_rust_python_test.go @@ -3,11 +3,12 @@ package tests -// This file is the Rust/Python suite wrapper. It keeps the matrix declaration, -// fixture lifecycle, and transport coverage local to this slice while reusing -// the shared Go-authored behavior assertions. +// This file is the Rust/Python suite wrapper. Tests cover all four client/server +// pairings of Rust and Python over JSON-RPC, REST, and gRPC. import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -18,98 +19,102 @@ var _ = ginkgo.Describe("Rust+Python", ginkgo.Ordered, ginkgo.ContinueOnFailure, pythonAssets pythonFixtureAssets ) - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST, transportGRPC} - fixtures := []interopSuiteFixtureSpec{ - { - label: "rust", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startRustFixture(rustAssets, findFreePort(), transportJSONRPC) - }, - }, - { - label: "python", - protocol: transportJSONRPC, - start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportJSONRPC) - }, - }, - { - label: "rust", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startRustFixture(rustAssets, findFreePort(), transportREST) - }, - }, - { - label: "python", - protocol: transportREST, - start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportREST) - }, - }, - { - label: "rust", - protocol: transportGRPC, - start: func() (*fixtureProcess, string, error) { - return startRustFixture(rustAssets, findFreePort(), transportGRPC) - }, - }, - { - label: "python", - protocol: transportGRPC, - start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportGRPC) - }, - }, - } - - clients := []interopClientMatrixSpec{ - { - label: "rust", - displayName: "Rust", - harness: newRustProbeHarness( - func() fixtureBinaries { return rustAssets }, - rustProbeOptions{expectPushSupported: true}, - ), - }, - { - label: "python", - displayName: "Python", - harness: newPythonProbeHarness( - func() pythonFixtureAssets { return pythonAssets }, - rustProbeOptions{expectPushSupported: true}, - ), - }, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "rust", "Rust", "rust", true, protocols...), - newInteropServerSpec(runtime, "python", "Python", "python", true, protocols...), - } - ginkgo.BeforeAll(func() { var err error - rustAssets, err = buildRustFixtureBinaryOnly() gomega.Expect(err).NotTo(gomega.HaveOccurred()) pythonAssets, err = buildPythonFixtureAssets() gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) - err = startInteropSuiteFixtures(runtime, fixtures) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) + ginkgo.AfterAll(func() { removeTempDir(rustAssets.tempDir) }) + + ginkgo.When("a Rust client calls a Rust server", ginkgo.Ordered, func() { + server := newRustServer(func() fixtureBinaries { return rustAssets }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + rustClient := func(ctx context.Context, url string) (probeClient, error) { + return newRustProbeClient(func() fixtureBinaries { return rustAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-rust"), func() { + registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-rust"), func() { + registerBehaviors(rustClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "rust-rust"), func() { + registerBehaviors(rustClient, server.targetFor(transportGRPC)) + }) + }) + + ginkgo.When("a Rust client calls a Python server", ginkgo.Ordered, func() { + server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + rustClient := func(ctx context.Context, url string) (probeClient, error) { + return newRustProbeClient(func() fixtureBinaries { return rustAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-python"), func() { + registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-python"), func() { + registerBehaviors(rustClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "rust-python"), func() { + registerBehaviors(rustClient, server.targetFor(transportGRPC)) + }) }) - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - removeTempDir(rustAssets.tempDir) + ginkgo.When("a Python client calls a Rust server", ginkgo.Ordered, func() { + server := newRustServer(func() fixtureBinaries { return rustAssets }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + pythonClient := func(ctx context.Context, url string) (probeClient, error) { + return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-rust"), func() { + registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-rust"), func() { + registerBehaviors(pythonClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-rust"), func() { + registerBehaviors(pythonClient, server.targetFor(transportGRPC)) + }) }) - registerInteropTransportMatrix( - protocols, - clients, - servers, - nil, - ) + ginkgo.When("a Python client calls a Python server", ginkgo.Ordered, func() { + server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + pythonClient := func(ctx context.Context, url string) (probeClient, error) { + return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportGRPC)) + }) + }) }) diff --git a/integrations/agntcy-a2a/tests/interop_self_dotnet_test.go b/integrations/agntcy-a2a/tests/interop_self_dotnet_test.go index 3d92a04e..b74d37bc 100644 --- a/integrations/agntcy-a2a/tests/interop_self_dotnet_test.go +++ b/integrations/agntcy-a2a/tests/interop_self_dotnet_test.go @@ -4,54 +4,39 @@ package tests import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) var _ = ginkgo.Describe(".NET", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-self-dotnet"), func() { - var dotNetAssets dotNetFixtureBinaries - - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST} - fixtures := []interopSuiteFixtureSpec{ - {label: "dotnet", protocol: transportJSONRPC, start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(dotNetAssets, findFreePort(), transportJSONRPC) - }}, - {label: "dotnet", protocol: transportREST, start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(dotNetAssets, findFreePort(), transportREST) - }}, - } - - clients := []interopClientMatrixSpec{ - { - label: "dotnet", - displayName: ".NET", - harness: newDotNetProbeHarness( - func() dotNetFixtureBinaries { return dotNetAssets }, - dotNetProbeOptions{ - expectPushUnsupported: true, - expectedPushErrorCode: dotNetPushUnsupportedCode, - }, - ), - }, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "dotnet", ".NET", "dotnet", false, protocols...), - } + var assets dotNetFixtureBinaries ginkgo.BeforeAll(func() { var err error - dotNetAssets, err = buildDotNetFixtureBinaryOnly() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - err = startInteropSuiteFixtures(runtime, fixtures) + assets, err = buildDotNetFixtureBinaryOnly() gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - removeTempDir(dotNetAssets.tempDir) - }) + ginkgo.AfterAll(func() { removeTempDir(assets.tempDir) }) - registerInteropSelfTestMatrix(protocols, clients, servers) + ginkgo.When("a .NET client calls a .NET server", ginkgo.Ordered, func() { + server := newDotNetServer(func() dotNetFixtureBinaries { return assets }, false, + transportJSONRPC, transportREST) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + dotNetClient := func(ctx context.Context, url string) (probeClient, error) { + return newDotNetProbeClient(func() dotNetFixtureBinaries { return assets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-dotnet"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-dotnet"), func() { + registerBehaviors(dotNetClient, server.targetFor(transportREST)) + }) + }) }) diff --git a/integrations/agntcy-a2a/tests/interop_self_go_test.go b/integrations/agntcy-a2a/tests/interop_self_go_test.go index b90f358e..f73f21ab 100644 --- a/integrations/agntcy-a2a/tests/interop_self_go_test.go +++ b/integrations/agntcy-a2a/tests/interop_self_go_test.go @@ -4,6 +4,8 @@ package tests import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -11,40 +13,33 @@ import ( var _ = ginkgo.Describe("Go", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-self-go"), func() { var binaries fixtureBinaries - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST, transportGRPC} - fixtures := []interopSuiteFixtureSpec{ - {label: "go", protocol: transportJSONRPC, start: func() (*fixtureProcess, string, error) { - return startGoFixture(binaries, findFreePort(), transportJSONRPC) - }}, - {label: "go", protocol: transportREST, start: func() (*fixtureProcess, string, error) { - return startGoFixture(binaries, findFreePort(), transportREST) - }}, - {label: "go", protocol: transportGRPC, start: func() (*fixtureProcess, string, error) { - return startGoFixture(binaries, findFreePort(), transportGRPC) - }}, - } - - clients := []interopClientMatrixSpec{ - {label: "go", displayName: "Go", harness: goSDKHarness{}}, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "go", "Go", "go", true, protocols...), - } - ginkgo.BeforeAll(func() { var err error binaries, err = buildGoFixtureBinaryOnly() gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - err = startInteropSuiteFixtures(runtime, fixtures) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - removeTempDir(binaries.tempDir) - }) + ginkgo.AfterAll(func() { removeTempDir(binaries.tempDir) }) - registerInteropSelfTestMatrix(protocols, clients, servers) + ginkgo.When("a Go client calls a Go server", ginkgo.Ordered, func() { + server := newGoServer(func() fixtureBinaries { return binaries }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + goClient := func(ctx context.Context, url string) (probeClient, error) { + return newGoProbeClient(ctx, url) + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-go"), func() { + registerBehaviors(goClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-go"), func() { + registerBehaviors(goClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "go-go"), func() { + registerBehaviors(goClient, server.targetFor(transportGRPC)) + }) + }) }) diff --git a/integrations/agntcy-a2a/tests/interop_self_python_test.go b/integrations/agntcy-a2a/tests/interop_self_python_test.go index df80f1b2..921fd7da 100644 --- a/integrations/agntcy-a2a/tests/interop_self_python_test.go +++ b/integrations/agntcy-a2a/tests/interop_self_python_test.go @@ -4,53 +4,40 @@ package tests import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) var _ = ginkgo.Describe("Python", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-self-python"), func() { - var pythonAssets pythonFixtureAssets - - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST, transportGRPC} - fixtures := []interopSuiteFixtureSpec{ - {label: "python", protocol: transportJSONRPC, start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportJSONRPC) - }}, - {label: "python", protocol: transportREST, start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportREST) - }}, - {label: "python", protocol: transportGRPC, start: func() (*fixtureProcess, string, error) { - return startPythonFixture(pythonAssets, findFreePort(), transportGRPC) - }}, - } - - clients := []interopClientMatrixSpec{ - { - label: "python", - displayName: "Python", - harness: newPythonProbeHarness( - func() pythonFixtureAssets { return pythonAssets }, - rustProbeOptions{expectPushSupported: true}, - ), - }, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "python", "Python", "python", true, protocols...), - } + var assets pythonFixtureAssets ginkgo.BeforeAll(func() { var err error - pythonAssets, err = buildPythonFixtureAssets() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - err = startInteropSuiteFixtures(runtime, fixtures) + assets, err = buildPythonFixtureAssets() gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) + ginkgo.When("a Python client calls a Python server", ginkgo.Ordered, func() { + server := newPythonServer(func() pythonFixtureAssets { return assets }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + pythonClient := func(ctx context.Context, url string) (probeClient, error) { + return newPythonProbeClient(func() pythonFixtureAssets { return assets }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-python"), func() { + registerBehaviors(pythonClient, server.targetFor(transportGRPC)) + }) }) - - registerInteropSelfTestMatrix(protocols, clients, servers) }) diff --git a/integrations/agntcy-a2a/tests/interop_self_rust_test.go b/integrations/agntcy-a2a/tests/interop_self_rust_test.go index 73d1bcbd..8e93c9ac 100644 --- a/integrations/agntcy-a2a/tests/interop_self_rust_test.go +++ b/integrations/agntcy-a2a/tests/interop_self_rust_test.go @@ -4,6 +4,8 @@ package tests import ( + "context" + ginkgo "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -11,47 +13,33 @@ import ( var _ = ginkgo.Describe("Rust", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-self-rust"), func() { var binaries fixtureBinaries - runtime := newInteropSuiteRuntime() - protocols := []transportProtocol{transportJSONRPC, transportREST, transportGRPC} - fixtures := []interopSuiteFixtureSpec{ - {label: "rust", protocol: transportJSONRPC, start: func() (*fixtureProcess, string, error) { - return startRustFixture(binaries, findFreePort(), transportJSONRPC) - }}, - {label: "rust", protocol: transportREST, start: func() (*fixtureProcess, string, error) { - return startRustFixture(binaries, findFreePort(), transportREST) - }}, - {label: "rust", protocol: transportGRPC, start: func() (*fixtureProcess, string, error) { - return startRustFixture(binaries, findFreePort(), transportGRPC) - }}, - } - - clients := []interopClientMatrixSpec{ - { - label: "rust", - displayName: "Rust", - harness: newRustProbeHarness( - func() fixtureBinaries { return binaries }, - rustProbeOptions{expectPushSupported: true}, - ), - }, - } - servers := []interopServerMatrixSpec{ - newInteropServerSpec(runtime, "rust", "Rust", "rust", true, protocols...), - } - ginkgo.BeforeAll(func() { var err error binaries, err = buildRustFixtureBinaryOnly() gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - err = startInteropSuiteFixtures(runtime, fixtures) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - removeTempDir(binaries.tempDir) - }) + ginkgo.AfterAll(func() { removeTempDir(binaries.tempDir) }) - registerInteropSelfTestMatrix(protocols, clients, servers) + ginkgo.When("a Rust client calls a Rust server", ginkgo.Ordered, func() { + server := newRustServer(func() fixtureBinaries { return binaries }, true, + transportJSONRPC, transportREST, transportGRPC) + + ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) + ginkgo.AfterAll(func() { server.stop() }) + + rustClient := func(ctx context.Context, url string) (probeClient, error) { + return newRustProbeClient(func() fixtureBinaries { return binaries }, url), nil + } + + ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-rust"), func() { + registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) + }) + ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-rust"), func() { + registerBehaviors(rustClient, server.targetFor(transportREST)) + }) + ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "rust-rust"), func() { + registerBehaviors(rustClient, server.targetFor(transportGRPC)) + }) + }) }) diff --git a/integrations/agntcy-a2a/tests/interop_shared_test.go b/integrations/agntcy-a2a/tests/interop_shared_test.go index b58e6d4e..ea46b462 100644 --- a/integrations/agntcy-a2a/tests/interop_shared_test.go +++ b/integrations/agntcy-a2a/tests/interop_shared_test.go @@ -3,10 +3,11 @@ package tests -// This file holds the reusable interop test primitives: shared constants, transport and -// scenario enums, fixture process types, and generic lifecycle helpers. -// Add code here only when multiple suites or harnesses need the same low-level plumbing. -// New behavior assertions belong in interop_behaviors_test.go, and suite-specific matrix +// This file holds the reusable interop test primitives: shared constants, transport +// enums, fixture process types, the interopSuiteRuntime, and the fixtureServer +// abstraction that each suite wrapper uses to manage server lifecycle. +// Add code here only when multiple suites or behaviors need the same low-level plumbing. +// New behavior assertions belong in interop_behaviors_test.go, and suite-specific // wiring belongs in the suite wrapper files. import ( @@ -60,27 +61,12 @@ const ( transportGRPC transportProtocol = "grpc" ) -type probeScenario string - -const ( - probeScenarioCore probeScenario = "core" - probeScenarioTaskStreaming probeScenario = "task-streaming" - probeScenarioTaskLifecycle probeScenario = "task-lifecycle" - probeScenarioPushConfig probeScenario = "push-config" - probeScenarioParity probeScenario = "parity" -) - -type rustProbeOptions struct { - scenario probeScenario - expectSubscribeUnsupported bool - expectPushSupported bool - expectPushUnsupported bool - relaxedErrorChecks bool - expectedPushErrorCode int +type interopTarget struct { + baseURL string + serverPrefix string + expectPushSupported bool } -type dotNetProbeOptions = rustProbeOptions - type fixtureBinaries struct { tempDir string goServer string @@ -299,21 +285,6 @@ func (runtime *interopSuiteRuntime) fixtureURL(label string, protocol transportP return runtime.urls[interopSuiteFixtureKey(label, protocol)] } -func (runtime *interopSuiteRuntime) fixtureURLs( - label string, - protocols ...transportProtocol, -) map[transportProtocol]func() string { - urls := make(map[transportProtocol]func() string, len(protocols)) - for _, protocol := range protocols { - protocol := protocol - urls[protocol] = func() string { - return runtime.fixtureURL(label, protocol) - } - } - - return urls -} - func (runtime *interopSuiteRuntime) stopFixtures(fixtures []interopSuiteFixtureSpec) { for index := len(fixtures) - 1; index >= 0; index-- { fixture := fixtures[index] @@ -337,20 +308,32 @@ func startInteropSuiteFixtures(runtime *interopSuiteRuntime, fixtures []interopS return nil } -func newInteropServerSpec( - runtime *interopSuiteRuntime, - label string, - displayName string, - serverPrefix string, - expectPushSupported bool, - protocols ...transportProtocol, -) interopServerMatrixSpec { - return interopServerMatrixSpec{ - label: label, - displayName: displayName, - serverPrefix: serverPrefix, - expectPushSupported: expectPushSupported, - urls: runtime.fixtureURLs(label, protocols...), +// fixtureServer manages the lifecycle of a server fixture for a single When block. +type fixtureServer struct { + serverPrefix string + expectPushSupported bool + runtime *interopSuiteRuntime + fixtures []interopSuiteFixtureSpec +} + +func (s *fixtureServer) start() error { + return startInteropSuiteFixtures(s.runtime, s.fixtures) +} + +func (s *fixtureServer) stop() { + s.runtime.stopFixtures(s.fixtures) +} + +// targetFor returns a lazy getter for the interopTarget for a given protocol. +// The returned function is safe to call at spec registration time (expectPushSupported +// is known; baseURL is resolved lazily when the function is actually called in BeforeAll). +func (s *fixtureServer) targetFor(protocol transportProtocol) func() interopTarget { + return func() interopTarget { + return interopTarget{ + baseURL: s.runtime.fixtureURL(s.serverPrefix, protocol), + serverPrefix: s.serverPrefix, + expectPushSupported: s.expectPushSupported, + } } } diff --git a/integrations/agntcy-a2a/tests/native_go_harness_test.go b/integrations/agntcy-a2a/tests/native_go_harness_test.go deleted file mode 100644 index 8052c7d6..00000000 --- a/integrations/agntcy-a2a/tests/native_go_harness_test.go +++ /dev/null @@ -1,569 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -// This file isolates the native Go SDK harness used by the shared interop behaviors. -// Keep the matrix wiring in interop_behaviors_test.go and add Go-specific request and -// assertion helpers here so the cross-SDK behavior registry stays readable and the -// test-only native Go path is easy to find. - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/a2aproject/a2a-go/v2/a2a" - "github.com/a2aproject/a2a-go/v2/a2aclient" - "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" - a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" -) - -type goSDKHarness struct{} - -func (goSDKHarness) AssertTaskStreaming(ctx context.Context, target interopTarget) { - client, err := newGoClient(ctx, target.baseURL) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - unaryText, err := goClientUnaryText(ctx, client) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(unaryText).To(gomega.Equal(expectedServerText(target.serverPrefix, requestText))) - - streamText, err := goClientStreamingText(ctx, client) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(streamText).To(gomega.Equal(expectedServerText(target.serverPrefix, requestText))) -} - -func (goSDKHarness) AssertTaskLifecycle(ctx context.Context, target interopTarget) { - client, err := newGoClient(ctx, target.baseURL) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - goClientAssertTaskLifecycle(ctx, client, target.serverPrefix) -} - -func (goSDKHarness) AssertPushConfig(ctx context.Context, target interopTarget) { - client, err := newGoClient(ctx, target.baseURL) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - completedTask, err := goClientSendTask(ctx, client, requestText, false) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(completedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - - completedText, err := taskStatusText(completedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(completedText).To(gomega.Equal(expectedServerText(target.serverPrefix, requestText))) - assertTaskHistoryPayload(completedTask, requestText, "push config task history") - - if !target.expectPushSupported { - goClientAssertPushUnsupported(ctx, client, completedTask.ID) - return - } - - goClientAssertPushLifecycle(ctx, client, completedTask.ID) -} - -func (goSDKHarness) AssertScenarioParity(ctx context.Context, target interopTarget) { - client, err := newGoClient(ctx, target.baseURL) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - goClientAssertScenarioParity(ctx, client, target.serverPrefix) -} - -func newGoClient(ctx context.Context, baseURL string) (*a2aclient.Client, error) { - card, err := agentcard.DefaultResolver.Resolve(ctx, baseURL) - if err != nil { - return nil, err - } - - return a2aclient.NewFromCard( - ctx, - card, - a2agrpc.WithGRPCTransport(grpc.WithTransportCredentials(insecure.NewCredentials())), - ) -} - -func newInteropRequest(text string, returnImmediately bool) *a2a.SendMessageRequest { - return newInteropRequestWithIDs(text, returnImmediately, "", "") -} - -func newInteropRequestWithIDs(text string, returnImmediately bool, taskID a2a.TaskID, contextID string) *a2a.SendMessageRequest { - message := a2a.NewMessage( - a2a.MessageRoleUser, - a2a.NewTextPart(text), - a2a.NewDataPart(map[string]any{ - "kind": requestDataKind, - "scope": requestDataScope, - }), - ) - if taskID != "" { - message.TaskID = taskID - } - if contextID != "" { - message.ContextID = contextID - } - message.Metadata = map[string]any{ - requestMetadataKey: requestMetadataValue, - } - - return &a2a.SendMessageRequest{ - Message: message, - Config: &a2a.SendMessageConfig{ - ReturnImmediately: returnImmediately, - }, - } -} - -func assertMessageInteropPayload(message *a2a.Message, expectedText string, kind string) { - gomega.Expect(message).NotTo(gomega.BeNil(), kind) - - text, err := firstMessageText(message) - gomega.Expect(err).NotTo(gomega.HaveOccurred(), kind) - gomega.Expect(text).To(gomega.Equal(expectedText), kind) - gomega.Expect(message.Parts).To(gomega.HaveLen(2), kind) - - dataPart, ok := message.Parts[1].Data().(map[string]any) - gomega.Expect(ok).To(gomega.BeTrue(), kind) - gomega.Expect(dataPart).To(gomega.HaveKeyWithValue("kind", requestDataKind), kind) - gomega.Expect(dataPart).To(gomega.HaveKeyWithValue("scope", requestDataScope), kind) - gomega.Expect(message.Metadata).To(gomega.HaveKeyWithValue(requestMetadataKey, requestMetadataValue), kind) -} - -func assertTaskHistoryPayload(task *a2a.Task, expectedText string, kind string) { - assertTaskHistoryPayloads(task, []string{expectedText}, kind) -} - -func assertTaskHistoryPayloads(task *a2a.Task, expectedTexts []string, kind string) { - gomega.Expect(task.History).To(gomega.HaveLen(len(expectedTexts)), kind) - for index, expectedText := range expectedTexts { - assertMessageInteropPayload(task.History[index], expectedText, kind) - } -} - -func firstMessageText(message *a2a.Message) (string, error) { - for _, part := range message.Parts { - if text := part.Text(); text != "" { - return text, nil - } - } - - return "", errors.New("message did not include a text part") -} - -func newInteropPushConfig() *a2a.PushConfig { - return &a2a.PushConfig{ - ID: "interop-config", - URL: "https://example.invalid/webhook", - Token: "interop-token", - Auth: &a2a.PushAuthInfo{ - Scheme: "Bearer", - Credentials: "interop-credential", - }, - } -} - -func assertTaskPushConfig(config *a2a.PushConfig, taskID a2a.TaskID, expected *a2a.PushConfig, kind string) { - gomega.Expect(config).NotTo(gomega.BeNil(), kind) - gomega.Expect(config.TaskID).To(gomega.Equal(taskID), kind) - gomega.Expect(config.ID).To(gomega.Equal(expected.ID), kind) - gomega.Expect(config.URL).To(gomega.Equal(expected.URL), kind) - gomega.Expect(config.Token).To(gomega.Equal(expected.Token), kind) - gomega.Expect(config.Auth).To(gomega.Equal(expected.Auth), kind) -} - -func goClientAssertPushLifecycle(ctx context.Context, client *a2aclient.Client, taskID a2a.TaskID) { - pushConfig := newInteropPushConfig() - - pushConfig.TaskID = taskID - createdConfig, err := client.CreateTaskPushConfig(ctx, pushConfig) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - assertTaskPushConfig(createdConfig, taskID, pushConfig, "created push config") - - fetchedConfig, err := client.GetTaskPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ - TaskID: taskID, - ID: pushConfig.ID, - }) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - assertTaskPushConfig(fetchedConfig, taskID, pushConfig, "fetched push config") - - listedConfigs, err := client.ListTaskPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: taskID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(listedConfigs).To(gomega.HaveLen(1)) - assertTaskPushConfig(listedConfigs[0], taskID, pushConfig, "listed push config") - - err = client.DeleteTaskPushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ - TaskID: taskID, - ID: pushConfig.ID, - }) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - listedConfigs, err = client.ListTaskPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: taskID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(listedConfigs).To(gomega.BeEmpty()) -} - -func goClientAssertPushUnsupported(ctx context.Context, client *a2aclient.Client, taskID a2a.TaskID) { - pushConfig := newInteropPushConfig() - expectUnsupportedError := func(err error, kind string) { - gomega.Expect(err).To(gomega.HaveOccurred(), kind) - gomega.Expect(strings.ToLower(err.Error())).To(gomega.Or( - gomega.ContainSubstring("push notification not supported"), - gomega.ContainSubstring("push_notification_not_supported"), - gomega.ContainSubstring("not supported"), - gomega.ContainSubstring("unsupported"), - gomega.ContainSubstring("unimplemented"), - gomega.ContainSubstring("server error"), - ), kind) - } - - pushConfig.TaskID = taskID - _, err := client.CreateTaskPushConfig(ctx, pushConfig) - expectUnsupportedError(err, "create push config unsupported") - - _, err = client.GetTaskPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ - TaskID: taskID, - ID: pushConfig.ID, - }) - expectUnsupportedError(err, "get push config unsupported") - - _, err = client.ListTaskPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: taskID}) - expectUnsupportedError(err, "list push config unsupported") - - err = client.DeleteTaskPushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ - TaskID: taskID, - ID: pushConfig.ID, - }) - expectUnsupportedError(err, "delete push config unsupported") -} - -func assertTaskNotFoundError(err error, kind string) { - gomega.Expect(err).To(gomega.HaveOccurred(), kind) - gomega.Expect(strings.ToLower(err.Error())).To(gomega.Or( - gomega.ContainSubstring("task not found"), - gomega.ContainSubstring("not found"), - ), kind) -} - -func assertTaskNotCancelableError(err error, kind string) { - gomega.Expect(err).To(gomega.HaveOccurred(), kind) - gomega.Expect(strings.ToLower(err.Error())).To(gomega.Or( - gomega.ContainSubstring("cancel"), - gomega.ContainSubstring("terminal state"), - gomega.ContainSubstring("not cancelable"), - ), kind) -} - -func taskStatusText(task *a2a.Task) (string, error) { - if task == nil || task.Status.Message == nil { - return "", errors.New("task status did not include a message") - } - - return firstMessageText(task.Status.Message) -} - -func eventText(event a2a.Event) (string, bool, error) { - switch value := event.(type) { - case *a2a.Message: - text, err := firstMessageText(value) - return text, true, err - case *a2a.Task: - text, err := taskStatusText(value) - return text, true, err - case *a2a.TaskStatusUpdateEvent: - if value.Status.Message == nil { - return "", false, nil - } - text, err := firstMessageText(value.Status.Message) - return text, true, err - default: - return "", false, nil - } -} - -func firstArtifactText(artifact *a2a.Artifact) (string, error) { - if artifact == nil { - return "", errors.New("artifact was nil") - } - - for _, part := range artifact.Parts { - if text := part.Text(); text != "" { - return text, nil - } - } - - return "", errors.New("artifact did not include a text part") -} - -func goClientSendResult( - ctx context.Context, - client *a2aclient.Client, - text string, - returnImmediately bool, - taskID a2a.TaskID, - contextID string, -) (a2a.SendMessageResult, error) { - return client.SendMessage(ctx, newInteropRequestWithIDs(text, returnImmediately, taskID, contextID)) -} - -func goClientSendTask(ctx context.Context, client *a2aclient.Client, text string, returnImmediately bool) (*a2a.Task, error) { - result, err := goClientSendResult(ctx, client, text, returnImmediately, "", "") - if err != nil { - return nil, err - } - - task, ok := result.(*a2a.Task) - if !ok { - return nil, fmt.Errorf("unexpected unary response type %T", result) - } - - return task, nil -} - -func goClientSendMessage(ctx context.Context, client *a2aclient.Client, text string) (*a2a.Message, error) { - result, err := goClientSendResult(ctx, client, text, false, "", "") - if err != nil { - return nil, err - } - - message, ok := result.(*a2a.Message) - if !ok { - return nil, fmt.Errorf("unexpected unary response type %T", result) - } - - return message, nil -} - -func goClientUnaryText(ctx context.Context, client *a2aclient.Client) (string, error) { - task, err := goClientSendTask(ctx, client, requestText, false) - if err != nil { - return "", err - } - - return taskStatusText(task) -} - -func goClientStreamingText(ctx context.Context, client *a2aclient.Client) (string, error) { - request := newInteropRequest(requestText, false) - - for event, err := range client.SendStreamingMessage(ctx, request) { - if err != nil { - return "", err - } - - text, hasText, textErr := eventText(event) - if textErr != nil { - return "", textErr - } - if !hasText { - continue - } - - return text, nil - } - - return "", errors.New("stream completed without a message event") -} - -func goClientAssertTaskLifecycle(ctx context.Context, client *a2aclient.Client, serverPrefix string) { - completedTask, err := goClientSendTask(ctx, client, requestText, false) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(completedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - - completedText, err := taskStatusText(completedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(completedText).To(gomega.Equal(expectedServerText(serverPrefix, requestText))) - assertTaskHistoryPayload(completedTask, requestText, "completed task history") - - fetchedTask, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: completedTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(fetchedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - - fetchedText, err := taskStatusText(fetchedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(fetchedText).To(gomega.Equal(expectedServerText(serverPrefix, requestText))) - assertTaskHistoryPayload(fetchedTask, requestText, "fetched task history") - - listedTasks, err := client.ListTasks(ctx, &a2a.ListTasksRequest{ContextID: completedTask.ContextID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(listedTasks.Tasks).NotTo(gomega.BeEmpty()) - gomega.Expect(listedTasks.Tasks).To(gomega.ContainElement(gomega.HaveField("ID", completedTask.ID))) - - pendingTask, err := goClientSendTask(ctx, client, pendingRequestText, true) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(pendingTask.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) - - pendingText, err := taskStatusText(pendingTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(pendingText).To(gomega.Equal(expectedServerText(serverPrefix, pendingRequestText))) - - canceledTask, err := client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: pendingTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(canceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) - - canceledText, err := taskStatusText(canceledTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(canceledText).To(gomega.Equal(expectedCancelText(serverPrefix))) - - fetchedCanceledTask, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: pendingTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(fetchedCanceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) - - fetchedCanceledText, err := taskStatusText(fetchedCanceledTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(fetchedCanceledText).To(gomega.Equal(expectedCancelText(serverPrefix))) - - _, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: a2a.NewTaskID()}) - assertTaskNotFoundError(err, "missing task should fail") - - _, err = client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: completedTask.ID}) - assertTaskNotCancelableError(err, "canceling a terminal task should fail") -} - -func goClientWaitForTaskState(ctx context.Context, client *a2aclient.Client, taskID a2a.TaskID, expectedState a2a.TaskState) *a2a.Task { - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - task, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: taskID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - if task.Status.State == expectedState { - return task - } - time.Sleep(50 * time.Millisecond) - } - - ginkgo.Fail(fmt.Sprintf("timed out waiting for task %s to reach state %s", taskID, expectedState)) - return nil -} - -func assertDataTypesTask(task *a2a.Task, kind string) { - gomega.Expect(task.Artifacts).To(gomega.HaveLen(1), kind) - artifact := task.Artifacts[0] - gomega.Expect(artifact.Parts).To(gomega.HaveLen(3), kind) - gomega.Expect(artifact.Parts[0].Text()).To(gomega.Equal("structured summary"), kind) - dataPart, ok := artifact.Parts[1].Data().(map[string]any) - gomega.Expect(ok).To(gomega.BeTrue(), kind) - gomega.Expect(dataPart).To(gomega.HaveKeyWithValue("kind", "report"), kind) - gomega.Expect(dataPart).To(gomega.HaveKeyWithValue("items", float64(2)), kind) - gomega.Expect(string(artifact.Parts[2].URL())).To(gomega.Equal("https://example.invalid/diagram.svg"), kind) - gomega.Expect(artifact.Parts[2].MediaType).To(gomega.Equal("image/svg+xml"), kind) -} - -func assertExtendedCardMetadata(card *a2a.AgentCard, kind string) { - gomega.Expect(card).NotTo(gomega.BeNil(), kind) - gomega.Expect(card.Capabilities.ExtendedAgentCard).To(gomega.BeTrue(), kind) - gomega.Expect(card.Description).To(gomega.ContainSubstring("(extended)"), kind) - - if len(card.SecuritySchemes) > 0 { - scheme, ok := card.SecuritySchemes[extendedCardSchemeID] - gomega.Expect(ok).To(gomega.BeTrue(), kind) - httpScheme, ok := scheme.(a2a.HTTPAuthSecurityScheme) - gomega.Expect(ok).To(gomega.BeTrue(), kind) - gomega.Expect(httpScheme.Scheme).To(gomega.Equal("Bearer"), kind) - } - - for _, expectedSkill := range expectedSkillIDs { - gomega.Expect(card.Skills).To(gomega.ContainElement(gomega.HaveField("ID", expectedSkill)), kind) - } -} - -func goClientAssertScenarioParity(ctx context.Context, client *a2aclient.Client, serverPrefix string) { - messageOnly, err := goClientSendMessage(ctx, client, messageOnlyRequestText) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - messageText, err := firstMessageText(messageOnly) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(messageText).To(gomega.Equal(fmt.Sprintf("%s server message-only response", serverPrefix))) - - failedTask, err := goClientSendTask(ctx, client, taskFailureRequestText, false) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(failedTask.Status.State).To(gomega.Equal(a2a.TaskStateFailed)) - failedText, err := taskStatusText(failedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(failedText).To(gomega.Equal(fmt.Sprintf("%s server failed task", serverPrefix))) - - inputRequiredTask, err := goClientSendTask(ctx, client, multiTurnStartRequestText, false) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(inputRequiredTask.Status.State).To(gomega.Equal(a2a.TaskStateInputRequired)) - inputRequiredText, err := taskStatusText(inputRequiredTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(inputRequiredText).To(gomega.Equal(fmt.Sprintf("%s server needs more input", serverPrefix))) - assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") - - continuedResult, err := goClientSendResult(ctx, client, multiTurnContinueRequestText, false, inputRequiredTask.ID, inputRequiredTask.ContextID) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - continuedTask, ok := continuedResult.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(continuedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - continuedText, err := taskStatusText(continuedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(continuedText).To(gomega.Equal(fmt.Sprintf("%s server multi-turn completed", serverPrefix))) - assertTaskHistoryPayloads(continuedTask, []string{multiTurnStartRequestText, multiTurnContinueRequestText}, "multi-turn continuation") - - streamingChunks := []string{} - sawStreamingStart := false - sawStreamingComplete := false - sawAppend := false - for event, err := range client.SendStreamingMessage(ctx, newInteropRequest(streamingRequestText, false)) { - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - switch value := event.(type) { - case *a2a.Task: - sawStreamingStart = true - gomega.Expect(value.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) - text, textErr := taskStatusText(value) - gomega.Expect(textErr).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming started", serverPrefix))) - case *a2a.TaskArtifactUpdateEvent: - text, textErr := firstArtifactText(value.Artifact) - gomega.Expect(textErr).NotTo(gomega.HaveOccurred()) - streamingChunks = append(streamingChunks, text) - if value.Append { - sawAppend = true - } - case *a2a.TaskStatusUpdateEvent: - sawStreamingComplete = true - gomega.Expect(value.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - text, textErr := firstMessageText(value.Status.Message) - gomega.Expect(textErr).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming complete", serverPrefix))) - default: - ginkgo.Fail(fmt.Sprintf("unexpected streaming scenario event type %T", event)) - } - } - gomega.Expect(sawStreamingStart).To(gomega.BeTrue()) - gomega.Expect(sawStreamingComplete).To(gomega.BeTrue()) - gomega.Expect(sawAppend).To(gomega.BeTrue()) - gomega.Expect(streamingChunks).To(gomega.Equal([]string{"streaming chunk 1", "streaming chunk 2"})) - - longRunningTask, err := goClientSendTask(ctx, client, longRunningRequestText, true) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var longRunningCompleted *a2a.Task - switch longRunningTask.Status.State { - case a2a.TaskStateWorking: - longRunningText, textErr := taskStatusText(longRunningTask) - gomega.Expect(textErr).NotTo(gomega.HaveOccurred()) - gomega.Expect(longRunningText).To(gomega.Equal(fmt.Sprintf("%s server long-running started", serverPrefix))) - longRunningCompleted = goClientWaitForTaskState(ctx, client, longRunningTask.ID, a2a.TaskStateCompleted) - case a2a.TaskStateCompleted: - longRunningCompleted = longRunningTask - default: - ginkgo.Fail(fmt.Sprintf("unexpected long-running task state: got %s, want WORKING or COMPLETED", longRunningTask.Status.State)) - } - longRunningCompletedText, err := taskStatusText(longRunningCompleted) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(longRunningCompletedText).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", serverPrefix))) - - dataTypesTask, err := goClientSendTask(ctx, client, dataTypesRequestText, false) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(dataTypesTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - dataTypesText, err := taskStatusText(dataTypesTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(dataTypesText).To(gomega.Equal(fmt.Sprintf("%s server data-types ready", serverPrefix))) - assertDataTypesTask(dataTypesTask, "data-types") - - extendedCard, err := client.GetExtendedAgentCard(ctx, &a2a.GetExtendedAgentCardRequest{}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - assertExtendedCardMetadata(extendedCard, "extended-card") -} diff --git a/integrations/agntcy-a2a/tests/native_go_launchers_test.go b/integrations/agntcy-a2a/tests/native_go_launchers_test.go index f570a7d2..dd872e65 100644 --- a/integrations/agntcy-a2a/tests/native_go_launchers_test.go +++ b/integrations/agntcy-a2a/tests/native_go_launchers_test.go @@ -51,3 +51,23 @@ func buildGoFixtureBinaryOnly() (fixtureBinaries, error) { func startGoFixture(binaries fixtureBinaries, port int, protocol transportProtocol) (*fixtureProcess, string, error) { return startNativeFixture(fmt.Sprintf("go-%s-server", protocol), binaries.goServer, port, protocol) } + +// newGoServer returns a fixtureServer that starts a Go server for each of the given protocols. +func newGoServer(getBinaries func() fixtureBinaries, expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { + s := &fixtureServer{ + serverPrefix: "go", + expectPushSupported: expectPushSupported, + runtime: newInteropSuiteRuntime(), + } + for _, p := range protocols { + p := p + s.fixtures = append(s.fixtures, interopSuiteFixtureSpec{ + label: "go", + protocol: p, + start: func() (*fixtureProcess, string, error) { + return startGoFixture(getBinaries(), findFreePort(), p) + }, + }) + } + return s +} From a0b92dc2c836d4cbfce24847e786c5fc6c107c6b Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 10:04:16 +0100 Subject: [PATCH 02/18] refactor(a2a): replace 10 suite wrapper files with DescribeTableSubtree Replace the 10 per-SDK-pair Describe/When/Context wrapper files with a single DescribeTableSubtree in interop_table_test.go. Each of the 41 (client SDK, server SDK, transport) combinations is now a single Entry with Ordered+ContinueOnFailure decorators and labels. Asset builds are cached package-level via sync.Once so each SDK is compiled at most once per test run regardless of how many entries share it. An AfterSuite handler cleans up temp dirs. The registerBehaviors function is unchanged and called from within each entry's subtree body. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_go_dotnet_test.go | 111 -------- .../tests/interop_python_dotnet_test.go | 108 -------- .../tests/interop_python_go_test.go | 120 --------- .../tests/interop_rust_dotnet_test.go | 102 -------- .../agntcy-a2a/tests/interop_rust_go_test.go | 67 ----- .../tests/interop_rust_python_test.go | 120 --------- .../tests/interop_self_dotnet_test.go | 42 --- .../agntcy-a2a/tests/interop_self_go_test.go | 45 ---- .../tests/interop_self_python_test.go | 43 ---- .../tests/interop_self_rust_test.go | 45 ---- .../agntcy-a2a/tests/interop_table_test.go | 243 ++++++++++++++++++ 11 files changed, 243 insertions(+), 803 deletions(-) delete mode 100644 integrations/agntcy-a2a/tests/interop_go_dotnet_test.go delete mode 100644 integrations/agntcy-a2a/tests/interop_python_dotnet_test.go delete mode 100644 integrations/agntcy-a2a/tests/interop_python_go_test.go delete mode 100644 integrations/agntcy-a2a/tests/interop_rust_dotnet_test.go delete mode 100644 integrations/agntcy-a2a/tests/interop_rust_go_test.go delete mode 100644 integrations/agntcy-a2a/tests/interop_rust_python_test.go delete mode 100644 integrations/agntcy-a2a/tests/interop_self_dotnet_test.go delete mode 100644 integrations/agntcy-a2a/tests/interop_self_go_test.go delete mode 100644 integrations/agntcy-a2a/tests/interop_self_python_test.go delete mode 100644 integrations/agntcy-a2a/tests/interop_self_rust_test.go create mode 100644 integrations/agntcy-a2a/tests/interop_table_test.go diff --git a/integrations/agntcy-a2a/tests/interop_go_dotnet_test.go b/integrations/agntcy-a2a/tests/interop_go_dotnet_test.go deleted file mode 100644 index ab99ca9d..00000000 --- a/integrations/agntcy-a2a/tests/interop_go_dotnet_test.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -// This file is the Go/.NET suite wrapper. Tests cover all four client/server -// pairings of Go and .NET over the transports both SDKs support (JSON-RPC and REST). - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe("Go+.NET", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-go-dotnet"), func() { - var ( - goAssets fixtureBinaries - dotNetAssets dotNetFixtureBinaries - ) - - ginkgo.BeforeAll(func() { - var err error - goAssets, err = buildGoFixtureBinaryOnly() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - dotNetAssets, err = buildDotNetFixtureBinaryOnly() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { - removeTempDir(goAssets.tempDir) - removeTempDir(dotNetAssets.tempDir) - }) - - ginkgo.When("a Go client calls a Go server", ginkgo.Ordered, func() { - server := newGoServer(func() fixtureBinaries { return goAssets }, true, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - goClient := func(ctx context.Context, url string) (probeClient, error) { - return newGoProbeClient(ctx, url) - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-go"), func() { - registerBehaviors(goClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-go"), func() { - registerBehaviors(goClient, server.targetFor(transportREST)) - }) - }) - - ginkgo.When("a Go client calls a .NET server", ginkgo.Ordered, func() { - server := newDotNetServer(func() dotNetFixtureBinaries { return dotNetAssets }, false, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - goClient := func(ctx context.Context, url string) (probeClient, error) { - return newGoProbeClient(ctx, url) - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-dotnet"), func() { - registerBehaviors(goClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-dotnet"), func() { - registerBehaviors(goClient, server.targetFor(transportREST)) - }) - }) - - ginkgo.When("a .NET client calls a Go server", ginkgo.Ordered, func() { - server := newGoServer(func() fixtureBinaries { return goAssets }, true, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - dotNetClient := func(ctx context.Context, url string) (probeClient, error) { - return newDotNetProbeClient(func() dotNetFixtureBinaries { return dotNetAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-go"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-go"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportREST)) - }) - }) - - ginkgo.When("a .NET client calls a .NET server", ginkgo.Ordered, func() { - server := newDotNetServer(func() dotNetFixtureBinaries { return dotNetAssets }, false, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - dotNetClient := func(ctx context.Context, url string) (probeClient, error) { - return newDotNetProbeClient(func() dotNetFixtureBinaries { return dotNetAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-dotnet"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-dotnet"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportREST)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_python_dotnet_test.go b/integrations/agntcy-a2a/tests/interop_python_dotnet_test.go deleted file mode 100644 index 9c7cb9a1..00000000 --- a/integrations/agntcy-a2a/tests/interop_python_dotnet_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -// This file is the Python/.NET suite wrapper. Tests cover all four client/server -// pairings of Python and .NET over the transports both SDKs support (JSON-RPC and REST). - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe("Python+.NET", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-python-dotnet"), func() { - var ( - pythonAssets pythonFixtureAssets - dotNetAssets dotNetFixtureBinaries - ) - - ginkgo.BeforeAll(func() { - var err error - pythonAssets, err = buildPythonFixtureAssets() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - dotNetAssets, err = buildDotNetFixtureBinaryOnly() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { removeTempDir(dotNetAssets.tempDir) }) - - ginkgo.When("a Python client calls a Python server", ginkgo.Ordered, func() { - server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - pythonClient := func(ctx context.Context, url string) (probeClient, error) { - return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportREST)) - }) - }) - - ginkgo.When("a Python client calls a .NET server", ginkgo.Ordered, func() { - server := newDotNetServer(func() dotNetFixtureBinaries { return dotNetAssets }, false, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - pythonClient := func(ctx context.Context, url string) (probeClient, error) { - return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-dotnet"), func() { - registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-dotnet"), func() { - registerBehaviors(pythonClient, server.targetFor(transportREST)) - }) - }) - - ginkgo.When("a .NET client calls a Python server", ginkgo.Ordered, func() { - server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - dotNetClient := func(ctx context.Context, url string) (probeClient, error) { - return newDotNetProbeClient(func() dotNetFixtureBinaries { return dotNetAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-python"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-python"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportREST)) - }) - }) - - ginkgo.When("a .NET client calls a .NET server", ginkgo.Ordered, func() { - server := newDotNetServer(func() dotNetFixtureBinaries { return dotNetAssets }, false, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - dotNetClient := func(ctx context.Context, url string) (probeClient, error) { - return newDotNetProbeClient(func() dotNetFixtureBinaries { return dotNetAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-dotnet"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-dotnet"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportREST)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_python_go_test.go b/integrations/agntcy-a2a/tests/interop_python_go_test.go deleted file mode 100644 index 045dcfab..00000000 --- a/integrations/agntcy-a2a/tests/interop_python_go_test.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -// This file is the Python/Go suite wrapper. Tests cover all four client/server -// pairings of Python and Go over JSON-RPC, REST, and gRPC. - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe("Python+Go", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-python-go"), func() { - var ( - goAssets fixtureBinaries - pythonAssets pythonFixtureAssets - ) - - ginkgo.BeforeAll(func() { - var err error - goAssets, err = buildGoFixtureBinaryOnly() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - pythonAssets, err = buildPythonFixtureAssets() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { removeTempDir(goAssets.tempDir) }) - - ginkgo.When("a Go client calls a Go server", ginkgo.Ordered, func() { - server := newGoServer(func() fixtureBinaries { return goAssets }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - goClient := func(ctx context.Context, url string) (probeClient, error) { - return newGoProbeClient(ctx, url) - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-go"), func() { - registerBehaviors(goClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-go"), func() { - registerBehaviors(goClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "go-go"), func() { - registerBehaviors(goClient, server.targetFor(transportGRPC)) - }) - }) - - ginkgo.When("a Go client calls a Python server", ginkgo.Ordered, func() { - server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - goClient := func(ctx context.Context, url string) (probeClient, error) { - return newGoProbeClient(ctx, url) - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-python"), func() { - registerBehaviors(goClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-python"), func() { - registerBehaviors(goClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "go-python"), func() { - registerBehaviors(goClient, server.targetFor(transportGRPC)) - }) - }) - - ginkgo.When("a Python client calls a Go server", ginkgo.Ordered, func() { - server := newGoServer(func() fixtureBinaries { return goAssets }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - pythonClient := func(ctx context.Context, url string) (probeClient, error) { - return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-go"), func() { - registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-go"), func() { - registerBehaviors(pythonClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-go"), func() { - registerBehaviors(pythonClient, server.targetFor(transportGRPC)) - }) - }) - - ginkgo.When("a Python client calls a Python server", ginkgo.Ordered, func() { - server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - pythonClient := func(ctx context.Context, url string) (probeClient, error) { - return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportGRPC)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_rust_dotnet_test.go b/integrations/agntcy-a2a/tests/interop_rust_dotnet_test.go deleted file mode 100644 index 2f14dbb6..00000000 --- a/integrations/agntcy-a2a/tests/interop_rust_dotnet_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -// This file is the Rust/.NET suite wrapper. Tests cover all four client/server -// pairings of Rust and .NET over the transports both SDKs support (JSON-RPC and REST). - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe("Rust+.NET", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-rust-dotnet"), func() { - var binaries rustDotNetFixtureBinaries - - ginkgo.BeforeAll(func() { - var err error - binaries, err = buildRustDotNetFixtureBinaries() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { removeTempDir(binaries.tempDir) }) - - ginkgo.When("a Rust client calls a Rust server", ginkgo.Ordered, func() { - server := newRustServer(func() fixtureBinaries { return binaries.rustAssets() }, true, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - rustClient := func(ctx context.Context, url string) (probeClient, error) { - return newRustProbeClient(func() fixtureBinaries { return binaries.rustAssets() }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-rust"), func() { - registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-rust"), func() { - registerBehaviors(rustClient, server.targetFor(transportREST)) - }) - }) - - ginkgo.When("a Rust client calls a .NET server", ginkgo.Ordered, func() { - server := newDotNetServer(func() dotNetFixtureBinaries { return binaries.dotNetAssets() }, false, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - rustClient := func(ctx context.Context, url string) (probeClient, error) { - return newRustProbeClient(func() fixtureBinaries { return binaries.rustAssets() }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-dotnet"), func() { - registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-dotnet"), func() { - registerBehaviors(rustClient, server.targetFor(transportREST)) - }) - }) - - ginkgo.When("a .NET client calls a Rust server", ginkgo.Ordered, func() { - server := newRustServer(func() fixtureBinaries { return binaries.rustAssets() }, true, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - dotNetClient := func(ctx context.Context, url string) (probeClient, error) { - return newDotNetProbeClient(func() dotNetFixtureBinaries { return binaries.dotNetAssets() }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-rust"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-rust"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportREST)) - }) - }) - - ginkgo.When("a .NET client calls a .NET server", ginkgo.Ordered, func() { - server := newDotNetServer(func() dotNetFixtureBinaries { return binaries.dotNetAssets() }, false, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - dotNetClient := func(ctx context.Context, url string) (probeClient, error) { - return newDotNetProbeClient(func() dotNetFixtureBinaries { return binaries.dotNetAssets() }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-dotnet"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-dotnet"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportREST)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_rust_go_test.go b/integrations/agntcy-a2a/tests/interop_rust_go_test.go deleted file mode 100644 index e1371e27..00000000 --- a/integrations/agntcy-a2a/tests/interop_rust_go_test.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe("Rust+Go", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-rust-go"), func() { - var binaries fixtureBinaries - - ginkgo.BeforeAll(func() { - var err error - binaries, err = buildFixtureBinaries() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { removeTempDir(binaries.tempDir) }) - - ginkgo.When("a Go client calls a Rust server", ginkgo.Ordered, func() { - server := newRustServer(func() fixtureBinaries { return binaries }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - goClient := func(ctx context.Context, url string) (probeClient, error) { - return newGoProbeClient(ctx, url) - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-rust"), func() { - registerBehaviors(goClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-rust"), func() { - registerBehaviors(goClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "go-rust"), func() { - registerBehaviors(goClient, server.targetFor(transportGRPC)) - }) - }) - - ginkgo.When("a Rust client calls a Go server", ginkgo.Ordered, func() { - server := newGoServer(func() fixtureBinaries { return binaries }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - rustClient := func(ctx context.Context, url string) (probeClient, error) { - return newRustProbeClient(func() fixtureBinaries { return binaries }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-go"), func() { - registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-go"), func() { - registerBehaviors(rustClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "rust-go"), func() { - registerBehaviors(rustClient, server.targetFor(transportGRPC)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_rust_python_test.go b/integrations/agntcy-a2a/tests/interop_rust_python_test.go deleted file mode 100644 index 9b24485b..00000000 --- a/integrations/agntcy-a2a/tests/interop_rust_python_test.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -// This file is the Rust/Python suite wrapper. Tests cover all four client/server -// pairings of Rust and Python over JSON-RPC, REST, and gRPC. - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe("Rust+Python", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-rust-python"), func() { - var ( - rustAssets fixtureBinaries - pythonAssets pythonFixtureAssets - ) - - ginkgo.BeforeAll(func() { - var err error - rustAssets, err = buildRustFixtureBinaryOnly() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - pythonAssets, err = buildPythonFixtureAssets() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { removeTempDir(rustAssets.tempDir) }) - - ginkgo.When("a Rust client calls a Rust server", ginkgo.Ordered, func() { - server := newRustServer(func() fixtureBinaries { return rustAssets }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - rustClient := func(ctx context.Context, url string) (probeClient, error) { - return newRustProbeClient(func() fixtureBinaries { return rustAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-rust"), func() { - registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-rust"), func() { - registerBehaviors(rustClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "rust-rust"), func() { - registerBehaviors(rustClient, server.targetFor(transportGRPC)) - }) - }) - - ginkgo.When("a Rust client calls a Python server", ginkgo.Ordered, func() { - server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - rustClient := func(ctx context.Context, url string) (probeClient, error) { - return newRustProbeClient(func() fixtureBinaries { return rustAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-python"), func() { - registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-python"), func() { - registerBehaviors(rustClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "rust-python"), func() { - registerBehaviors(rustClient, server.targetFor(transportGRPC)) - }) - }) - - ginkgo.When("a Python client calls a Rust server", ginkgo.Ordered, func() { - server := newRustServer(func() fixtureBinaries { return rustAssets }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - pythonClient := func(ctx context.Context, url string) (probeClient, error) { - return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-rust"), func() { - registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-rust"), func() { - registerBehaviors(pythonClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-rust"), func() { - registerBehaviors(pythonClient, server.targetFor(transportGRPC)) - }) - }) - - ginkgo.When("a Python client calls a Python server", ginkgo.Ordered, func() { - server := newPythonServer(func() pythonFixtureAssets { return pythonAssets }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - pythonClient := func(ctx context.Context, url string) (probeClient, error) { - return newPythonProbeClient(func() pythonFixtureAssets { return pythonAssets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportGRPC)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_self_dotnet_test.go b/integrations/agntcy-a2a/tests/interop_self_dotnet_test.go deleted file mode 100644 index b74d37bc..00000000 --- a/integrations/agntcy-a2a/tests/interop_self_dotnet_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe(".NET", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-self-dotnet"), func() { - var assets dotNetFixtureBinaries - - ginkgo.BeforeAll(func() { - var err error - assets, err = buildDotNetFixtureBinaryOnly() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { removeTempDir(assets.tempDir) }) - - ginkgo.When("a .NET client calls a .NET server", ginkgo.Ordered, func() { - server := newDotNetServer(func() dotNetFixtureBinaries { return assets }, false, - transportJSONRPC, transportREST) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - dotNetClient := func(ctx context.Context, url string) (probeClient, error) { - return newDotNetProbeClient(func() dotNetFixtureBinaries { return assets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "dotnet-dotnet"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "dotnet-dotnet"), func() { - registerBehaviors(dotNetClient, server.targetFor(transportREST)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_self_go_test.go b/integrations/agntcy-a2a/tests/interop_self_go_test.go deleted file mode 100644 index f73f21ab..00000000 --- a/integrations/agntcy-a2a/tests/interop_self_go_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe("Go", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-self-go"), func() { - var binaries fixtureBinaries - - ginkgo.BeforeAll(func() { - var err error - binaries, err = buildGoFixtureBinaryOnly() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { removeTempDir(binaries.tempDir) }) - - ginkgo.When("a Go client calls a Go server", ginkgo.Ordered, func() { - server := newGoServer(func() fixtureBinaries { return binaries }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - goClient := func(ctx context.Context, url string) (probeClient, error) { - return newGoProbeClient(ctx, url) - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "go-go"), func() { - registerBehaviors(goClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "go-go"), func() { - registerBehaviors(goClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "go-go"), func() { - registerBehaviors(goClient, server.targetFor(transportGRPC)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_self_python_test.go b/integrations/agntcy-a2a/tests/interop_self_python_test.go deleted file mode 100644 index 921fd7da..00000000 --- a/integrations/agntcy-a2a/tests/interop_self_python_test.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe("Python", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-self-python"), func() { - var assets pythonFixtureAssets - - ginkgo.BeforeAll(func() { - var err error - assets, err = buildPythonFixtureAssets() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.When("a Python client calls a Python server", ginkgo.Ordered, func() { - server := newPythonServer(func() pythonFixtureAssets { return assets }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - pythonClient := func(ctx context.Context, url string) (probeClient, error) { - return newPythonProbeClient(func() pythonFixtureAssets { return assets }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "python-python"), func() { - registerBehaviors(pythonClient, server.targetFor(transportGRPC)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_self_rust_test.go b/integrations/agntcy-a2a/tests/interop_self_rust_test.go deleted file mode 100644 index 8e93c9ac..00000000 --- a/integrations/agntcy-a2a/tests/interop_self_rust_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -import ( - "context" - - ginkgo "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" -) - -var _ = ginkgo.Describe("Rust", ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("suite-self-rust"), func() { - var binaries fixtureBinaries - - ginkgo.BeforeAll(func() { - var err error - binaries, err = buildRustFixtureBinaryOnly() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { removeTempDir(binaries.tempDir) }) - - ginkgo.When("a Rust client calls a Rust server", ginkgo.Ordered, func() { - server := newRustServer(func() fixtureBinaries { return binaries }, true, - transportJSONRPC, transportREST, transportGRPC) - - ginkgo.BeforeAll(func() { gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { server.stop() }) - - rustClient := func(ctx context.Context, url string) (probeClient, error) { - return newRustProbeClient(func() fixtureBinaries { return binaries }, url), nil - } - - ginkgo.Context("over JSON-RPC", ginkgo.Ordered, ginkgo.Label("jsonrpc", "rust-rust"), func() { - registerBehaviors(rustClient, server.targetFor(transportJSONRPC)) - }) - ginkgo.Context("over REST", ginkgo.Ordered, ginkgo.Label("rest", "rust-rust"), func() { - registerBehaviors(rustClient, server.targetFor(transportREST)) - }) - ginkgo.Context("over gRPC", ginkgo.Ordered, ginkgo.Label("grpc", "rust-rust"), func() { - registerBehaviors(rustClient, server.targetFor(transportGRPC)) - }) - }) -}) diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go new file mode 100644 index 00000000..8797d3e7 --- /dev/null +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -0,0 +1,243 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tests + +// This file replaces the 10 per-SDK-pair suite wrapper files with a single +// DescribeTable that has one Entry per (client SDK, server SDK, transport) combination. +// +// Each SDK's fixture assets are built at most once per test run via package-level +// sync.Once caches. Each Entry is an independent ordered Context: its BeforeAll +// starts a dedicated server instance and its AfterAll stops it. The shared +// registerBehaviors call registers the full When/Context/It spec tree. + +import ( + "context" + "sync" + + ginkgo "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +// ── per-SDK asset caches ────────────────────────────────────────────────────── + +var ( + onceGo sync.Once + cachedGo fixtureBinaries + errGo error + + onceRust sync.Once + cachedRust fixtureBinaries + errRust error + + onceDotNet sync.Once + cachedDotNet dotNetFixtureBinaries + errDotNet error + + oncePython sync.Once + cachedPython pythonFixtureAssets + errPython error +) + +func getGoBinaries() fixtureBinaries { + onceGo.Do(func() { cachedGo, errGo = buildGoFixtureBinaryOnly() }) + gomega.Expect(errGo).NotTo(gomega.HaveOccurred(), "build Go fixtures") + return cachedGo +} + +func getRustBinaries() fixtureBinaries { + onceRust.Do(func() { cachedRust, errRust = buildRustFixtureBinaryOnly() }) + gomega.Expect(errRust).NotTo(gomega.HaveOccurred(), "build Rust fixtures") + return cachedRust +} + +func getDotNetBinaries() dotNetFixtureBinaries { + onceDotNet.Do(func() { cachedDotNet, errDotNet = buildDotNetFixtureBinaryOnly() }) + gomega.Expect(errDotNet).NotTo(gomega.HaveOccurred(), "build .NET fixtures") + return cachedDotNet +} + +func getPythonAssets() pythonFixtureAssets { + oncePython.Do(func() { cachedPython, errPython = buildPythonFixtureAssets() }) + gomega.Expect(errPython).NotTo(gomega.HaveOccurred(), "build Python fixtures") + return cachedPython +} + +var _ = ginkgo.AfterSuite(func() { + removeTempDir(cachedGo.tempDir) + removeTempDir(cachedRust.tempDir) + removeTempDir(cachedDotNet.tempDir) +}) + +// ── client factories ────────────────────────────────────────────────────────── + +var ( + goClientFn newClientFn = func(ctx context.Context, url string) (probeClient, error) { + return newGoProbeClient(ctx, url) + } + rustClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { + return newRustProbeClient(getRustBinaries, url), nil + } + dotNetClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { + return newDotNetProbeClient(getDotNetBinaries, url), nil + } + pythonClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { + return newPythonProbeClient(getPythonAssets, url), nil + } +) + +// ── interoperability table ──────────────────────────────────────────────────── + +var _ = ginkgo.DescribeTableSubtree( + "A2A interoperability", + func(newClient newClientFn, server *fixtureServer, protocol transportProtocol) { + ginkgo.BeforeAll(func() { + gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) + }) + ginkgo.AfterAll(func() { server.stop() }) + registerBehaviors(newClient, server.targetFor(protocol)) + }, + + // ── Go client ───────────────────────────────────────────────────────────── + ginkgo.Entry("go → go [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-go", "jsonrpc"), + goClientFn, newGoServer(getGoBinaries, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("go → go [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-go", "rest"), + goClientFn, newGoServer(getGoBinaries, true, transportREST), transportREST), + ginkgo.Entry("go → go [grpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-go", "grpc"), + goClientFn, newGoServer(getGoBinaries, true, transportGRPC), transportGRPC), + + ginkgo.Entry("go → rust [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-rust", "jsonrpc"), + goClientFn, newRustServer(getRustBinaries, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("go → rust [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-rust", "rest"), + goClientFn, newRustServer(getRustBinaries, true, transportREST), transportREST), + ginkgo.Entry("go → rust [grpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-rust", "grpc"), + goClientFn, newRustServer(getRustBinaries, true, transportGRPC), transportGRPC), + + ginkgo.Entry("go → python [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-python", "jsonrpc"), + goClientFn, newPythonServer(getPythonAssets, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("go → python [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-python", "rest"), + goClientFn, newPythonServer(getPythonAssets, true, transportREST), transportREST), + ginkgo.Entry("go → python [grpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-python", "grpc"), + goClientFn, newPythonServer(getPythonAssets, true, transportGRPC), transportGRPC), + + ginkgo.Entry("go → dotnet [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-dotnet", "jsonrpc"), + goClientFn, newDotNetServer(getDotNetBinaries, false, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("go → dotnet [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-dotnet", "rest"), + goClientFn, newDotNetServer(getDotNetBinaries, false, transportREST), transportREST), + + // ── Rust client ─────────────────────────────────────────────────────────── + ginkgo.Entry("rust → go [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-go", "jsonrpc"), + rustClientFn, newGoServer(getGoBinaries, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("rust → go [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-go", "rest"), + rustClientFn, newGoServer(getGoBinaries, true, transportREST), transportREST), + ginkgo.Entry("rust → go [grpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-go", "grpc"), + rustClientFn, newGoServer(getGoBinaries, true, transportGRPC), transportGRPC), + + ginkgo.Entry("rust → rust [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-rust", "jsonrpc"), + rustClientFn, newRustServer(getRustBinaries, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("rust → rust [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-rust", "rest"), + rustClientFn, newRustServer(getRustBinaries, true, transportREST), transportREST), + ginkgo.Entry("rust → rust [grpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-rust", "grpc"), + rustClientFn, newRustServer(getRustBinaries, true, transportGRPC), transportGRPC), + + ginkgo.Entry("rust → python [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-python", "jsonrpc"), + rustClientFn, newPythonServer(getPythonAssets, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("rust → python [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-python", "rest"), + rustClientFn, newPythonServer(getPythonAssets, true, transportREST), transportREST), + ginkgo.Entry("rust → python [grpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-python", "grpc"), + rustClientFn, newPythonServer(getPythonAssets, true, transportGRPC), transportGRPC), + + ginkgo.Entry("rust → dotnet [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-dotnet", "jsonrpc"), + rustClientFn, newDotNetServer(getDotNetBinaries, false, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("rust → dotnet [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-dotnet", "rest"), + rustClientFn, newDotNetServer(getDotNetBinaries, false, transportREST), transportREST), + + // ── Python client ───────────────────────────────────────────────────────── + ginkgo.Entry("python → go [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-go", "jsonrpc"), + pythonClientFn, newGoServer(getGoBinaries, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("python → go [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-go", "rest"), + pythonClientFn, newGoServer(getGoBinaries, true, transportREST), transportREST), + ginkgo.Entry("python → go [grpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-go", "grpc"), + pythonClientFn, newGoServer(getGoBinaries, true, transportGRPC), transportGRPC), + + ginkgo.Entry("python → rust [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-rust", "jsonrpc"), + pythonClientFn, newRustServer(getRustBinaries, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("python → rust [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-rust", "rest"), + pythonClientFn, newRustServer(getRustBinaries, true, transportREST), transportREST), + ginkgo.Entry("python → rust [grpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-rust", "grpc"), + pythonClientFn, newRustServer(getRustBinaries, true, transportGRPC), transportGRPC), + + ginkgo.Entry("python → python [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-python", "jsonrpc"), + pythonClientFn, newPythonServer(getPythonAssets, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("python → python [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-python", "rest"), + pythonClientFn, newPythonServer(getPythonAssets, true, transportREST), transportREST), + ginkgo.Entry("python → python [grpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-python", "grpc"), + pythonClientFn, newPythonServer(getPythonAssets, true, transportGRPC), transportGRPC), + + ginkgo.Entry("python → dotnet [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-dotnet", "jsonrpc"), + pythonClientFn, newDotNetServer(getDotNetBinaries, false, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("python → dotnet [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-dotnet", "rest"), + pythonClientFn, newDotNetServer(getDotNetBinaries, false, transportREST), transportREST), + + // ── .NET client ─────────────────────────────────────────────────────────── + ginkgo.Entry("dotnet → go [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-go", "jsonrpc"), + dotNetClientFn, newGoServer(getGoBinaries, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("dotnet → go [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-go", "rest"), + dotNetClientFn, newGoServer(getGoBinaries, true, transportREST), transportREST), + + ginkgo.Entry("dotnet → rust [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-rust", "jsonrpc"), + dotNetClientFn, newRustServer(getRustBinaries, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("dotnet → rust [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-rust", "rest"), + dotNetClientFn, newRustServer(getRustBinaries, true, transportREST), transportREST), + + ginkgo.Entry("dotnet → python [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-python", "jsonrpc"), + dotNetClientFn, newPythonServer(getPythonAssets, true, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("dotnet → python [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-python", "rest"), + dotNetClientFn, newPythonServer(getPythonAssets, true, transportREST), transportREST), + + ginkgo.Entry("dotnet → dotnet [jsonrpc]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-dotnet", "jsonrpc"), + dotNetClientFn, newDotNetServer(getDotNetBinaries, false, transportJSONRPC), transportJSONRPC), + ginkgo.Entry("dotnet → dotnet [rest]", + ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-dotnet", "rest"), + dotNetClientFn, newDotNetServer(getDotNetBinaries, false, transportREST), transportREST), +) From e607a01b7debd6e4a736e1237ed11c22a0fad510 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 10:16:00 +0100 Subject: [PATCH 03/18] =?UTF-8?q?refactor(a2a):=20nest=20interop=20table?= =?UTF-8?q?=20into=20client=E2=86=92server=E2=86=92transport=20layers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat 41-entry DescribeTableSubtree with three nested DescribeTableSubtrees: A2A interoperability (4 client entries) server (4 server entries) transport (2–3 transport entries, gRPC added dynamically) Server entries and transport entries are now defined once each instead of once per (client, server) or (client, server, transport) combination. The serverMaker type carries the per-SDK factory closure. ContinueOnFailure sits only on the outermost Ordered containers (client entries) and propagates to nested server and transport containers per Ginkgo semantics. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../agntcy-a2a/tests/interop_table_test.go | 241 +++++++----------- 1 file changed, 86 insertions(+), 155 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index 8797d3e7..2b57551b 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -3,13 +3,29 @@ package tests -// This file replaces the 10 per-SDK-pair suite wrapper files with a single -// DescribeTable that has one Entry per (client SDK, server SDK, transport) combination. +// Three nested DescribeTableSubtrees produce the full (client, server, transport) +// interop matrix with minimal repetition: +// +// A2A interoperability +// go (client) +// server go +// transport JSON-RPC → BeforeAll/registerBehaviors/AfterAll +// transport REST +// transport gRPC +// server rust +// ... +// server dotnet (JSON-RPC + REST only — no gRPC) +// ... +// rust (client) +// ... +// dotnet (client) (JSON-RPC + REST only — no gRPC for any server) +// +// Server entries and transport entries are defined once; the body functions close +// over the outer-level variables. gRPC transport entries are injected dynamically +// when both the client and server SDKs support it. // // Each SDK's fixture assets are built at most once per test run via package-level -// sync.Once caches. Each Entry is an independent ordered Context: its BeforeAll -// starts a dedicated server instance and its AfterAll stops it. The shared -// registerBehaviors call registers the full When/Context/It spec tree. +// sync.Once caches. An AfterSuite handler removes the temp directories. import ( "context" @@ -86,158 +102,73 @@ var ( } ) -// ── interoperability table ──────────────────────────────────────────────────── +// ── server factory type ─────────────────────────────────────────────────────── + +// serverMaker creates a fresh fixtureServer configured for a single transport +// protocol. Called at tree-building time; the actual process is started in BeforeAll. +type serverMaker func(protocol transportProtocol) *fixtureServer + +// ── interoperability matrix ─────────────────────────────────────────────────── var _ = ginkgo.DescribeTableSubtree( "A2A interoperability", - func(newClient newClientFn, server *fixtureServer, protocol transportProtocol) { - ginkgo.BeforeAll(func() { - gomega.Expect(server.start()).NotTo(gomega.HaveOccurred()) - }) - ginkgo.AfterAll(func() { server.stop() }) - registerBehaviors(newClient, server.targetFor(protocol)) + func(clientSDK string, newClient newClientFn, clientGRPC bool) { + ginkgo.DescribeTableSubtree("server", + func(serverSDK string, makeServer serverMaker, serverGRPC bool) { + label := clientSDK + "-" + serverSDK + + // Build transport entries; gRPC is only added when both sides support it. + transportBodyFn := func(protocol transportProtocol) { + srv := makeServer(protocol) + ginkgo.BeforeAll(func() { + gomega.Expect(srv.start()).NotTo(gomega.HaveOccurred()) + }) + ginkgo.AfterAll(func() { srv.stop() }) + registerBehaviors(newClient, srv.targetFor(protocol)) + } + + // ContinueOnFailure is not set here: transport entries are nested + // inside client entries (which are the outermost Ordered containers) + // and inherit ContinueOnFailure behaviour from them. + args := []interface{}{ + transportBodyFn, + ginkgo.Entry("JSON-RPC", + ginkgo.Ordered, ginkgo.Label(label, "jsonrpc"), + transportJSONRPC), + ginkgo.Entry("REST", + ginkgo.Ordered, ginkgo.Label(label, "rest"), + transportREST), + } + if clientGRPC && serverGRPC { + args = append(args, ginkgo.Entry("gRPC", + ginkgo.Ordered, ginkgo.Label(label, "grpc"), + transportGRPC)) + } + ginkgo.DescribeTableSubtree("transport", args...) + }, + // ── server entries ──────────────────────────────────────────────── + // ContinueOnFailure is inherited from the client entries (outermost Ordered). + ginkgo.Entry("go", ginkgo.Ordered, + "go", serverMaker(func(p transportProtocol) *fixtureServer { + return newGoServer(getGoBinaries, true, p) + }), true), + ginkgo.Entry("rust", ginkgo.Ordered, + "rust", serverMaker(func(p transportProtocol) *fixtureServer { + return newRustServer(getRustBinaries, true, p) + }), true), + ginkgo.Entry("python", ginkgo.Ordered, + "python", serverMaker(func(p transportProtocol) *fixtureServer { + return newPythonServer(getPythonAssets, true, p) + }), true), + ginkgo.Entry("dotnet", ginkgo.Ordered, + "dotnet", serverMaker(func(p transportProtocol) *fixtureServer { + return newDotNetServer(getDotNetBinaries, false, p) + }), false), + ) }, - - // ── Go client ───────────────────────────────────────────────────────────── - ginkgo.Entry("go → go [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-go", "jsonrpc"), - goClientFn, newGoServer(getGoBinaries, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("go → go [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-go", "rest"), - goClientFn, newGoServer(getGoBinaries, true, transportREST), transportREST), - ginkgo.Entry("go → go [grpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-go", "grpc"), - goClientFn, newGoServer(getGoBinaries, true, transportGRPC), transportGRPC), - - ginkgo.Entry("go → rust [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-rust", "jsonrpc"), - goClientFn, newRustServer(getRustBinaries, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("go → rust [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-rust", "rest"), - goClientFn, newRustServer(getRustBinaries, true, transportREST), transportREST), - ginkgo.Entry("go → rust [grpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-rust", "grpc"), - goClientFn, newRustServer(getRustBinaries, true, transportGRPC), transportGRPC), - - ginkgo.Entry("go → python [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-python", "jsonrpc"), - goClientFn, newPythonServer(getPythonAssets, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("go → python [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-python", "rest"), - goClientFn, newPythonServer(getPythonAssets, true, transportREST), transportREST), - ginkgo.Entry("go → python [grpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-python", "grpc"), - goClientFn, newPythonServer(getPythonAssets, true, transportGRPC), transportGRPC), - - ginkgo.Entry("go → dotnet [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-dotnet", "jsonrpc"), - goClientFn, newDotNetServer(getDotNetBinaries, false, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("go → dotnet [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("go-dotnet", "rest"), - goClientFn, newDotNetServer(getDotNetBinaries, false, transportREST), transportREST), - - // ── Rust client ─────────────────────────────────────────────────────────── - ginkgo.Entry("rust → go [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-go", "jsonrpc"), - rustClientFn, newGoServer(getGoBinaries, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("rust → go [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-go", "rest"), - rustClientFn, newGoServer(getGoBinaries, true, transportREST), transportREST), - ginkgo.Entry("rust → go [grpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-go", "grpc"), - rustClientFn, newGoServer(getGoBinaries, true, transportGRPC), transportGRPC), - - ginkgo.Entry("rust → rust [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-rust", "jsonrpc"), - rustClientFn, newRustServer(getRustBinaries, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("rust → rust [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-rust", "rest"), - rustClientFn, newRustServer(getRustBinaries, true, transportREST), transportREST), - ginkgo.Entry("rust → rust [grpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-rust", "grpc"), - rustClientFn, newRustServer(getRustBinaries, true, transportGRPC), transportGRPC), - - ginkgo.Entry("rust → python [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-python", "jsonrpc"), - rustClientFn, newPythonServer(getPythonAssets, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("rust → python [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-python", "rest"), - rustClientFn, newPythonServer(getPythonAssets, true, transportREST), transportREST), - ginkgo.Entry("rust → python [grpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-python", "grpc"), - rustClientFn, newPythonServer(getPythonAssets, true, transportGRPC), transportGRPC), - - ginkgo.Entry("rust → dotnet [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-dotnet", "jsonrpc"), - rustClientFn, newDotNetServer(getDotNetBinaries, false, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("rust → dotnet [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("rust-dotnet", "rest"), - rustClientFn, newDotNetServer(getDotNetBinaries, false, transportREST), transportREST), - - // ── Python client ───────────────────────────────────────────────────────── - ginkgo.Entry("python → go [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-go", "jsonrpc"), - pythonClientFn, newGoServer(getGoBinaries, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("python → go [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-go", "rest"), - pythonClientFn, newGoServer(getGoBinaries, true, transportREST), transportREST), - ginkgo.Entry("python → go [grpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-go", "grpc"), - pythonClientFn, newGoServer(getGoBinaries, true, transportGRPC), transportGRPC), - - ginkgo.Entry("python → rust [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-rust", "jsonrpc"), - pythonClientFn, newRustServer(getRustBinaries, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("python → rust [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-rust", "rest"), - pythonClientFn, newRustServer(getRustBinaries, true, transportREST), transportREST), - ginkgo.Entry("python → rust [grpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-rust", "grpc"), - pythonClientFn, newRustServer(getRustBinaries, true, transportGRPC), transportGRPC), - - ginkgo.Entry("python → python [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-python", "jsonrpc"), - pythonClientFn, newPythonServer(getPythonAssets, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("python → python [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-python", "rest"), - pythonClientFn, newPythonServer(getPythonAssets, true, transportREST), transportREST), - ginkgo.Entry("python → python [grpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-python", "grpc"), - pythonClientFn, newPythonServer(getPythonAssets, true, transportGRPC), transportGRPC), - - ginkgo.Entry("python → dotnet [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-dotnet", "jsonrpc"), - pythonClientFn, newDotNetServer(getDotNetBinaries, false, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("python → dotnet [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("python-dotnet", "rest"), - pythonClientFn, newDotNetServer(getDotNetBinaries, false, transportREST), transportREST), - - // ── .NET client ─────────────────────────────────────────────────────────── - ginkgo.Entry("dotnet → go [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-go", "jsonrpc"), - dotNetClientFn, newGoServer(getGoBinaries, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("dotnet → go [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-go", "rest"), - dotNetClientFn, newGoServer(getGoBinaries, true, transportREST), transportREST), - - ginkgo.Entry("dotnet → rust [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-rust", "jsonrpc"), - dotNetClientFn, newRustServer(getRustBinaries, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("dotnet → rust [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-rust", "rest"), - dotNetClientFn, newRustServer(getRustBinaries, true, transportREST), transportREST), - - ginkgo.Entry("dotnet → python [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-python", "jsonrpc"), - dotNetClientFn, newPythonServer(getPythonAssets, true, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("dotnet → python [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-python", "rest"), - dotNetClientFn, newPythonServer(getPythonAssets, true, transportREST), transportREST), - - ginkgo.Entry("dotnet → dotnet [jsonrpc]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-dotnet", "jsonrpc"), - dotNetClientFn, newDotNetServer(getDotNetBinaries, false, transportJSONRPC), transportJSONRPC), - ginkgo.Entry("dotnet → dotnet [rest]", - ginkgo.Ordered, ginkgo.ContinueOnFailure, ginkgo.Label("dotnet-dotnet", "rest"), - dotNetClientFn, newDotNetServer(getDotNetBinaries, false, transportREST), transportREST), + // ── client entries ──────────────────────────────────────────────────────── + ginkgo.Entry("go", ginkgo.Ordered, ginkgo.ContinueOnFailure, "go", goClientFn, true), + ginkgo.Entry("rust", ginkgo.Ordered, ginkgo.ContinueOnFailure, "rust", rustClientFn, true), + ginkgo.Entry("python", ginkgo.Ordered, ginkgo.ContinueOnFailure, "python", pythonClientFn, true), + ginkgo.Entry("dotnet", ginkgo.Ordered, ginkgo.ContinueOnFailure, "dotnet", dotNetClientFn, false), ) From 80b84d58208486a2de9e4392c69cec52500cb8d6 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 10:19:10 +0100 Subject: [PATCH 04/18] refactor(a2a): dot-import ginkgo to drop ginkgo. prefix in test files Switch from named alias import (ginkgo "github.com/onsi/ginkgo/v2") to dot import (. "github.com/onsi/ginkgo/v2") in interop_behaviors_test.go, interop_table_test.go, and suite_test.go. This is the idiomatic Ginkgo style and removes the ginkgo. prefix from every Describe/When/It/Entry/ BeforeAll/AfterAll call, making the spec tree easier to read. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_behaviors_test.go | 124 +++++++++--------- .../agntcy-a2a/tests/interop_table_test.go | 42 +++--- integrations/agntcy-a2a/tests/suite_test.go | 6 +- 3 files changed, 86 insertions(+), 86 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_behaviors_test.go b/integrations/agntcy-a2a/tests/interop_behaviors_test.go index a73d8acc..ee3cabd5 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -19,7 +19,7 @@ import ( "time" "github.com/a2aproject/a2a-go/v2/a2a" - ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -233,7 +233,7 @@ func waitForProbeTaskState(ctx context.Context, client probeClient, taskID a2a.T func registerBehaviors(newClient newClientFn, target func() interopTarget) { var client probeClient - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { t := target() var err error client, err = newClient(ctx, t.baseURL) @@ -242,11 +242,11 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { // ── send message (unary) ───────────────────────────────────────────────── - ginkgo.When("the client sends a message", ginkgo.Ordered, func() { - ginkgo.Context("with return_immediately=false", ginkgo.Ordered, func() { + When("the client sends a message", Ordered, func() { + Context("with return_immediately=false", Ordered, func() { var task *a2a.Task - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) var ok bool @@ -254,15 +254,15 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(ok).To(gomega.BeTrue(), "expected Task result from SendMessage") }) - ginkgo.It("receives a completed Task", func() { + It("receives a completed Task", func() { gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) }) - ginkgo.It("includes the request payload in task history", func() { + It("includes the request payload in task history", func() { assertTaskHistoryPayload(task, requestText, "task history") }) - ginkgo.It("includes the response text from the server", func() { + It("includes the response text from the server", func() { text, err := taskStatusText(task) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(expectedServerText(target().serverPrefix, requestText))) @@ -272,16 +272,16 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { // ── streaming send message ─────────────────────────────────────────────── - ginkgo.When("the client streams a message", ginkgo.Ordered, func() { + When("the client streams a message", Ordered, func() { var events []a2a.Event - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { var err error events, err = client.SendStreamingMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.It("receives at least one event with the server response text", func() { + It("receives at least one event with the server response text", func() { gomega.Expect(events).NotTo(gomega.BeEmpty()) var texts []string for _, event := range events { @@ -299,10 +299,10 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { // ── get task ───────────────────────────────────────────────────────────── - ginkgo.When("the client fetches a task by ID", ginkgo.Ordered, func() { + When("the client fetches a task by ID", Ordered, func() { var completedTask, fetchedTask *a2a.Task - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) var ok bool @@ -313,28 +313,28 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.It("returns the task with completed state", func() { + It("returns the task with completed state", func() { gomega.Expect(fetchedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) }) - ginkgo.It("returns the correct response text", func() { + It("returns the correct response text", func() { text, err := taskStatusText(fetchedTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(expectedServerText(target().serverPrefix, requestText))) }) - ginkgo.It("returns the history payload", func() { + It("returns the history payload", func() { assertTaskHistoryPayload(fetchedTask, requestText, "fetched task history") }) }) // ── list tasks ─────────────────────────────────────────────────────────── - ginkgo.When("the client lists tasks by context ID", ginkgo.Ordered, func() { + When("the client lists tasks by context ID", Ordered, func() { var completedTask *a2a.Task var listedTasks *a2a.ListTasksResponse - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) var ok bool @@ -345,7 +345,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.It("includes the sent task in the list", func() { + It("includes the sent task in the list", func() { gomega.Expect(listedTasks.Tasks).NotTo(gomega.BeEmpty()) gomega.Expect(listedTasks.Tasks).To(gomega.ContainElement(gomega.HaveField("ID", completedTask.ID))) }) @@ -353,10 +353,10 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { // ── cancel task ────────────────────────────────────────────────────────── - ginkgo.When("the client cancels a working task", ginkgo.Ordered, func() { + When("the client cancels a working task", Ordered, func() { var canceledTask, fetchedCanceledTask *a2a.Task - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(pendingRequestText, true)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) workingTask, ok := result.(*a2a.Task) @@ -370,25 +370,25 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.It("returns a canceled task", func() { + It("returns a canceled task", func() { gomega.Expect(canceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) }) - ginkgo.It("returns the cancel text in the task status", func() { + It("returns the cancel text in the task status", func() { text, err := taskStatusText(canceledTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(expectedCancelText(target().serverPrefix))) }) - ginkgo.It("the canceled state persists on fetch", func() { + It("the canceled state persists on fetch", func() { gomega.Expect(fetchedCanceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) }) }) // ── missing task error ─────────────────────────────────────────────────── - ginkgo.When("the client requests a task that does not exist", func() { - ginkgo.It("returns a not-found error", func(ctx ginkgo.SpecContext) { + When("the client requests a task that does not exist", func() { + It("returns a not-found error", func(ctx SpecContext) { _, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: a2a.NewTaskID()}) assertTaskNotFoundError(err, "missing task") }) @@ -396,10 +396,10 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { // ── cancel completed task error ────────────────────────────────────────── - ginkgo.When("the client attempts to cancel a completed task", ginkgo.Ordered, func() { + When("the client attempts to cancel a completed task", Ordered, func() { var completedTask *a2a.Task - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) var ok bool @@ -407,7 +407,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(ok).To(gomega.BeTrue()) }) - ginkgo.It("returns an error", func(ctx ginkgo.SpecContext) { + It("returns an error", func(ctx SpecContext) { _, err := client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: completedTask.ID}) assertTaskNotCancelableError(err, "canceling a terminal task should fail") }) @@ -416,11 +416,11 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { // ── push notification config ───────────────────────────────────────────── if target().expectPushSupported { - ginkgo.When("the client manages push notification config", ginkgo.Ordered, func() { + When("the client manages push notification config", Ordered, func() { var completedTask *a2a.Task var createdConfig *a2a.PushConfig - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) var ok bool @@ -434,11 +434,11 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.It("creates a push config with expected fields", func() { + It("creates a push config with expected fields", func() { assertTaskPushConfig(createdConfig, completedTask.ID, newInteropPushConfig(), "created push config") }) - ginkgo.It("fetches the push config by ID", func(ctx ginkgo.SpecContext) { + It("fetches the push config by ID", func(ctx SpecContext) { fetched, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ TaskID: completedTask.ID, ID: createdConfig.ID, @@ -447,14 +447,14 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { assertTaskPushConfig(fetched, completedTask.ID, newInteropPushConfig(), "fetched push config") }) - ginkgo.It("lists exactly one push config for the task", func(ctx ginkgo.SpecContext) { + It("lists exactly one push config for the task", func(ctx SpecContext) { listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(listed).To(gomega.HaveLen(1)) assertTaskPushConfig(listed[0], completedTask.ID, newInteropPushConfig(), "listed push config") }) - ginkgo.It("deletes the push config and the list is empty afterward", func(ctx ginkgo.SpecContext) { + It("deletes the push config and the list is empty afterward", func(ctx SpecContext) { err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ TaskID: completedTask.ID, ID: createdConfig.ID, @@ -467,10 +467,10 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { }) }) } else { - ginkgo.When("the server does not support push notifications", ginkgo.Ordered, func() { + When("the server does not support push notifications", Ordered, func() { var completedTask *a2a.Task - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) var ok bool @@ -478,14 +478,14 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(ok).To(gomega.BeTrue()) }) - ginkgo.It("returns an error for CreatePushConfig", func(ctx ginkgo.SpecContext) { + It("returns an error for CreatePushConfig", func(ctx SpecContext) { cfg := newInteropPushConfig() cfg.TaskID = completedTask.ID _, err := client.CreatePushConfig(ctx, cfg) assertPushUnsupportedError(err, "create push config unsupported") }) - ginkgo.It("returns an error for GetPushConfig", func(ctx ginkgo.SpecContext) { + It("returns an error for GetPushConfig", func(ctx SpecContext) { _, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ TaskID: completedTask.ID, ID: "interop-config", @@ -493,12 +493,12 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { assertPushUnsupportedError(err, "get push config unsupported") }) - ginkgo.It("returns an error for ListPushConfigs", func(ctx ginkgo.SpecContext) { + It("returns an error for ListPushConfigs", func(ctx SpecContext) { _, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) assertPushUnsupportedError(err, "list push config unsupported") }) - ginkgo.It("returns an error for DeletePushConfig", func(ctx ginkgo.SpecContext) { + It("returns an error for DeletePushConfig", func(ctx SpecContext) { err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ TaskID: completedTask.ID, ID: "interop-config", @@ -510,8 +510,8 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { // ── scenario parity ────────────────────────────────────────────────────── - ginkgo.When("the server returns a message-only response", func() { - ginkgo.It("the client receives a Message result", func(ctx ginkgo.SpecContext) { + When("the server returns a message-only response", func() { + It("the client receives a Message result", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(messageOnlyRequestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) message, ok := result.(*a2a.Message) @@ -522,8 +522,8 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { }) }) - ginkgo.When("the server fails the task", func() { - ginkgo.It("the client receives a failed task", func(ctx ginkgo.SpecContext) { + When("the server fails the task", func() { + It("the client receives a failed task", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(taskFailureRequestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) task, ok := result.(*a2a.Task) @@ -535,10 +535,10 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { }) }) - ginkgo.When("the agent requires multi-turn input", ginkgo.Ordered, func() { + When("the agent requires multi-turn input", Ordered, func() { var inputRequiredTask, continuedTask *a2a.Task - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(multiTurnStartRequestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) var ok bool @@ -555,14 +555,14 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(ok).To(gomega.BeTrue()) }) - ginkgo.It("the first request returns input-required state", func() { + It("the first request returns input-required state", func() { text, err := taskStatusText(inputRequiredTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", target().serverPrefix))) assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") }) - ginkgo.It("the continued request completes the task", func() { + It("the continued request completes the task", func() { gomega.Expect(continuedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) text, err := taskStatusText(continuedTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) @@ -574,16 +574,16 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { }) }) - ginkgo.When("the server streams artifacts", ginkgo.Ordered, func() { + When("the server streams artifacts", Ordered, func() { var streamingEvents []a2a.Event - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { var err error streamingEvents, err = client.SendStreamingMessage(ctx, newInteropRequest(streamingRequestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - ginkgo.It("emits a working task at the start", func() { + It("emits a working task at the start", func() { sawStart := false for _, event := range streamingEvents { if task, ok := event.(*a2a.Task); ok { @@ -598,7 +598,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(sawStart).To(gomega.BeTrue(), "expected a working Task streaming-start event") }) - ginkgo.It("emits artifact chunks in order with at least one append", func() { + It("emits artifact chunks in order with at least one append", func() { var chunks []string sawAppend := false for _, event := range streamingEvents { @@ -615,7 +615,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { gomega.Expect(chunks).To(gomega.Equal([]string{"streaming chunk 1", "streaming chunk 2"})) }) - ginkgo.It("emits a completed status update at the end", func() { + It("emits a completed status update at the end", func() { sawComplete := false for _, event := range streamingEvents { if update, ok := event.(*a2a.TaskStatusUpdateEvent); ok { @@ -630,10 +630,10 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { }) }) - ginkgo.When("the server handles a long-running request", ginkgo.Ordered, func() { + When("the server handles a long-running request", Ordered, func() { var completedTask *a2a.Task - ginkgo.BeforeAll(func(ctx ginkgo.SpecContext) { + BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(longRunningRequestText, true)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) task, ok := result.(*a2a.Task) @@ -649,19 +649,19 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { case a2a.TaskStateCompleted: completedTask = task default: - ginkgo.Fail(fmt.Sprintf("unexpected long-running task initial state: %s", task.Status.State)) + Fail(fmt.Sprintf("unexpected long-running task initial state: %s", task.Status.State)) } }) - ginkgo.It("completes with the expected text", func() { + It("completes with the expected text", func() { text, err := taskStatusText(completedTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", target().serverPrefix))) }) }) - ginkgo.When("the server returns rich data types", func() { - ginkgo.It("the completed task includes structured artifact data", func(ctx ginkgo.SpecContext) { + When("the server returns rich data types", func() { + It("the completed task includes structured artifact data", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(dataTypesRequestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) task, ok := result.(*a2a.Task) @@ -674,8 +674,8 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { }) }) - ginkgo.When("the client fetches the extended agent card", func() { - ginkgo.It("returns a valid extended card", func(ctx ginkgo.SpecContext) { + When("the client fetches the extended agent card", func() { + It("returns a valid extended card", func(ctx SpecContext) { card, err := client.GetExtendedCard(ctx, &a2a.GetExtendedAgentCardRequest{}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) assertExtendedCardMetadata(card, "extended-card") diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index 2b57551b..ba557fb5 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -31,7 +31,7 @@ import ( "context" "sync" - ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -79,7 +79,7 @@ func getPythonAssets() pythonFixtureAssets { return cachedPython } -var _ = ginkgo.AfterSuite(func() { +var _ = AfterSuite(func() { removeTempDir(cachedGo.tempDir) removeTempDir(cachedRust.tempDir) removeTempDir(cachedDotNet.tempDir) @@ -110,20 +110,20 @@ type serverMaker func(protocol transportProtocol) *fixtureServer // ── interoperability matrix ─────────────────────────────────────────────────── -var _ = ginkgo.DescribeTableSubtree( +var _ = DescribeTableSubtree( "A2A interoperability", func(clientSDK string, newClient newClientFn, clientGRPC bool) { - ginkgo.DescribeTableSubtree("server", + DescribeTableSubtree("server", func(serverSDK string, makeServer serverMaker, serverGRPC bool) { label := clientSDK + "-" + serverSDK // Build transport entries; gRPC is only added when both sides support it. transportBodyFn := func(protocol transportProtocol) { srv := makeServer(protocol) - ginkgo.BeforeAll(func() { + BeforeAll(func() { gomega.Expect(srv.start()).NotTo(gomega.HaveOccurred()) }) - ginkgo.AfterAll(func() { srv.stop() }) + AfterAll(func() { srv.stop() }) registerBehaviors(newClient, srv.targetFor(protocol)) } @@ -132,43 +132,43 @@ var _ = ginkgo.DescribeTableSubtree( // and inherit ContinueOnFailure behaviour from them. args := []interface{}{ transportBodyFn, - ginkgo.Entry("JSON-RPC", - ginkgo.Ordered, ginkgo.Label(label, "jsonrpc"), + Entry("JSON-RPC", + Ordered, Label(label, "jsonrpc"), transportJSONRPC), - ginkgo.Entry("REST", - ginkgo.Ordered, ginkgo.Label(label, "rest"), + Entry("REST", + Ordered, Label(label, "rest"), transportREST), } if clientGRPC && serverGRPC { - args = append(args, ginkgo.Entry("gRPC", - ginkgo.Ordered, ginkgo.Label(label, "grpc"), + args = append(args, Entry("gRPC", + Ordered, Label(label, "grpc"), transportGRPC)) } - ginkgo.DescribeTableSubtree("transport", args...) + DescribeTableSubtree("transport", args...) }, // ── server entries ──────────────────────────────────────────────── // ContinueOnFailure is inherited from the client entries (outermost Ordered). - ginkgo.Entry("go", ginkgo.Ordered, + Entry("go", Ordered, "go", serverMaker(func(p transportProtocol) *fixtureServer { return newGoServer(getGoBinaries, true, p) }), true), - ginkgo.Entry("rust", ginkgo.Ordered, + Entry("rust", Ordered, "rust", serverMaker(func(p transportProtocol) *fixtureServer { return newRustServer(getRustBinaries, true, p) }), true), - ginkgo.Entry("python", ginkgo.Ordered, + Entry("python", Ordered, "python", serverMaker(func(p transportProtocol) *fixtureServer { return newPythonServer(getPythonAssets, true, p) }), true), - ginkgo.Entry("dotnet", ginkgo.Ordered, + Entry("dotnet", Ordered, "dotnet", serverMaker(func(p transportProtocol) *fixtureServer { return newDotNetServer(getDotNetBinaries, false, p) }), false), ) }, // ── client entries ──────────────────────────────────────────────────────── - ginkgo.Entry("go", ginkgo.Ordered, ginkgo.ContinueOnFailure, "go", goClientFn, true), - ginkgo.Entry("rust", ginkgo.Ordered, ginkgo.ContinueOnFailure, "rust", rustClientFn, true), - ginkgo.Entry("python", ginkgo.Ordered, ginkgo.ContinueOnFailure, "python", pythonClientFn, true), - ginkgo.Entry("dotnet", ginkgo.Ordered, ginkgo.ContinueOnFailure, "dotnet", dotNetClientFn, false), + Entry("go", Ordered, ContinueOnFailure, "go", goClientFn, true), + Entry("rust", Ordered, ContinueOnFailure, "rust", rustClientFn, true), + Entry("python", Ordered, ContinueOnFailure, "python", pythonClientFn, true), + Entry("dotnet", Ordered, ContinueOnFailure, "dotnet", dotNetClientFn, false), ) diff --git a/integrations/agntcy-a2a/tests/suite_test.go b/integrations/agntcy-a2a/tests/suite_test.go index 88160aba..678f46e2 100644 --- a/integrations/agntcy-a2a/tests/suite_test.go +++ b/integrations/agntcy-a2a/tests/suite_test.go @@ -6,11 +6,11 @@ package tests import ( "testing" - ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) func TestTests(t *testing.T) { - gomega.RegisterFailHandler(ginkgo.Fail) - ginkgo.RunSpecs(t, "A2A Interop Suite") + gomega.RegisterFailHandler(Fail) + RunSpecs(t, "A2A Interop Suite") } From 54087375f254197eb395012af19f9383cfe3d1a9 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 10:39:57 +0100 Subject: [PATCH 05/18] refactor(a2a): use Skip() for unsupported gRPC combinations instead of omitting entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 48 (client × server × transport) combinations are now always present in the Ginkgo spec tree. Previously the gRPC transport entry was injected dynamically only when both sides supported it, so 7 combinations simply did not appear in test reports. Now every transport entry is always registered. When either the client or the server SDK does not support gRPC, BeforeAll calls Skip(), which marks all specs in that ordered container as skipped and keeps AfterAll running for cleanup. stopFixtures is a no-op for processes that were never started, so this is safe. Spec count: 1107 → 1296 (7 new gRPC combinations × 27 specs each). Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../agntcy-a2a/tests/interop_table_test.go | 56 +++++++++---------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index ba557fb5..523ad6d3 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -14,15 +14,20 @@ package tests // transport gRPC // server rust // ... -// server dotnet (JSON-RPC + REST only — no gRPC) +// server dotnet +// transport JSON-RPC +// transport REST +// transport gRPC ← skipped: dotnet server does not support gRPC // ... // rust (client) // ... -// dotnet (client) (JSON-RPC + REST only — no gRPC for any server) +// dotnet (client) +// ... +// transport gRPC ← skipped: dotnet client does not support gRPC // -// Server entries and transport entries are defined once; the body functions close -// over the outer-level variables. gRPC transport entries are injected dynamically -// when both the client and server SDKs support it. +// All three transports are always present in the tree. When either the client +// or the server SDK does not support gRPC, the gRPC BeforeAll calls Skip() so +// that the specs appear as skipped in the report rather than being absent. // // Each SDK's fixture assets are built at most once per test run via package-level // sync.Once caches. An AfterSuite handler removes the temp directories. @@ -117,34 +122,25 @@ var _ = DescribeTableSubtree( func(serverSDK string, makeServer serverMaker, serverGRPC bool) { label := clientSDK + "-" + serverSDK - // Build transport entries; gRPC is only added when both sides support it. - transportBodyFn := func(protocol transportProtocol) { - srv := makeServer(protocol) - BeforeAll(func() { - gomega.Expect(srv.start()).NotTo(gomega.HaveOccurred()) - }) - AfterAll(func() { srv.stop() }) - registerBehaviors(newClient, srv.targetFor(protocol)) - } - // ContinueOnFailure is not set here: transport entries are nested // inside client entries (which are the outermost Ordered containers) // and inherit ContinueOnFailure behaviour from them. - args := []interface{}{ - transportBodyFn, - Entry("JSON-RPC", - Ordered, Label(label, "jsonrpc"), - transportJSONRPC), - Entry("REST", - Ordered, Label(label, "rest"), - transportREST), - } - if clientGRPC && serverGRPC { - args = append(args, Entry("gRPC", - Ordered, Label(label, "grpc"), - transportGRPC)) - } - DescribeTableSubtree("transport", args...) + DescribeTableSubtree("transport", + func(protocol transportProtocol) { + srv := makeServer(protocol) + BeforeAll(func() { + if protocol == transportGRPC && (!clientGRPC || !serverGRPC) { + Skip("gRPC transport is not supported by this client/server combination") + } + gomega.Expect(srv.start()).NotTo(gomega.HaveOccurred()) + }) + AfterAll(func() { srv.stop() }) + registerBehaviors(newClient, srv.targetFor(protocol)) + }, + Entry("JSON-RPC", Ordered, Label(label, "jsonrpc"), transportJSONRPC), + Entry("REST", Ordered, Label(label, "rest"), transportREST), + Entry("gRPC", Ordered, Label(label, "grpc"), transportGRPC), + ) }, // ── server entries ──────────────────────────────────────────────── // ContinueOnFailure is inherited from the client entries (outermost Ordered). From a29e4dd11c98762119f547da36d1eb5a2ec95ae7 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 11:46:58 +0100 Subject: [PATCH 06/18] refactor(a2a): move serverMaker factories to package vars and makeServer into BeforeAll Two readability improvements to the interop table: 1. Extract the four inline serverMaker closures from the Entry call sites into named package-level variables (goServerMaker, rustServerMaker, pythonServerMaker, dotNetServerMaker), matching the existing pattern used for the client factories. Server entries are now single-line. 2. Move srv = makeServer(protocol) inside BeforeAll after the Skip check, so no fixtureServer is constructed at all for unsupported combinations. AfterAll guards against the nil case with an explicit nil check. To allow this, expectPushSupported is threaded as an explicit bool parameter on both the server entries and registerBehaviors, removing the tree-building-time call to target().expectPushSupported. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_behaviors_test.go | 4 +- .../agntcy-a2a/tests/interop_table_test.go | 60 +++++++++++-------- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_behaviors_test.go b/integrations/agntcy-a2a/tests/interop_behaviors_test.go index ee3cabd5..3332b3d4 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -230,7 +230,7 @@ func waitForProbeTaskState(ctx context.Context, client probeClient, taskID a2a.T // registerBehaviors registers the full interop spec tree against the given client factory // and server target. The containing Context MUST be Ordered so that the outer BeforeAll // (which creates the client) executes before any It block. -func registerBehaviors(newClient newClientFn, target func() interopTarget) { +func registerBehaviors(newClient newClientFn, target func() interopTarget, expectPushSupported bool) { var client probeClient BeforeAll(func(ctx SpecContext) { @@ -415,7 +415,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget) { // ── push notification config ───────────────────────────────────────────── - if target().expectPushSupported { + if expectPushSupported { When("the client manages push notification config", Ordered, func() { var completedTask *a2a.Task var createdConfig *a2a.PushConfig diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index 523ad6d3..17b559dd 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -107,19 +107,34 @@ var ( } ) -// ── server factory type ─────────────────────────────────────────────────────── +// ── server factory type and factories ───────────────────────────────────────── -// serverMaker creates a fresh fixtureServer configured for a single transport -// protocol. Called at tree-building time; the actual process is started in BeforeAll. +// serverMaker creates a fresh fixtureServer for a single transport protocol. +// Called in BeforeAll once the Skip check has passed. type serverMaker func(protocol transportProtocol) *fixtureServer +var ( + goServerMaker serverMaker = func(p transportProtocol) *fixtureServer { + return newGoServer(getGoBinaries, true, p) + } + rustServerMaker serverMaker = func(p transportProtocol) *fixtureServer { + return newRustServer(getRustBinaries, true, p) + } + pythonServerMaker serverMaker = func(p transportProtocol) *fixtureServer { + return newPythonServer(getPythonAssets, true, p) + } + dotNetServerMaker serverMaker = func(p transportProtocol) *fixtureServer { + return newDotNetServer(getDotNetBinaries, false, p) + } +) + // ── interoperability matrix ─────────────────────────────────────────────────── var _ = DescribeTableSubtree( "A2A interoperability", func(clientSDK string, newClient newClientFn, clientGRPC bool) { DescribeTableSubtree("server", - func(serverSDK string, makeServer serverMaker, serverGRPC bool) { + func(serverSDK string, makeServer serverMaker, serverGRPC bool, expectPushSupported bool) { label := clientSDK + "-" + serverSDK // ContinueOnFailure is not set here: transport entries are nested @@ -127,15 +142,22 @@ var _ = DescribeTableSubtree( // and inherit ContinueOnFailure behaviour from them. DescribeTableSubtree("transport", func(protocol transportProtocol) { - srv := makeServer(protocol) + var srv *fixtureServer BeforeAll(func() { if protocol == transportGRPC && (!clientGRPC || !serverGRPC) { Skip("gRPC transport is not supported by this client/server combination") } + srv = makeServer(protocol) gomega.Expect(srv.start()).NotTo(gomega.HaveOccurred()) }) - AfterAll(func() { srv.stop() }) - registerBehaviors(newClient, srv.targetFor(protocol)) + AfterAll(func() { + if srv != nil { + srv.stop() + } + }) + registerBehaviors(newClient, func() interopTarget { + return srv.targetFor(protocol)() + }, expectPushSupported) }, Entry("JSON-RPC", Ordered, Label(label, "jsonrpc"), transportJSONRPC), Entry("REST", Ordered, Label(label, "rest"), transportREST), @@ -144,27 +166,15 @@ var _ = DescribeTableSubtree( }, // ── server entries ──────────────────────────────────────────────── // ContinueOnFailure is inherited from the client entries (outermost Ordered). - Entry("go", Ordered, - "go", serverMaker(func(p transportProtocol) *fixtureServer { - return newGoServer(getGoBinaries, true, p) - }), true), - Entry("rust", Ordered, - "rust", serverMaker(func(p transportProtocol) *fixtureServer { - return newRustServer(getRustBinaries, true, p) - }), true), - Entry("python", Ordered, - "python", serverMaker(func(p transportProtocol) *fixtureServer { - return newPythonServer(getPythonAssets, true, p) - }), true), - Entry("dotnet", Ordered, - "dotnet", serverMaker(func(p transportProtocol) *fixtureServer { - return newDotNetServer(getDotNetBinaries, false, p) - }), false), + Entry("go", Ordered, "go", goServerMaker, true, true), + Entry("rust", Ordered, "rust", rustServerMaker, true, true), + Entry("python", Ordered, "python", pythonServerMaker, true, true), + Entry("dotnet", Ordered, "dotnet", dotNetServerMaker, false, false), ) }, // ── client entries ──────────────────────────────────────────────────────── - Entry("go", Ordered, ContinueOnFailure, "go", goClientFn, true), - Entry("rust", Ordered, ContinueOnFailure, "rust", rustClientFn, true), + Entry("go", Ordered, ContinueOnFailure, "go", goClientFn, true), + Entry("rust", Ordered, ContinueOnFailure, "rust", rustClientFn, true), Entry("python", Ordered, ContinueOnFailure, "python", pythonClientFn, true), Entry("dotnet", Ordered, ContinueOnFailure, "dotnet", dotNetClientFn, false), ) From 4604e3c566e6d97b1ebcee77c4d2233b2ad155c5 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 11:54:12 +0100 Subject: [PATCH 07/18] refactor(a2a): replace SDK string params with CLIENT:/SERVER:/TRANSPORT: labels Use Ginkgo's label set-operation syntax instead of hand-composed strings. Each entry level now carries a structured label: CLIENT:go CLIENT:rust CLIENT:python CLIENT:dotnet SERVER:go SERVER:rust SERVER:python SERVER:dotnet TRANSPORT:jsonrpc TRANSPORT:rest TRANSPORT:grpc Specs inherit all three category labels from their ancestor entries, enabling expressive label-filter queries: --ginkgo.label-filter='CLIENT: consistsOf {go}' --ginkgo.label-filter='CLIENT: containsAny {rust, go} && SERVER: consistsOf {dotnet}' --ginkgo.label-filter='TRANSPORT: containsAny {grpc}' As a side-effect, the clientSDK and serverSDK string parameters are removed from the outer body functions (they existed solely to compose the old "go-rust"-style label), simplifying both body function signatures. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../agntcy-a2a/tests/interop_table_test.go | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index 17b559dd..ba75e319 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -7,27 +7,33 @@ package tests // interop matrix with minimal repetition: // // A2A interoperability -// go (client) -// server go -// transport JSON-RPC → BeforeAll/registerBehaviors/AfterAll -// transport REST -// transport gRPC -// server rust +// go (client) [CLIENT:go] +// server go [SERVER:go] +// transport JSON-RPC [TRANSPORT:jsonrpc] +// transport REST [TRANSPORT:rest] +// transport gRPC [TRANSPORT:grpc] +// server rust [SERVER:rust] // ... -// server dotnet +// server dotnet [SERVER:dotnet] // transport JSON-RPC // transport REST -// transport gRPC ← skipped: dotnet server does not support gRPC +// transport gRPC ← skipped: dotnet does not support gRPC // ... -// rust (client) +// rust (client) [CLIENT:rust] // ... -// dotnet (client) +// dotnet (client) [CLIENT:dotnet] // ... -// transport gRPC ← skipped: dotnet client does not support gRPC +// transport gRPC ← skipped: dotnet does not support gRPC +// +// Each spec inherits all three category labels from its ancestor entries, enabling +// set-based label filtering: // -// All three transports are always present in the tree. When either the client -// or the server SDK does not support gRPC, the gRPC BeforeAll calls Skip() so -// that the specs appear as skipped in the report rather than being absent. +// --ginkgo.label-filter='CLIENT: consistsOf {go}' +// --ginkgo.label-filter='CLIENT: containsAny {rust, go} && SERVER: consistsOf {dotnet}' +// --ginkgo.label-filter='TRANSPORT: containsAny {grpc}' +// +// When either the client or the server SDK does not support gRPC, the gRPC +// BeforeAll calls Skip() so the specs appear as skipped rather than absent. // // Each SDK's fixture assets are built at most once per test run via package-level // sync.Once caches. An AfterSuite handler removes the temp directories. @@ -132,11 +138,9 @@ var ( var _ = DescribeTableSubtree( "A2A interoperability", - func(clientSDK string, newClient newClientFn, clientGRPC bool) { + func(newClient newClientFn, clientGRPC bool) { DescribeTableSubtree("server", - func(serverSDK string, makeServer serverMaker, serverGRPC bool, expectPushSupported bool) { - label := clientSDK + "-" + serverSDK - + func(makeServer serverMaker, serverGRPC bool, expectPushSupported bool) { // ContinueOnFailure is not set here: transport entries are nested // inside client entries (which are the outermost Ordered containers) // and inherit ContinueOnFailure behaviour from them. @@ -159,22 +163,22 @@ var _ = DescribeTableSubtree( return srv.targetFor(protocol)() }, expectPushSupported) }, - Entry("JSON-RPC", Ordered, Label(label, "jsonrpc"), transportJSONRPC), - Entry("REST", Ordered, Label(label, "rest"), transportREST), - Entry("gRPC", Ordered, Label(label, "grpc"), transportGRPC), + Entry("JSON-RPC", Ordered, Label("TRANSPORT:jsonrpc"), transportJSONRPC), + Entry("REST", Ordered, Label("TRANSPORT:rest"), transportREST), + Entry("gRPC", Ordered, Label("TRANSPORT:grpc"), transportGRPC), ) }, // ── server entries ──────────────────────────────────────────────── // ContinueOnFailure is inherited from the client entries (outermost Ordered). - Entry("go", Ordered, "go", goServerMaker, true, true), - Entry("rust", Ordered, "rust", rustServerMaker, true, true), - Entry("python", Ordered, "python", pythonServerMaker, true, true), - Entry("dotnet", Ordered, "dotnet", dotNetServerMaker, false, false), + Entry("go", Ordered, Label("SERVER:go"), goServerMaker, true, true), + Entry("rust", Ordered, Label("SERVER:rust"), rustServerMaker, true, true), + Entry("python", Ordered, Label("SERVER:python"), pythonServerMaker, true, true), + Entry("dotnet", Ordered, Label("SERVER:dotnet"), dotNetServerMaker, false, false), ) }, // ── client entries ──────────────────────────────────────────────────────── - Entry("go", Ordered, ContinueOnFailure, "go", goClientFn, true), - Entry("rust", Ordered, ContinueOnFailure, "rust", rustClientFn, true), - Entry("python", Ordered, ContinueOnFailure, "python", pythonClientFn, true), - Entry("dotnet", Ordered, ContinueOnFailure, "dotnet", dotNetClientFn, false), + Entry("go", Ordered, ContinueOnFailure, Label("CLIENT:go"), goClientFn, true), + Entry("rust", Ordered, ContinueOnFailure, Label("CLIENT:rust"), rustClientFn, true), + Entry("python", Ordered, ContinueOnFailure, Label("CLIENT:python"), pythonClientFn, true), + Entry("dotnet", Ordered, ContinueOnFailure, Label("CLIENT:dotnet"), dotNetClientFn, false), ) From b3375da10643c202bcbccc3774190ac7ca72629a Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 12:23:37 +0100 Subject: [PATCH 08/18] refactor(a2a): align Go probe client with external probe pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the direct Go SDK wrapper (goProbeClient) with a subprocess-based external probe client (go-probe binary) that follows the same subcommand CLI as the Rust, Python, and .NET probes. This removes the binding between the test framework and the fixture SDK — the Go client now goes through the same JSON serialization/deserialization path as every other language client. Changes: - Add fixtures/go-probe/main.go: thin A2A probe binary with --card-url flag and subcommand dispatch (send-message, send-streaming-message, get-task, cancel-task, list-tasks, create/get/list/delete-push-config, get-extended-card) - Add goProbe field to fixtureBinaries and build it in both buildGoFixtureBinaryOnly and buildFixtureBinaries - Add newGoProbeClient to interop_probe_clients_test.go using externalProbeClient and makeProbeInvoker - Strip goProbeClient struct and SDK imports from interop_client_test.go, keeping only the probeClient interface and newClientFn type - Update goClientFn to use newGoProbeClient(getGoBinaries, url) matching the pattern of rustClientFn, dotNetClientFn, and pythonClientFn Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../agntcy-a2a/fixtures/go-probe/main.go | 242 ++++++++++++++++++ .../agntcy-a2a/tests/interop_client_test.go | 78 +----- .../tests/interop_launchers_test.go | 3 +- .../tests/interop_probe_clients_test.go | 10 + .../agntcy-a2a/tests/interop_shared_test.go | 1 + .../agntcy-a2a/tests/interop_table_test.go | 4 +- .../tests/native_go_launchers_test.go | 19 +- 7 files changed, 272 insertions(+), 85 deletions(-) create mode 100644 integrations/agntcy-a2a/fixtures/go-probe/main.go diff --git a/integrations/agntcy-a2a/fixtures/go-probe/main.go b/integrations/agntcy-a2a/fixtures/go-probe/main.go new file mode 100644 index 00000000..a678269e --- /dev/null +++ b/integrations/agntcy-a2a/fixtures/go-probe/main.go @@ -0,0 +1,242 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +// Thin A2A probe adapter: executes a single A2A operation and writes the raw +// JSON result to stdout. On A2A error, writes {"code":…,"message":"…"} to +// stderr and exits 1. No assertions or orchestration live here — all test +// logic is in the Go/Ginkgo spec tree. + +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" + a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// ── error helpers ───────────────────────────────────────────────────────────── + +func probeErrorCode(err error) int { + switch { + case errors.Is(err, a2a.ErrTaskNotFound): + return -32001 + case errors.Is(err, a2a.ErrTaskNotCancelable): + return -32002 + case errors.Is(err, a2a.ErrPushNotificationNotSupported): + return -32003 + case errors.Is(err, a2a.ErrUnsupportedOperation): + return -32004 + default: + return -32000 + } +} + +func fail(code int, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + data, _ := json.Marshal(map[string]any{"code": code, "message": msg}) + fmt.Fprintln(os.Stderr, string(data)) + os.Exit(1) +} + +func failErr(err error) { + fail(probeErrorCode(err), "%s", err.Error()) +} + +// ── output helpers ──────────────────────────────────────────────────────────── + +func printJSON(v any) { + data, err := json.Marshal(v) + if err != nil { + fail(-32000, "marshal response: %v", err) + } + fmt.Println(string(data)) +} + +func envelope(typ string, data any) map[string]any { + return map[string]any{"type": typ, "data": data} +} + +// ── argument helpers ────────────────────────────────────────────────────────── + +func flagValue(args []string, name string) string { + for i, arg := range args { + if arg == name && i+1 < len(args) { + return args[i+1] + } + } + fail(-32000, "missing %s", name) + return "" +} + +// ── main ────────────────────────────────────────────────────────────────────── + +func main() { + cardURL := flag.String("card-url", "", "agent card URL") + flag.Parse() + + if *cardURL == "" { + fail(-32000, "missing --card-url") + } + + rest := flag.Args() + if len(rest) == 0 { + fail(-32000, "missing subcommand") + } + subcommand := rest[0] + args := rest[1:] + + ctx := context.Background() + + card, err := agentcard.DefaultResolver.Resolve(ctx, *cardURL) + if err != nil { + fail(-32000, "resolve agent card: %v", err) + } + + client, err := a2aclient.NewFromCard( + ctx, + card, + a2agrpc.WithGRPCTransport(grpc.WithTransportCredentials(insecure.NewCredentials())), + ) + if err != nil { + fail(-32000, "create client: %v", err) + } + + switch subcommand { + + // ── send-message ────────────────────────────────────────────────────────── + case "send-message": + var req a2a.SendMessageRequest + if err := json.Unmarshal([]byte(flagValue(args, "--message-json")), &req); err != nil { + fail(-32000, "parse send-message request: %v", err) + } + result, err := client.SendMessage(ctx, &req) + if err != nil { + failErr(err) + } + switch v := result.(type) { + case *a2a.Task: + printJSON(envelope("task", v)) + case *a2a.Message: + printJSON(envelope("message", v)) + default: + fail(-32000, "unexpected send-message result type") + } + + // ── send-streaming-message ──────────────────────────────────────────────── + case "send-streaming-message": + var req a2a.SendMessageRequest + if err := json.Unmarshal([]byte(flagValue(args, "--message-json")), &req); err != nil { + fail(-32000, "parse send-streaming-message request: %v", err) + } + for event, err := range client.SendStreamingMessage(ctx, &req) { + if err != nil { + failErr(err) + } + switch v := event.(type) { + case *a2a.Task: + printJSON(envelope("task", v)) + case *a2a.Message: + printJSON(envelope("message", v)) + case *a2a.TaskStatusUpdateEvent: + printJSON(envelope("task-status-update", v)) + case *a2a.TaskArtifactUpdateEvent: + printJSON(envelope("task-artifact-update", v)) + default: + fail(-32000, "unexpected streaming event type") + } + } + + // ── get-task ────────────────────────────────────────────────────────────── + case "get-task": + task, err := client.GetTask(ctx, &a2a.GetTaskRequest{ + ID: a2a.TaskID(flagValue(args, "--task-id")), + }) + if err != nil { + failErr(err) + } + printJSON(task) + + // ── cancel-task ─────────────────────────────────────────────────────────── + case "cancel-task": + task, err := client.CancelTask(ctx, &a2a.CancelTaskRequest{ + ID: a2a.TaskID(flagValue(args, "--task-id")), + }) + if err != nil { + failErr(err) + } + printJSON(task) + + // ── list-tasks ──────────────────────────────────────────────────────────── + case "list-tasks": + resp, err := client.ListTasks(ctx, &a2a.ListTasksRequest{ + ContextID: flagValue(args, "--context-id"), + }) + if err != nil { + failErr(err) + } + printJSON(resp) + + // ── create-push-config ──────────────────────────────────────────────────── + case "create-push-config": + var cfg a2a.PushConfig + if err := json.Unmarshal([]byte(flagValue(args, "--config-json")), &cfg); err != nil { + fail(-32000, "parse push config: %v", err) + } + result, err := client.CreateTaskPushConfig(ctx, &cfg) + if err != nil { + failErr(err) + } + printJSON(result) + + // ── get-push-config ─────────────────────────────────────────────────────── + case "get-push-config": + result, err := client.GetTaskPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + TaskID: a2a.TaskID(flagValue(args, "--task-id")), + ID: flagValue(args, "--config-id"), + }) + if err != nil { + failErr(err) + } + printJSON(result) + + // ── list-push-configs ───────────────────────────────────────────────────── + case "list-push-configs": + configs, err := client.ListTaskPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{ + TaskID: a2a.TaskID(flagValue(args, "--task-id")), + }) + if err != nil { + failErr(err) + } + printJSON(a2a.ListTaskPushConfigResponse{Configs: configs}) + + // ── delete-push-config ──────────────────────────────────────────────────── + case "delete-push-config": + if err := client.DeleteTaskPushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + TaskID: a2a.TaskID(flagValue(args, "--task-id")), + ID: flagValue(args, "--config-id"), + }); err != nil { + failErr(err) + } + + // ── get-extended-card ───────────────────────────────────────────────────── + case "get-extended-card": + card, err := client.GetExtendedAgentCard(ctx, &a2a.GetExtendedAgentCardRequest{}) + if err != nil { + failErr(err) + } + printJSON(card) + + default: + fail(-32000, "unknown subcommand: %s", subcommand) + } +} diff --git a/integrations/agntcy-a2a/tests/interop_client_test.go b/integrations/agntcy-a2a/tests/interop_client_test.go index f185c66a..6d486cdd 100644 --- a/integrations/agntcy-a2a/tests/interop_client_test.go +++ b/integrations/agntcy-a2a/tests/interop_client_test.go @@ -3,19 +3,15 @@ package tests -// This file defines the probeClient interface and the native Go SDK implementation. +// This file defines the probeClient interface and the newClientFn type. // All interop behavior specs are written against probeClient so the same spec tree // exercises Go, Rust, Python, and .NET clients without duplication. +// Client implementations live in interop_probe_clients_test.go. import ( "context" "github.com/a2aproject/a2a-go/v2/a2a" - "github.com/a2aproject/a2a-go/v2/a2aclient" - "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" - a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" ) // probeClient is the unified A2A client interface used by all behavior specs. @@ -35,73 +31,3 @@ type probeClient interface { // newClientFn creates a probeClient bound to a specific baseURL. type newClientFn func(ctx context.Context, baseURL string) (probeClient, error) - -// goProbeClient wraps the native Go A2A SDK client. -type goProbeClient struct { - client *a2aclient.Client -} - -func newGoProbeClient(ctx context.Context, baseURL string) (probeClient, error) { - card, err := agentcard.DefaultResolver.Resolve(ctx, baseURL) - if err != nil { - return nil, err - } - - client, err := a2aclient.NewFromCard( - ctx, - card, - a2agrpc.WithGRPCTransport(grpc.WithTransportCredentials(insecure.NewCredentials())), - ) - if err != nil { - return nil, err - } - - return &goProbeClient{client: client}, nil -} - -func (c *goProbeClient) SendMessage(ctx context.Context, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) { - return c.client.SendMessage(ctx, req) -} - -func (c *goProbeClient) SendStreamingMessage(ctx context.Context, req *a2a.SendMessageRequest) ([]a2a.Event, error) { - var events []a2a.Event - for event, err := range c.client.SendStreamingMessage(ctx, req) { - if err != nil { - return nil, err - } - events = append(events, event) - } - return events, nil -} - -func (c *goProbeClient) GetTask(ctx context.Context, req *a2a.GetTaskRequest) (*a2a.Task, error) { - return c.client.GetTask(ctx, req) -} - -func (c *goProbeClient) CancelTask(ctx context.Context, req *a2a.CancelTaskRequest) (*a2a.Task, error) { - return c.client.CancelTask(ctx, req) -} - -func (c *goProbeClient) ListTasks(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) { - return c.client.ListTasks(ctx, req) -} - -func (c *goProbeClient) CreatePushConfig(ctx context.Context, cfg *a2a.PushConfig) (*a2a.PushConfig, error) { - return c.client.CreateTaskPushConfig(ctx, cfg) -} - -func (c *goProbeClient) GetPushConfig(ctx context.Context, req *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) { - return c.client.GetTaskPushConfig(ctx, req) -} - -func (c *goProbeClient) ListPushConfigs(ctx context.Context, req *a2a.ListTaskPushConfigRequest) ([]*a2a.PushConfig, error) { - return c.client.ListTaskPushConfigs(ctx, req) -} - -func (c *goProbeClient) DeletePushConfig(ctx context.Context, req *a2a.DeleteTaskPushConfigRequest) error { - return c.client.DeleteTaskPushConfig(ctx, req) -} - -func (c *goProbeClient) GetExtendedCard(ctx context.Context, req *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) { - return c.client.GetExtendedAgentCard(ctx, req) -} diff --git a/integrations/agntcy-a2a/tests/interop_launchers_test.go b/integrations/agntcy-a2a/tests/interop_launchers_test.go index ed41fc4b..612aba54 100644 --- a/integrations/agntcy-a2a/tests/interop_launchers_test.go +++ b/integrations/agntcy-a2a/tests/interop_launchers_test.go @@ -70,10 +70,11 @@ func buildFixtureBinaries() (fixtureBinaries, error) { binaries := fixtureBinaries{ tempDir: tempDir, goServer: filepath.Join(tempDir, executableName("go-jsonrpc-server")), + goProbe: filepath.Join(tempDir, executableName("go-probe")), rustServer: filepath.Join(tempDir, "cargo-target", "debug", executableName("interop-rust-server")), rustProbe: filepath.Join(tempDir, "cargo-target", "debug", executableName("interop-rust-probe")), } - if err := buildGoFixture(root, binaries.goServer); err != nil { + if err := buildGoFixture(root, binaries.goServer, binaries.goProbe); err != nil { _ = os.RemoveAll(tempDir) return fixtureBinaries{}, err } diff --git a/integrations/agntcy-a2a/tests/interop_probe_clients_test.go b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go index fcf463b1..98cfb5dd 100644 --- a/integrations/agntcy-a2a/tests/interop_probe_clients_test.go +++ b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go @@ -286,6 +286,16 @@ func makeProbeInvoker(binary string, globalArgs ...string) probeInvoker { } } +func newGoProbeClient(getBinaries func() fixtureBinaries, baseURL string) probeClient { + return &externalProbeClient{ + baseURL: baseURL, + invoke: func(ctx context.Context, args ...string) ([]byte, []byte, error) { + invoke := makeProbeInvoker(getBinaries().goProbe, "--card-url", baseURL) + return invoke(ctx, args...) + }, + } +} + func newRustProbeClient(getBinaries func() fixtureBinaries, baseURL string) probeClient { return &externalProbeClient{ baseURL: baseURL, diff --git a/integrations/agntcy-a2a/tests/interop_shared_test.go b/integrations/agntcy-a2a/tests/interop_shared_test.go index ea46b462..61062f84 100644 --- a/integrations/agntcy-a2a/tests/interop_shared_test.go +++ b/integrations/agntcy-a2a/tests/interop_shared_test.go @@ -70,6 +70,7 @@ type interopTarget struct { type fixtureBinaries struct { tempDir string goServer string + goProbe string rustServer string rustProbe string } diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index ba75e319..a1de4ba3 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -99,8 +99,8 @@ var _ = AfterSuite(func() { // ── client factories ────────────────────────────────────────────────────────── var ( - goClientFn newClientFn = func(ctx context.Context, url string) (probeClient, error) { - return newGoProbeClient(ctx, url) + goClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { + return newGoProbeClient(getGoBinaries, url), nil } rustClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { return newRustProbeClient(getRustBinaries, url), nil diff --git a/integrations/agntcy-a2a/tests/native_go_launchers_test.go b/integrations/agntcy-a2a/tests/native_go_launchers_test.go index dd872e65..11724da0 100644 --- a/integrations/agntcy-a2a/tests/native_go_launchers_test.go +++ b/integrations/agntcy-a2a/tests/native_go_launchers_test.go @@ -15,14 +15,20 @@ import ( "path/filepath" ) -func buildGoFixture(root string, outputPath string) error { +func buildGoFixture(root string, serverPath string, probePath string) error { buildCtx, cancel := context.WithTimeout(context.Background(), buildTimeout) defer cancel() - goBuild := exec.CommandContext(buildCtx, "go", "build", "-o", outputPath, "./fixtures/go-jsonrpc-server") - goBuild.Dir = root - if output, err := goBuild.CombinedOutput(); err != nil { - return fmt.Errorf("build go fixture: %w\n%s", err, string(output)) + goBuildServer := exec.CommandContext(buildCtx, "go", "build", "-o", serverPath, "./fixtures/go-jsonrpc-server") + goBuildServer.Dir = root + if output, err := goBuildServer.CombinedOutput(); err != nil { + return fmt.Errorf("build go server fixture: %w\n%s", err, string(output)) + } + + goBuildProbe := exec.CommandContext(buildCtx, "go", "build", "-o", probePath, "./fixtures/go-probe") + goBuildProbe.Dir = root + if output, err := goBuildProbe.CombinedOutput(); err != nil { + return fmt.Errorf("build go probe fixture: %w\n%s", err, string(output)) } return nil @@ -38,9 +44,10 @@ func buildGoFixtureBinaryOnly() (fixtureBinaries, error) { binaries := fixtureBinaries{ tempDir: tempDir, goServer: filepath.Join(tempDir, executableName("go-jsonrpc-server")), + goProbe: filepath.Join(tempDir, executableName("go-probe")), } - if err := buildGoFixture(root, binaries.goServer); err != nil { + if err := buildGoFixture(root, binaries.goServer, binaries.goProbe); err != nil { _ = os.RemoveAll(tempDir) return fixtureBinaries{}, err } From 465b82199a504d38060f747ecafab92f30ed5ca8 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 13:24:20 +0100 Subject: [PATCH 09/18] refactor(a2a): replace all fixture pre-build steps with toolchain run commands All four SDKs now use their toolchain's run command instead of a pre-build step: go: go run ./fixtures/go-jsonrpc-server / go run ./fixtures/go-probe rust: cargo run --bin interop-rust-server / cargo run --bin interop-rust-probe python: uv run interop_python_server.py / uv run interop_python_probe.py dotnet: dotnet run --project InteropServer / dotnet run --project InteropProbe externalProbeClient is redesigned around a basecmd []string field (the prefix needed to invoke the probe) and a call(ctx, args...) method that appends --card-url and the subcommand args. makeProbeInvoker and the probeInvoker type are removed. Removes: - buildPythonFixtureAssets (uv sync step) - buildDotNetFixtures, buildDotNetFixtureBinaryOnly - dotNetFixtureBinaries struct, pythonFixtureAssets struct - oncePython/cachedPython, onceDotNet/cachedDotNet sync.Once caches - getPythonAssets, getDotNetBinaries getter functions - getBinaries/getAssets parameters from all server factory functions - AfterSuite (no more temp dirs to clean up) - buildTimeout, probeTimeout constants (no longer used) - removeTempDir helper startPythonFixture / startDotNetFixture now resolve their commands inline via resolveUvCommand() / resolveDotNetCommand(). Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_launchers_test.go | 266 +++--------------- .../tests/interop_probe_clients_test.go | 145 +++++----- .../agntcy-a2a/tests/interop_shared_test.go | 49 ---- .../agntcy-a2a/tests/interop_table_test.go | 71 +---- .../tests/native_go_launchers_test.go | 70 +---- 5 files changed, 137 insertions(+), 464 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_launchers_test.go b/integrations/agntcy-a2a/tests/interop_launchers_test.go index 612aba54..4b8fd3ba 100644 --- a/integrations/agntcy-a2a/tests/interop_launchers_test.go +++ b/integrations/agntcy-a2a/tests/interop_launchers_test.go @@ -3,10 +3,10 @@ package tests -// This file builds non-Go fixture assets, launches shared transport processes, -// and provides fixtureServer factory functions for Rust, .NET, and Python. -// Extend this file for shared process orchestration and non-Go external runtime support. -// The native Go fixture path lives in native_go_launchers_test.go. +// This file launches shared transport processes and provides fixtureServer factory +// functions for Rust, .NET, and Python. All fixtures use their toolchain's run +// command (go run / cargo run / uv run / dotnet run) so no explicit build step +// is required. The native Go fixture path lives in native_go_launchers_test.go. import ( "context" @@ -17,75 +17,6 @@ import ( "path/filepath" ) -func buildRustFixtures(root string, targetDir string) error { - buildCtx, cancel := context.WithTimeout(context.Background(), buildTimeout) - defer cancel() - - rustBuild := exec.CommandContext( - buildCtx, - "cargo", - "build", - "--manifest-path", - filepath.Join(root, "fixtures", "rust", "Cargo.toml"), - "--bins", - "--target-dir", - targetDir, - ) - rustBuild.Dir = root - if output, err := rustBuild.CombinedOutput(); err != nil { - return fmt.Errorf("build rust fixtures: %w\n%s", err, string(output)) - } - - return nil -} - -func buildRustFixtureBinaryOnly() (fixtureBinaries, error) { - root := componentRoot() - tempDir, err := os.MkdirTemp("", "agntcy-a2a-rust-") - if err != nil { - return fixtureBinaries{}, fmt.Errorf("create temp dir: %w", err) - } - - binaries := fixtureBinaries{ - tempDir: tempDir, - rustServer: filepath.Join(tempDir, "cargo-target", "debug", executableName("interop-rust-server")), - rustProbe: filepath.Join(tempDir, "cargo-target", "debug", executableName("interop-rust-probe")), - } - - if err := buildRustFixtures(root, filepath.Join(tempDir, "cargo-target")); err != nil { - _ = os.RemoveAll(tempDir) - return fixtureBinaries{}, err - } - - return binaries, nil -} - -func buildFixtureBinaries() (fixtureBinaries, error) { - root := componentRoot() - tempDir, err := os.MkdirTemp("", "agntcy-a2a-binaries-") - if err != nil { - return fixtureBinaries{}, fmt.Errorf("create temp dir: %w", err) - } - - binaries := fixtureBinaries{ - tempDir: tempDir, - goServer: filepath.Join(tempDir, executableName("go-jsonrpc-server")), - goProbe: filepath.Join(tempDir, executableName("go-probe")), - rustServer: filepath.Join(tempDir, "cargo-target", "debug", executableName("interop-rust-server")), - rustProbe: filepath.Join(tempDir, "cargo-target", "debug", executableName("interop-rust-probe")), - } - if err := buildGoFixture(root, binaries.goServer, binaries.goProbe); err != nil { - _ = os.RemoveAll(tempDir) - return fixtureBinaries{}, err - } - if err := buildRustFixtures(root, filepath.Join(tempDir, "cargo-target")); err != nil { - _ = os.RemoveAll(tempDir) - return fixtureBinaries{}, err - } - - return binaries, nil -} - func resolveUvCommand() (string, error) { if configured := os.Getenv("UV"); configured != "" { return configured, nil @@ -99,33 +30,6 @@ func resolveUvCommand() (string, error) { return path, nil } -func buildPythonFixtureAssets() (pythonFixtureAssets, error) { - root := componentRoot() - - uvCommand, err := resolveUvCommand() - if err != nil { - return pythonFixtureAssets{}, err - } - - fixtureDir := filepath.Join(root, "fixtures", "python") - - buildCtx, cancel := context.WithTimeout(context.Background(), 2*buildTimeout) - defer cancel() - - syncCmd := exec.CommandContext(buildCtx, uvCommand, "sync") - syncCmd.Dir = fixtureDir - if output, err := syncCmd.CombinedOutput(); err != nil { - return pythonFixtureAssets{}, fmt.Errorf("uv sync python fixture: %w\n%s", err, string(output)) - } - - return pythonFixtureAssets{ - uvCommand: uvCommand, - fixtureDir: fixtureDir, - serverScript: filepath.Join(fixtureDir, "interop_python_server.py"), - probeScript: filepath.Join(fixtureDir, "interop_python_probe.py"), - }, nil -} - func resolveDotNetCommand() (string, error) { if configured := os.Getenv("DOTNET"); configured != "" { return configured, nil @@ -155,105 +59,6 @@ func resolveDotNetCommand() (string, error) { return "", errors.New("dotnet executable not found; install the .NET 8 SDK or set DOTNET to the dotnet CLI path") } -func buildDotNetFixtures(root string, dotnetCommand string, tempDir string) (dotNetFixtureBinaries, error) { - binaries := dotNetFixtureBinaries{ - tempDir: tempDir, - dotnetCommand: dotnetCommand, - dotnetServerDL: filepath.Join(tempDir, "dotnet-server", "InteropServer.dll"), - dotnetProbeDL: filepath.Join(tempDir, "dotnet-probe", "InteropProbe.dll"), - } - - buildCtx, cancel := context.WithTimeout(context.Background(), buildTimeout) - defer cancel() - - dotnetServerBuild := exec.CommandContext( - buildCtx, - binaries.dotnetCommand, - "publish", - "./fixtures/dotnet/InteropServer/InteropServer.csproj", - "-c", - "Release", - "-o", - filepath.Join(tempDir, "dotnet-server"), - "-p:UseAppHost=false", - ) - dotnetServerBuild.Dir = root - if output, err := dotnetServerBuild.CombinedOutput(); err != nil { - return dotNetFixtureBinaries{}, fmt.Errorf("build dotnet server fixture: %w\n%s", err, string(output)) - } - - dotnetProbeBuild := exec.CommandContext( - buildCtx, - binaries.dotnetCommand, - "publish", - "./fixtures/dotnet/InteropProbe/InteropProbe.csproj", - "-c", - "Release", - "-o", - filepath.Join(tempDir, "dotnet-probe"), - "-p:UseAppHost=false", - ) - dotnetProbeBuild.Dir = root - if output, err := dotnetProbeBuild.CombinedOutput(); err != nil { - return dotNetFixtureBinaries{}, fmt.Errorf("build dotnet probe: %w\n%s", err, string(output)) - } - - return binaries, nil -} - -func buildDotNetFixtureBinaryOnly() (dotNetFixtureBinaries, error) { - root := componentRoot() - dotnetCommand, err := resolveDotNetCommand() - if err != nil { - return dotNetFixtureBinaries{}, err - } - - tempDir, err := os.MkdirTemp("", "agntcy-a2a-dotnet-") - if err != nil { - return dotNetFixtureBinaries{}, fmt.Errorf("create temp dir: %w", err) - } - - binaries, err := buildDotNetFixtures(root, dotnetCommand, tempDir) - if err != nil { - _ = os.RemoveAll(tempDir) - return dotNetFixtureBinaries{}, err - } - - return binaries, nil -} - -func buildRustDotNetFixtureBinaries() (rustDotNetFixtureBinaries, error) { - root := componentRoot() - dotnetCommand, err := resolveDotNetCommand() - if err != nil { - return rustDotNetFixtureBinaries{}, err - } - - tempDir, err := os.MkdirTemp("", "agntcy-a2a-rust-dotnet-") - if err != nil { - return rustDotNetFixtureBinaries{}, fmt.Errorf("create temp dir: %w", err) - } - - binaries := rustDotNetFixtureBinaries{ - rustServer: filepath.Join(tempDir, "cargo-target", "debug", executableName("interop-rust-server")), - rustProbe: filepath.Join(tempDir, "cargo-target", "debug", executableName("interop-rust-probe")), - } - - if err := buildRustFixtures(root, filepath.Join(tempDir, "cargo-target")); err != nil { - _ = os.RemoveAll(tempDir) - return rustDotNetFixtureBinaries{}, err - } - - dotNetAssets, err := buildDotNetFixtures(root, dotnetCommand, tempDir) - if err != nil { - _ = os.RemoveAll(tempDir) - return rustDotNetFixtureBinaries{}, err - } - binaries.dotNetFixtureBinaries = dotNetAssets - - return binaries, nil -} - func startFixtureProcess(name string, dir string, readyURL string, command string, args ...string) (*fixtureProcess, error) { ctx, cancel := context.WithCancel(context.Background()) cmd := exec.CommandContext(ctx, command, args...) @@ -300,7 +105,7 @@ func protocolFixtureArgs(port int, protocol transportProtocol) ([]string, string return args, grpcAddress } -func startNativeFixture(name string, command string, port int, protocol transportProtocol) (*fixtureProcess, string, error) { +func startNativeFixture(name string, port int, protocol transportProtocol, command string, commandArgs ...string) (*fixtureProcess, string, error) { baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) args, grpcAddress := protocolFixtureArgs(port, protocol) process, err := startFixtureProcess( @@ -308,7 +113,7 @@ func startNativeFixture(name string, command string, port int, protocol transpor componentRoot(), baseURL+"/.well-known/agent-card.json", command, - args..., + append(commandArgs, args...)..., ) if err != nil { return nil, "", err @@ -323,36 +128,49 @@ func startNativeFixture(name string, command string, port int, protocol transpor return process, baseURL, nil } -func startRustFixture(binaries fixtureBinaries, port int, protocol transportProtocol) (*fixtureProcess, string, error) { - return startNativeFixture(fmt.Sprintf("rust-%s-server", protocol), binaries.rustServer, port, protocol) +func startRustFixture(port int, protocol transportProtocol) (*fixtureProcess, string, error) { + return startNativeFixture( + fmt.Sprintf("rust-%s-server", protocol), + port, + protocol, + "cargo", "run", + "--manifest-path", "fixtures/rust/Cargo.toml", + "--bin", "interop-rust-server", + "--", + ) } -func startDotNetFixture(binaries dotNetFixtureBinaries, port int, protocol transportProtocol) (*fixtureProcess, string, error) { - baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) - args, _ := protocolFixtureArgs(port, protocol) - process, err := startFixtureProcess( +func startDotNetFixture(port int, protocol transportProtocol) (*fixtureProcess, string, error) { + dotnetCmd, err := resolveDotNetCommand() + if err != nil { + return nil, "", err + } + return startNativeFixture( fmt.Sprintf("dotnet-%s-server", protocol), - componentRoot(), - baseURL+"/.well-known/agent-card.json", - binaries.dotnetCommand, - append([]string{binaries.dotnetServerDL}, args...)..., + port, + protocol, + dotnetCmd, "run", + "--project", "./fixtures/dotnet/InteropServer", + "--", ) +} + +func startPythonFixture(port int, protocol transportProtocol) (*fixtureProcess, string, error) { + uvCmd, err := resolveUvCommand() if err != nil { return nil, "", err } - return process, baseURL, nil -} - -func startPythonFixture(assets pythonFixtureAssets, port int, protocol transportProtocol) (*fixtureProcess, string, error) { + fixtureDir := filepath.Join(componentRoot(), "fixtures", "python") baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) args, grpcAddress := protocolFixtureArgs(port, protocol) + process, err := startFixtureProcess( fmt.Sprintf("python-%s-server", protocol), - assets.fixtureDir, + fixtureDir, baseURL+"/.well-known/agent-card.json", - assets.uvCommand, - append([]string{"run", assets.serverScript}, args...)..., + uvCmd, + append([]string{"run", "interop_python_server.py"}, args...)..., ) if err != nil { return nil, "", err @@ -368,7 +186,7 @@ func startPythonFixture(assets pythonFixtureAssets, port int, protocol transport } // newRustServer returns a fixtureServer that starts a Rust server for each of the given protocols. -func newRustServer(getBinaries func() fixtureBinaries, expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { +func newRustServer(expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { s := &fixtureServer{ serverPrefix: "rust", expectPushSupported: expectPushSupported, @@ -380,7 +198,7 @@ func newRustServer(getBinaries func() fixtureBinaries, expectPushSupported bool, label: "rust", protocol: p, start: func() (*fixtureProcess, string, error) { - return startRustFixture(getBinaries(), findFreePort(), p) + return startRustFixture(findFreePort(), p) }, }) } @@ -388,7 +206,7 @@ func newRustServer(getBinaries func() fixtureBinaries, expectPushSupported bool, } // newDotNetServer returns a fixtureServer that starts a .NET server for each of the given protocols. -func newDotNetServer(getBinaries func() dotNetFixtureBinaries, expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { +func newDotNetServer(expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { s := &fixtureServer{ serverPrefix: "dotnet", expectPushSupported: expectPushSupported, @@ -400,7 +218,7 @@ func newDotNetServer(getBinaries func() dotNetFixtureBinaries, expectPushSupport label: "dotnet", protocol: p, start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(getBinaries(), findFreePort(), p) + return startDotNetFixture(findFreePort(), p) }, }) } @@ -408,7 +226,7 @@ func newDotNetServer(getBinaries func() dotNetFixtureBinaries, expectPushSupport } // newPythonServer returns a fixtureServer that starts a Python server for each of the given protocols. -func newPythonServer(getAssets func() pythonFixtureAssets, expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { +func newPythonServer(expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { s := &fixtureServer{ serverPrefix: "python", expectPushSupported: expectPushSupported, @@ -420,7 +238,7 @@ func newPythonServer(getAssets func() pythonFixtureAssets, expectPushSupported b label: "python", protocol: p, start: func() (*fixtureProcess, string, error) { - return startPythonFixture(getAssets(), findFreePort(), p) + return startPythonFixture(findFreePort(), p) }, }) } diff --git a/integrations/agntcy-a2a/tests/interop_probe_clients_test.go b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go index 98cfb5dd..ecc3d717 100644 --- a/integrations/agntcy-a2a/tests/interop_probe_clients_test.go +++ b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go @@ -3,10 +3,11 @@ package tests -// This file implements the external language probe clients: Rust, Python, and .NET. -// Each client invokes the corresponding probe binary as a subprocess with the new -// subcommand CLI, deserializes the JSON response from stdout, and converts stderr -// error JSON to a Go error on non-zero exit. +// This file implements the external language probe clients: Go, Rust, Python, and .NET. +// Each client stores a basecmd (the command needed to invoke the probe binary) and a +// baseURL. The call() method appends --card-url and the per-operation args, then +// runs the command and returns stdout/stderr. All probe CLIs share the same subcommand +// interface, so no per-language serialization logic is needed beyond what call() does. import ( "bufio" @@ -15,6 +16,7 @@ import ( "encoding/json" "fmt" "os/exec" + "path/filepath" "strings" "github.com/a2aproject/a2a-go/v2/a2a" @@ -41,13 +43,35 @@ func parseProbeError(stderr []byte) error { return fmt.Errorf("probe exited with non-zero status") } -// probeInvoker is a function that runs the probe binary and returns its stdout/stderr. -type probeInvoker func(ctx context.Context, args ...string) (stdout []byte, stderr []byte, err error) - -// externalProbeClient dispatches A2A operations to an external language probe binary. +// externalProbeClient dispatches A2A operations to an external language probe. +// basecmd is the full command prefix to invoke the probe binary +// (e.g., ["go", "run", "./fixtures/go-probe"]). +// call() appends "--card-url " and the subcommand args before executing. type externalProbeClient struct { + basecmd []string baseURL string - invoke probeInvoker + dir string // working directory; empty means componentRoot() +} + +func (c *externalProbeClient) call(ctx context.Context, args ...string) (stdout []byte, stderr []byte, err error) { + cmdArgs := make([]string, 0, len(c.basecmd)+2+len(args)) + cmdArgs = append(cmdArgs, c.basecmd[1:]...) + cmdArgs = append(cmdArgs, "--card-url", c.baseURL) + cmdArgs = append(cmdArgs, args...) + + cmd := exec.CommandContext(ctx, c.basecmd[0], cmdArgs...) + dir := c.dir + if dir == "" { + dir = componentRoot() + } + cmd.Dir = dir + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err = cmd.Run() + return stdoutBuf.Bytes(), stderrBuf.Bytes(), err } // probeMsgEnvelope wraps a send-message result from the probe. @@ -65,7 +89,7 @@ func (c *externalProbeClient) SendMessage(ctx context.Context, req *a2a.SendMess return nil, fmt.Errorf("marshal send-message request: %w", err) } - stdout, stderr, err := c.invoke(ctx, "send-message", "--message-json", string(reqJSON)) + stdout, stderr, err := c.call(ctx, "send-message", "--message-json", string(reqJSON)) if err != nil { return nil, parseProbeError(stderr) } @@ -99,7 +123,7 @@ func (c *externalProbeClient) SendStreamingMessage(ctx context.Context, req *a2a return nil, fmt.Errorf("marshal send-streaming-message request: %w", err) } - stdout, stderr, err := c.invoke(ctx, "send-streaming-message", "--message-json", string(reqJSON)) + stdout, stderr, err := c.call(ctx, "send-streaming-message", "--message-json", string(reqJSON)) if err != nil { return nil, parseProbeError(stderr) } @@ -162,7 +186,7 @@ func parseStreamEvent(env probeEventEnvelope) (a2a.Event, error) { } func (c *externalProbeClient) GetTask(ctx context.Context, req *a2a.GetTaskRequest) (*a2a.Task, error) { - stdout, stderr, err := c.invoke(ctx, "get-task", "--task-id", string(req.ID)) + stdout, stderr, err := c.call(ctx, "get-task", "--task-id", string(req.ID)) if err != nil { return nil, parseProbeError(stderr) } @@ -175,7 +199,7 @@ func (c *externalProbeClient) GetTask(ctx context.Context, req *a2a.GetTaskReque } func (c *externalProbeClient) CancelTask(ctx context.Context, req *a2a.CancelTaskRequest) (*a2a.Task, error) { - stdout, stderr, err := c.invoke(ctx, "cancel-task", "--task-id", string(req.ID)) + stdout, stderr, err := c.call(ctx, "cancel-task", "--task-id", string(req.ID)) if err != nil { return nil, parseProbeError(stderr) } @@ -188,7 +212,7 @@ func (c *externalProbeClient) CancelTask(ctx context.Context, req *a2a.CancelTas } func (c *externalProbeClient) ListTasks(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) { - stdout, stderr, err := c.invoke(ctx, "list-tasks", "--context-id", req.ContextID) + stdout, stderr, err := c.call(ctx, "list-tasks", "--context-id", req.ContextID) if err != nil { return nil, parseProbeError(stderr) } @@ -206,7 +230,7 @@ func (c *externalProbeClient) CreatePushConfig(ctx context.Context, cfg *a2a.Pus return nil, fmt.Errorf("marshal push config: %w", err) } - stdout, stderr, err := c.invoke(ctx, "create-push-config", "--config-json", string(cfgJSON)) + stdout, stderr, err := c.call(ctx, "create-push-config", "--config-json", string(cfgJSON)) if err != nil { return nil, parseProbeError(stderr) } @@ -219,7 +243,7 @@ func (c *externalProbeClient) CreatePushConfig(ctx context.Context, cfg *a2a.Pus } func (c *externalProbeClient) GetPushConfig(ctx context.Context, req *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) { - stdout, stderr, err := c.invoke(ctx, "get-push-config", + stdout, stderr, err := c.call(ctx, "get-push-config", "--task-id", string(req.TaskID), "--config-id", req.ID, ) @@ -235,7 +259,7 @@ func (c *externalProbeClient) GetPushConfig(ctx context.Context, req *a2a.GetTas } func (c *externalProbeClient) ListPushConfigs(ctx context.Context, req *a2a.ListTaskPushConfigRequest) ([]*a2a.PushConfig, error) { - stdout, stderr, err := c.invoke(ctx, "list-push-configs", "--task-id", string(req.TaskID)) + stdout, stderr, err := c.call(ctx, "list-push-configs", "--task-id", string(req.TaskID)) if err != nil { return nil, parseProbeError(stderr) } @@ -248,7 +272,7 @@ func (c *externalProbeClient) ListPushConfigs(ctx context.Context, req *a2a.List } func (c *externalProbeClient) DeletePushConfig(ctx context.Context, req *a2a.DeleteTaskPushConfigRequest) error { - _, stderr, err := c.invoke(ctx, "delete-push-config", + _, stderr, err := c.call(ctx, "delete-push-config", "--task-id", string(req.TaskID), "--config-id", req.ID, ) @@ -259,7 +283,7 @@ func (c *externalProbeClient) DeletePushConfig(ctx context.Context, req *a2a.Del } func (c *externalProbeClient) GetExtendedCard(ctx context.Context, req *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) { - stdout, stderr, err := c.invoke(ctx, "get-extended-card") + stdout, stderr, err := c.call(ctx, "get-extended-card") if err != nil { return nil, parseProbeError(stderr) } @@ -271,75 +295,48 @@ func (c *externalProbeClient) GetExtendedCard(ctx context.Context, req *a2a.GetE return &card, nil } -func makeProbeInvoker(binary string, globalArgs ...string) probeInvoker { - return func(ctx context.Context, args ...string) ([]byte, []byte, error) { - allArgs := append(globalArgs, args...) - cmd := exec.CommandContext(ctx, binary, allArgs...) - cmd.Dir = componentRoot() - - var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuf - cmd.Stderr = &stderrBuf - - err := cmd.Run() - return stdoutBuf.Bytes(), stderrBuf.Bytes(), err - } -} - -func newGoProbeClient(getBinaries func() fixtureBinaries, baseURL string) probeClient { +func newGoProbeClient(baseURL string) probeClient { return &externalProbeClient{ + basecmd: []string{"go", "run", "./fixtures/go-probe"}, baseURL: baseURL, - invoke: func(ctx context.Context, args ...string) ([]byte, []byte, error) { - invoke := makeProbeInvoker(getBinaries().goProbe, "--card-url", baseURL) - return invoke(ctx, args...) - }, } } -func newRustProbeClient(getBinaries func() fixtureBinaries, baseURL string) probeClient { +func newRustProbeClient(baseURL string) probeClient { return &externalProbeClient{ - baseURL: baseURL, - invoke: func(ctx context.Context, args ...string) ([]byte, []byte, error) { - invoke := makeProbeInvoker(getBinaries().rustProbe, "--card-url", baseURL) - return invoke(ctx, args...) + basecmd: []string{ + "cargo", "run", + "--manifest-path", "fixtures/rust/Cargo.toml", + "--bin", "interop-rust-probe", + "--", }, + baseURL: baseURL, } } -func newPythonProbeClient(getAssets func() pythonFixtureAssets, baseURL string) probeClient { +func newPythonProbeClient(baseURL string) (probeClient, error) { + uvCmd, err := resolveUvCommand() + if err != nil { + return nil, err + } return &externalProbeClient{ + basecmd: []string{uvCmd, "run", "interop_python_probe.py"}, baseURL: baseURL, - invoke: func(ctx context.Context, args ...string) ([]byte, []byte, error) { - assets := getAssets() - allArgs := append([]string{"run", assets.probeScript, "--card-url", baseURL}, args...) - cmd := exec.CommandContext(ctx, assets.uvCommand, allArgs...) - cmd.Dir = assets.fixtureDir - - var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuf - cmd.Stderr = &stderrBuf - - err := cmd.Run() - return stdoutBuf.Bytes(), stderrBuf.Bytes(), err - }, - } + dir: filepath.Join(componentRoot(), "fixtures", "python"), + }, nil } -func newDotNetProbeClient(getBinaries func() dotNetFixtureBinaries, baseURL string) probeClient { +func newDotNetProbeClient(baseURL string) (probeClient, error) { + dotnetCmd, err := resolveDotNetCommand() + if err != nil { + return nil, err + } return &externalProbeClient{ - baseURL: baseURL, - invoke: func(ctx context.Context, args ...string) ([]byte, []byte, error) { - binaries := getBinaries() - allArgs := append([]string{binaries.dotnetProbeDL, "--card-url", baseURL}, args...) - cmd := exec.CommandContext(ctx, binaries.dotnetCommand, allArgs...) - cmd.Dir = componentRoot() - - var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuf - cmd.Stderr = &stderrBuf - - err := cmd.Run() - return stdoutBuf.Bytes(), stderrBuf.Bytes(), err + basecmd: []string{ + dotnetCmd, "run", + "--project", "./fixtures/dotnet/InteropProbe", + "--", }, - } + baseURL: baseURL, + }, nil } diff --git a/integrations/agntcy-a2a/tests/interop_shared_test.go b/integrations/agntcy-a2a/tests/interop_shared_test.go index 61062f84..afcd6eb4 100644 --- a/integrations/agntcy-a2a/tests/interop_shared_test.go +++ b/integrations/agntcy-a2a/tests/interop_shared_test.go @@ -17,7 +17,6 @@ import ( "fmt" "net" "net/http" - "os" "os/exec" "path/filepath" "runtime" @@ -30,8 +29,6 @@ import ( const ( fixtureReadyTimeout = 20 * time.Second - probeTimeout = 2 * time.Minute - buildTimeout = 3 * time.Minute stopTimeout = 5 * time.Second requestText = "ping" pendingRequestText = "pending" @@ -67,45 +64,6 @@ type interopTarget struct { expectPushSupported bool } -type fixtureBinaries struct { - tempDir string - goServer string - goProbe string - rustServer string - rustProbe string -} - -type dotNetFixtureBinaries struct { - tempDir string - dotnetCommand string - dotnetServerDL string - dotnetProbeDL string -} - -type rustDotNetFixtureBinaries struct { - dotNetFixtureBinaries - rustServer string - rustProbe string -} - -type pythonFixtureAssets struct { - uvCommand string - fixtureDir string - serverScript string - probeScript string -} - -func (binaries rustDotNetFixtureBinaries) dotNetAssets() dotNetFixtureBinaries { - return binaries.dotNetFixtureBinaries -} - -func (binaries rustDotNetFixtureBinaries) rustAssets() fixtureBinaries { - return fixtureBinaries{ - tempDir: binaries.tempDir, - rustServer: binaries.rustServer, - rustProbe: binaries.rustProbe, - } -} type lockedBuffer struct { mu sync.Mutex @@ -241,13 +199,6 @@ func stopFixtureIfRunning(process *fixtureProcess) { gomega.Expect(process.stop()).To(gomega.Succeed(), process.logs.String()) } -func removeTempDir(path string) { - if path == "" { - return - } - - gomega.Expect(os.RemoveAll(path)).To(gomega.Succeed()) -} type interopSuiteFixtureSpec struct { label string diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index a1de4ba3..5f605827 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -35,81 +35,30 @@ package tests // When either the client or the server SDK does not support gRPC, the gRPC // BeforeAll calls Skip() so the specs appear as skipped rather than absent. // -// Each SDK's fixture assets are built at most once per test run via package-level -// sync.Once caches. An AfterSuite handler removes the temp directories. +// All fixtures use their toolchain's run command (go run / cargo run / uv run / +// dotnet run) so no pre-build step or asset cache is needed. import ( "context" - "sync" . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) -// ── per-SDK asset caches ────────────────────────────────────────────────────── - -var ( - onceGo sync.Once - cachedGo fixtureBinaries - errGo error - - onceRust sync.Once - cachedRust fixtureBinaries - errRust error - - onceDotNet sync.Once - cachedDotNet dotNetFixtureBinaries - errDotNet error - - oncePython sync.Once - cachedPython pythonFixtureAssets - errPython error -) - -func getGoBinaries() fixtureBinaries { - onceGo.Do(func() { cachedGo, errGo = buildGoFixtureBinaryOnly() }) - gomega.Expect(errGo).NotTo(gomega.HaveOccurred(), "build Go fixtures") - return cachedGo -} - -func getRustBinaries() fixtureBinaries { - onceRust.Do(func() { cachedRust, errRust = buildRustFixtureBinaryOnly() }) - gomega.Expect(errRust).NotTo(gomega.HaveOccurred(), "build Rust fixtures") - return cachedRust -} - -func getDotNetBinaries() dotNetFixtureBinaries { - onceDotNet.Do(func() { cachedDotNet, errDotNet = buildDotNetFixtureBinaryOnly() }) - gomega.Expect(errDotNet).NotTo(gomega.HaveOccurred(), "build .NET fixtures") - return cachedDotNet -} - -func getPythonAssets() pythonFixtureAssets { - oncePython.Do(func() { cachedPython, errPython = buildPythonFixtureAssets() }) - gomega.Expect(errPython).NotTo(gomega.HaveOccurred(), "build Python fixtures") - return cachedPython -} - -var _ = AfterSuite(func() { - removeTempDir(cachedGo.tempDir) - removeTempDir(cachedRust.tempDir) - removeTempDir(cachedDotNet.tempDir) -}) - // ── client factories ────────────────────────────────────────────────────────── var ( goClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { - return newGoProbeClient(getGoBinaries, url), nil + return newGoProbeClient(url), nil } rustClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { - return newRustProbeClient(getRustBinaries, url), nil + return newRustProbeClient(url), nil } dotNetClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { - return newDotNetProbeClient(getDotNetBinaries, url), nil + return newDotNetProbeClient(url) } pythonClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { - return newPythonProbeClient(getPythonAssets, url), nil + return newPythonProbeClient(url) } ) @@ -121,16 +70,16 @@ type serverMaker func(protocol transportProtocol) *fixtureServer var ( goServerMaker serverMaker = func(p transportProtocol) *fixtureServer { - return newGoServer(getGoBinaries, true, p) + return newGoServer(true, p) } rustServerMaker serverMaker = func(p transportProtocol) *fixtureServer { - return newRustServer(getRustBinaries, true, p) + return newRustServer(true, p) } pythonServerMaker serverMaker = func(p transportProtocol) *fixtureServer { - return newPythonServer(getPythonAssets, true, p) + return newPythonServer(true, p) } dotNetServerMaker serverMaker = func(p transportProtocol) *fixtureServer { - return newDotNetServer(getDotNetBinaries, false, p) + return newDotNetServer(false, p) } ) diff --git a/integrations/agntcy-a2a/tests/native_go_launchers_test.go b/integrations/agntcy-a2a/tests/native_go_launchers_test.go index 11724da0..f7a9498f 100644 --- a/integrations/agntcy-a2a/tests/native_go_launchers_test.go +++ b/integrations/agntcy-a2a/tests/native_go_launchers_test.go @@ -3,64 +3,22 @@ package tests -// This file isolates the native Go fixture build and startup path from the shared launcher -// helpers so the cross-SDK launcher layer stays focused on generic orchestration and the -// test-only native Go startup path is easy to find. - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" -) - -func buildGoFixture(root string, serverPath string, probePath string) error { - buildCtx, cancel := context.WithTimeout(context.Background(), buildTimeout) - defer cancel() - - goBuildServer := exec.CommandContext(buildCtx, "go", "build", "-o", serverPath, "./fixtures/go-jsonrpc-server") - goBuildServer.Dir = root - if output, err := goBuildServer.CombinedOutput(); err != nil { - return fmt.Errorf("build go server fixture: %w\n%s", err, string(output)) - } - - goBuildProbe := exec.CommandContext(buildCtx, "go", "build", "-o", probePath, "./fixtures/go-probe") - goBuildProbe.Dir = root - if output, err := goBuildProbe.CombinedOutput(); err != nil { - return fmt.Errorf("build go probe fixture: %w\n%s", err, string(output)) - } - - return nil -} - -func buildGoFixtureBinaryOnly() (fixtureBinaries, error) { - root := componentRoot() - tempDir, err := os.MkdirTemp("", "agntcy-a2a-go-") - if err != nil { - return fixtureBinaries{}, fmt.Errorf("create temp dir: %w", err) - } - - binaries := fixtureBinaries{ - tempDir: tempDir, - goServer: filepath.Join(tempDir, executableName("go-jsonrpc-server")), - goProbe: filepath.Join(tempDir, executableName("go-probe")), - } - - if err := buildGoFixture(root, binaries.goServer, binaries.goProbe); err != nil { - _ = os.RemoveAll(tempDir) - return fixtureBinaries{}, err - } - - return binaries, nil -} - -func startGoFixture(binaries fixtureBinaries, port int, protocol transportProtocol) (*fixtureProcess, string, error) { - return startNativeFixture(fmt.Sprintf("go-%s-server", protocol), binaries.goServer, port, protocol) +// This file isolates the native Go fixture startup path from the shared launcher +// helpers so the cross-SDK launcher layer stays focused on generic orchestration. + +import "fmt" + +func startGoFixture(port int, protocol transportProtocol) (*fixtureProcess, string, error) { + return startNativeFixture( + fmt.Sprintf("go-%s-server", protocol), + port, + protocol, + "go", "run", "./fixtures/go-jsonrpc-server", + ) } // newGoServer returns a fixtureServer that starts a Go server for each of the given protocols. -func newGoServer(getBinaries func() fixtureBinaries, expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { +func newGoServer(expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { s := &fixtureServer{ serverPrefix: "go", expectPushSupported: expectPushSupported, @@ -72,7 +30,7 @@ func newGoServer(getBinaries func() fixtureBinaries, expectPushSupported bool, p label: "go", protocol: p, start: func() (*fixtureProcess, string, error) { - return startGoFixture(getBinaries(), findFreePort(), p) + return startGoFixture(findFreePort(), p) }, }) } From 76444ceef761056eb46e0dca90f90392a84c02f4 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 14:01:04 +0100 Subject: [PATCH 10/18] refactor(a2a): remove fixtureServer abstraction, use startXYZFixture directly Each startGoFixture/startRustFixture/startPythonFixture/startDotNetFixture now accepts only a protocol and calls findFreePort() internally, returning (*fixtureProcess, string, error). The fixtureServer struct, interopSuiteRuntime, interopSuiteFixtureSpec, and all associated orchestration helpers are removed. The DescribeTableSubtree server-level body manages the fixtureProcess and URL directly via BeforeAll/AfterAll, using the new serverStarter type. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_launchers_test.go | 71 ++------------ .../agntcy-a2a/tests/interop_shared_test.go | 97 +------------------ .../agntcy-a2a/tests/interop_table_test.go | 94 ++++++++---------- .../tests/native_go_launchers_test.go | 24 +---- 4 files changed, 52 insertions(+), 234 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_launchers_test.go b/integrations/agntcy-a2a/tests/interop_launchers_test.go index 4b8fd3ba..dc6db148 100644 --- a/integrations/agntcy-a2a/tests/interop_launchers_test.go +++ b/integrations/agntcy-a2a/tests/interop_launchers_test.go @@ -128,10 +128,10 @@ func startNativeFixture(name string, port int, protocol transportProtocol, comma return process, baseURL, nil } -func startRustFixture(port int, protocol transportProtocol) (*fixtureProcess, string, error) { +func startRustFixture(protocol transportProtocol) (*fixtureProcess, string, error) { return startNativeFixture( fmt.Sprintf("rust-%s-server", protocol), - port, + findFreePort(), protocol, "cargo", "run", "--manifest-path", "fixtures/rust/Cargo.toml", @@ -140,14 +140,14 @@ func startRustFixture(port int, protocol transportProtocol) (*fixtureProcess, st ) } -func startDotNetFixture(port int, protocol transportProtocol) (*fixtureProcess, string, error) { +func startDotNetFixture(protocol transportProtocol) (*fixtureProcess, string, error) { dotnetCmd, err := resolveDotNetCommand() if err != nil { return nil, "", err } return startNativeFixture( fmt.Sprintf("dotnet-%s-server", protocol), - port, + findFreePort(), protocol, dotnetCmd, "run", "--project", "./fixtures/dotnet/InteropServer", @@ -155,12 +155,13 @@ func startDotNetFixture(port int, protocol transportProtocol) (*fixtureProcess, ) } -func startPythonFixture(port int, protocol transportProtocol) (*fixtureProcess, string, error) { +func startPythonFixture(protocol transportProtocol) (*fixtureProcess, string, error) { uvCmd, err := resolveUvCommand() if err != nil { return nil, "", err } + port := findFreePort() fixtureDir := filepath.Join(componentRoot(), "fixtures", "python") baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) args, grpcAddress := protocolFixtureArgs(port, protocol) @@ -184,63 +185,3 @@ func startPythonFixture(port int, protocol transportProtocol) (*fixtureProcess, return process, baseURL, nil } - -// newRustServer returns a fixtureServer that starts a Rust server for each of the given protocols. -func newRustServer(expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { - s := &fixtureServer{ - serverPrefix: "rust", - expectPushSupported: expectPushSupported, - runtime: newInteropSuiteRuntime(), - } - for _, p := range protocols { - p := p - s.fixtures = append(s.fixtures, interopSuiteFixtureSpec{ - label: "rust", - protocol: p, - start: func() (*fixtureProcess, string, error) { - return startRustFixture(findFreePort(), p) - }, - }) - } - return s -} - -// newDotNetServer returns a fixtureServer that starts a .NET server for each of the given protocols. -func newDotNetServer(expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { - s := &fixtureServer{ - serverPrefix: "dotnet", - expectPushSupported: expectPushSupported, - runtime: newInteropSuiteRuntime(), - } - for _, p := range protocols { - p := p - s.fixtures = append(s.fixtures, interopSuiteFixtureSpec{ - label: "dotnet", - protocol: p, - start: func() (*fixtureProcess, string, error) { - return startDotNetFixture(findFreePort(), p) - }, - }) - } - return s -} - -// newPythonServer returns a fixtureServer that starts a Python server for each of the given protocols. -func newPythonServer(expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { - s := &fixtureServer{ - serverPrefix: "python", - expectPushSupported: expectPushSupported, - runtime: newInteropSuiteRuntime(), - } - for _, p := range protocols { - p := p - s.fixtures = append(s.fixtures, interopSuiteFixtureSpec{ - label: "python", - protocol: p, - start: func() (*fixtureProcess, string, error) { - return startPythonFixture(findFreePort(), p) - }, - }) - } - return s -} diff --git a/integrations/agntcy-a2a/tests/interop_shared_test.go b/integrations/agntcy-a2a/tests/interop_shared_test.go index afcd6eb4..59f284cf 100644 --- a/integrations/agntcy-a2a/tests/interop_shared_test.go +++ b/integrations/agntcy-a2a/tests/interop_shared_test.go @@ -4,11 +4,8 @@ package tests // This file holds the reusable interop test primitives: shared constants, transport -// enums, fixture process types, the interopSuiteRuntime, and the fixtureServer -// abstraction that each suite wrapper uses to manage server lifecycle. -// Add code here only when multiple suites or behaviors need the same low-level plumbing. -// New behavior assertions belong in interop_behaviors_test.go, and suite-specific -// wiring belongs in the suite wrapper files. +// enums, fixture process management, and low-level helpers. +// New behavior assertions belong in interop_behaviors_test.go. import ( "bytes" @@ -199,96 +196,6 @@ func stopFixtureIfRunning(process *fixtureProcess) { gomega.Expect(process.stop()).To(gomega.Succeed(), process.logs.String()) } - -type interopSuiteFixtureSpec struct { - label string - protocol transportProtocol - start func() (*fixtureProcess, string, error) -} - -type interopSuiteRuntime struct { - fixtures map[string]*fixtureProcess - urls map[string]string -} - -func newInteropSuiteRuntime() *interopSuiteRuntime { - return &interopSuiteRuntime{ - fixtures: map[string]*fixtureProcess{}, - urls: map[string]string{}, - } -} - -func interopSuiteFixtureKey(label string, protocol transportProtocol) string { - return label + ":" + string(protocol) -} - -func (runtime *interopSuiteRuntime) setFixture( - label string, - protocol transportProtocol, - process *fixtureProcess, - url string, -) { - key := interopSuiteFixtureKey(label, protocol) - runtime.fixtures[key] = process - runtime.urls[key] = url -} - -func (runtime *interopSuiteRuntime) fixtureURL(label string, protocol transportProtocol) string { - return runtime.urls[interopSuiteFixtureKey(label, protocol)] -} - -func (runtime *interopSuiteRuntime) stopFixtures(fixtures []interopSuiteFixtureSpec) { - for index := len(fixtures) - 1; index >= 0; index-- { - fixture := fixtures[index] - stopFixtureIfRunning(runtime.fixtures[interopSuiteFixtureKey(fixture.label, fixture.protocol)]) - } -} - -func startInteropSuiteFixtures(runtime *interopSuiteRuntime, fixtures []interopSuiteFixtureSpec) error { - started := make([]interopSuiteFixtureSpec, 0, len(fixtures)) - for _, fixture := range fixtures { - process, url, err := fixture.start() - if err != nil { - runtime.stopFixtures(started) - return err - } - - runtime.setFixture(fixture.label, fixture.protocol, process, url) - started = append(started, fixture) - } - - return nil -} - -// fixtureServer manages the lifecycle of a server fixture for a single When block. -type fixtureServer struct { - serverPrefix string - expectPushSupported bool - runtime *interopSuiteRuntime - fixtures []interopSuiteFixtureSpec -} - -func (s *fixtureServer) start() error { - return startInteropSuiteFixtures(s.runtime, s.fixtures) -} - -func (s *fixtureServer) stop() { - s.runtime.stopFixtures(s.fixtures) -} - -// targetFor returns a lazy getter for the interopTarget for a given protocol. -// The returned function is safe to call at spec registration time (expectPushSupported -// is known; baseURL is resolved lazily when the function is actually called in BeforeAll). -func (s *fixtureServer) targetFor(protocol transportProtocol) func() interopTarget { - return func() interopTarget { - return interopTarget{ - baseURL: s.runtime.fixtureURL(s.serverPrefix, protocol), - serverPrefix: s.serverPrefix, - expectPushSupported: s.expectPushSupported, - } - } -} - func expectedServerText(serverPrefix string, text string) string { return fmt.Sprintf("%s server received: %s", serverPrefix, text) } diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index 5f605827..28560390 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -6,31 +6,31 @@ package tests // Three nested DescribeTableSubtrees produce the full (client, server, transport) // interop matrix with minimal repetition: // -// A2A interoperability -// go (client) [CLIENT:go] -// server go [SERVER:go] -// transport JSON-RPC [TRANSPORT:jsonrpc] -// transport REST [TRANSPORT:rest] -// transport gRPC [TRANSPORT:grpc] -// server rust [SERVER:rust] -// ... -// server dotnet [SERVER:dotnet] -// transport JSON-RPC -// transport REST -// transport gRPC ← skipped: dotnet does not support gRPC -// ... -// rust (client) [CLIENT:rust] -// ... -// dotnet (client) [CLIENT:dotnet] -// ... -// transport gRPC ← skipped: dotnet does not support gRPC +// A2A interoperability +// go (client) [CLIENT:go] +// server go [SERVER:go] +// transport JSON-RPC [TRANSPORT:jsonrpc] +// transport REST [TRANSPORT:rest] +// transport gRPC [TRANSPORT:grpc] +// server rust [SERVER:rust] +// ... +// server dotnet [SERVER:dotnet] +// transport JSON-RPC +// transport REST +// transport gRPC ← skipped: dotnet does not support gRPC +// ... +// rust (client) [CLIENT:rust] +// ... +// dotnet (client) [CLIENT:dotnet] +// ... +// transport gRPC ← skipped: dotnet does not support gRPC // // Each spec inherits all three category labels from its ancestor entries, enabling // set-based label filtering: // -// --ginkgo.label-filter='CLIENT: consistsOf {go}' -// --ginkgo.label-filter='CLIENT: containsAny {rust, go} && SERVER: consistsOf {dotnet}' -// --ginkgo.label-filter='TRANSPORT: containsAny {grpc}' +// --ginkgo.label-filter='CLIENT: consistsOf {go}' +// --ginkgo.label-filter='CLIENT: containsAny {rust, go} && SERVER: consistsOf {dotnet}' +// --ginkgo.label-filter='TRANSPORT: containsAny {grpc}' // // When either the client or the server SDK does not support gRPC, the gRPC // BeforeAll calls Skip() so the specs appear as skipped rather than absent. @@ -40,6 +40,7 @@ package tests import ( "context" + "fmt" . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" @@ -62,26 +63,11 @@ var ( } ) -// ── server factory type and factories ───────────────────────────────────────── +// ── server starter type ─────────────────────────────────────────────────────── -// serverMaker creates a fresh fixtureServer for a single transport protocol. -// Called in BeforeAll once the Skip check has passed. -type serverMaker func(protocol transportProtocol) *fixtureServer - -var ( - goServerMaker serverMaker = func(p transportProtocol) *fixtureServer { - return newGoServer(true, p) - } - rustServerMaker serverMaker = func(p transportProtocol) *fixtureServer { - return newRustServer(true, p) - } - pythonServerMaker serverMaker = func(p transportProtocol) *fixtureServer { - return newPythonServer(true, p) - } - dotNetServerMaker serverMaker = func(p transportProtocol) *fixtureServer { - return newDotNetServer(false, p) - } -) +// serverStarter starts a fresh fixture process for a single transport protocol. +// It returns the process handle, the server base URL, and any startup error. +type serverStarter func(protocol transportProtocol) (*fixtureProcess, string, error) // ── interoperability matrix ─────────────────────────────────────────────────── @@ -89,27 +75,31 @@ var _ = DescribeTableSubtree( "A2A interoperability", func(newClient newClientFn, clientGRPC bool) { DescribeTableSubtree("server", - func(makeServer serverMaker, serverGRPC bool, expectPushSupported bool) { + func(startServer serverStarter, serverPrefix string, serverGRPC bool, expectPushSupported bool) { // ContinueOnFailure is not set here: transport entries are nested // inside client entries (which are the outermost Ordered containers) // and inherit ContinueOnFailure behaviour from them. DescribeTableSubtree("transport", func(protocol transportProtocol) { - var srv *fixtureServer + var srv *fixtureProcess + var srvURL string BeforeAll(func() { if protocol == transportGRPC && (!clientGRPC || !serverGRPC) { Skip("gRPC transport is not supported by this client/server combination") } - srv = makeServer(protocol) - gomega.Expect(srv.start()).NotTo(gomega.HaveOccurred()) + var err error + srv, srvURL, err = startServer(protocol) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), fmt.Sprintf("start %s server", serverPrefix)) }) AfterAll(func() { - if srv != nil { - srv.stop() - } + stopFixtureIfRunning(srv) }) registerBehaviors(newClient, func() interopTarget { - return srv.targetFor(protocol)() + return interopTarget{ + baseURL: srvURL, + serverPrefix: serverPrefix, + expectPushSupported: expectPushSupported, + } }, expectPushSupported) }, Entry("JSON-RPC", Ordered, Label("TRANSPORT:jsonrpc"), transportJSONRPC), @@ -119,10 +109,10 @@ var _ = DescribeTableSubtree( }, // ── server entries ──────────────────────────────────────────────── // ContinueOnFailure is inherited from the client entries (outermost Ordered). - Entry("go", Ordered, Label("SERVER:go"), goServerMaker, true, true), - Entry("rust", Ordered, Label("SERVER:rust"), rustServerMaker, true, true), - Entry("python", Ordered, Label("SERVER:python"), pythonServerMaker, true, true), - Entry("dotnet", Ordered, Label("SERVER:dotnet"), dotNetServerMaker, false, false), + Entry("go", Ordered, Label("SERVER:go"), startGoFixture, "go", true, true), + Entry("rust", Ordered, Label("SERVER:rust"), startRustFixture, "rust", true, true), + Entry("python", Ordered, Label("SERVER:python"), startPythonFixture, "python", true, true), + Entry("dotnet", Ordered, Label("SERVER:dotnet"), startDotNetFixture, "dotnet", false, false), ) }, // ── client entries ──────────────────────────────────────────────────────── diff --git a/integrations/agntcy-a2a/tests/native_go_launchers_test.go b/integrations/agntcy-a2a/tests/native_go_launchers_test.go index f7a9498f..d7b40852 100644 --- a/integrations/agntcy-a2a/tests/native_go_launchers_test.go +++ b/integrations/agntcy-a2a/tests/native_go_launchers_test.go @@ -8,31 +8,11 @@ package tests import "fmt" -func startGoFixture(port int, protocol transportProtocol) (*fixtureProcess, string, error) { +func startGoFixture(protocol transportProtocol) (*fixtureProcess, string, error) { return startNativeFixture( fmt.Sprintf("go-%s-server", protocol), - port, + findFreePort(), protocol, "go", "run", "./fixtures/go-jsonrpc-server", ) } - -// newGoServer returns a fixtureServer that starts a Go server for each of the given protocols. -func newGoServer(expectPushSupported bool, protocols ...transportProtocol) *fixtureServer { - s := &fixtureServer{ - serverPrefix: "go", - expectPushSupported: expectPushSupported, - runtime: newInteropSuiteRuntime(), - } - for _, p := range protocols { - p := p - s.fixtures = append(s.fixtures, interopSuiteFixtureSpec{ - label: "go", - protocol: p, - start: func() (*fixtureProcess, string, error) { - return startGoFixture(findFreePort(), p) - }, - }) - } - return s -} From 937e6b7c395fe679b56221171769094323ccd601 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 14:12:43 +0100 Subject: [PATCH 11/18] refactor(a2a): pass newXYZProbeClient functions directly as newClientFn entries Drop context.Context from newClientFn (none of the constructors need it), change newGoProbeClient and newRustProbeClient to return (probeClient, error), and remove the intermediate goClientFn/rustClientFn/dotNetClientFn/pythonClientFn wrapper vars. The constructor functions now match newClientFn directly and are passed without wrapping or casting in the DescribeTableSubtree Entry list. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_behaviors_test.go | 2 +- .../agntcy-a2a/tests/interop_client_test.go | 2 +- .../tests/interop_probe_clients_test.go | 8 +++--- .../agntcy-a2a/tests/interop_table_test.go | 26 +++---------------- 4 files changed, 10 insertions(+), 28 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_behaviors_test.go b/integrations/agntcy-a2a/tests/interop_behaviors_test.go index 3332b3d4..da3f8200 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -236,7 +236,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec BeforeAll(func(ctx SpecContext) { t := target() var err error - client, err = newClient(ctx, t.baseURL) + client, err = newClient(t.baseURL) gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) diff --git a/integrations/agntcy-a2a/tests/interop_client_test.go b/integrations/agntcy-a2a/tests/interop_client_test.go index 6d486cdd..fd29f1dd 100644 --- a/integrations/agntcy-a2a/tests/interop_client_test.go +++ b/integrations/agntcy-a2a/tests/interop_client_test.go @@ -30,4 +30,4 @@ type probeClient interface { } // newClientFn creates a probeClient bound to a specific baseURL. -type newClientFn func(ctx context.Context, baseURL string) (probeClient, error) +type newClientFn func(baseURL string) (probeClient, error) diff --git a/integrations/agntcy-a2a/tests/interop_probe_clients_test.go b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go index ecc3d717..84654838 100644 --- a/integrations/agntcy-a2a/tests/interop_probe_clients_test.go +++ b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go @@ -295,14 +295,14 @@ func (c *externalProbeClient) GetExtendedCard(ctx context.Context, req *a2a.GetE return &card, nil } -func newGoProbeClient(baseURL string) probeClient { +func newGoProbeClient(baseURL string) (probeClient, error) { return &externalProbeClient{ basecmd: []string{"go", "run", "./fixtures/go-probe"}, baseURL: baseURL, - } + }, nil } -func newRustProbeClient(baseURL string) probeClient { +func newRustProbeClient(baseURL string) (probeClient, error) { return &externalProbeClient{ basecmd: []string{ "cargo", "run", @@ -311,7 +311,7 @@ func newRustProbeClient(baseURL string) probeClient { "--", }, baseURL: baseURL, - } + }, nil } func newPythonProbeClient(baseURL string) (probeClient, error) { diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index 28560390..9d9ee706 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -39,30 +39,12 @@ package tests // dotnet run) so no pre-build step or asset cache is needed. import ( - "context" "fmt" . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) -// ── client factories ────────────────────────────────────────────────────────── - -var ( - goClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { - return newGoProbeClient(url), nil - } - rustClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { - return newRustProbeClient(url), nil - } - dotNetClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { - return newDotNetProbeClient(url) - } - pythonClientFn newClientFn = func(_ context.Context, url string) (probeClient, error) { - return newPythonProbeClient(url) - } -) - // ── server starter type ─────────────────────────────────────────────────────── // serverStarter starts a fresh fixture process for a single transport protocol. @@ -116,8 +98,8 @@ var _ = DescribeTableSubtree( ) }, // ── client entries ──────────────────────────────────────────────────────── - Entry("go", Ordered, ContinueOnFailure, Label("CLIENT:go"), goClientFn, true), - Entry("rust", Ordered, ContinueOnFailure, Label("CLIENT:rust"), rustClientFn, true), - Entry("python", Ordered, ContinueOnFailure, Label("CLIENT:python"), pythonClientFn, true), - Entry("dotnet", Ordered, ContinueOnFailure, Label("CLIENT:dotnet"), dotNetClientFn, false), + Entry("go", Ordered, ContinueOnFailure, Label("CLIENT:go"), newGoProbeClient, true), + Entry("rust", Ordered, ContinueOnFailure, Label("CLIENT:rust"), newRustProbeClient, true), + Entry("python", Ordered, ContinueOnFailure, Label("CLIENT:python"), newPythonProbeClient, true), + Entry("dotnet", Ordered, ContinueOnFailure, Label("CLIENT:dotnet"), newDotNetProbeClient, false), ) From f8ff997a26d7e3d4ed841642d5246a3fb64850f0 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 14:16:46 +0100 Subject: [PATCH 12/18] refactor(a2a): merge startGoFixture into interop_launchers_test.go The separate native_go_launchers_test.go file no longer serves a purpose now that the orchestration layer is gone. Move startGoFixture alongside the other startXYZFixture functions and delete the file. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../agntcy-a2a/tests/interop_launchers_test.go | 16 ++++++++++++---- .../tests/native_go_launchers_test.go | 18 ------------------ 2 files changed, 12 insertions(+), 22 deletions(-) delete mode 100644 integrations/agntcy-a2a/tests/native_go_launchers_test.go diff --git a/integrations/agntcy-a2a/tests/interop_launchers_test.go b/integrations/agntcy-a2a/tests/interop_launchers_test.go index dc6db148..7770be22 100644 --- a/integrations/agntcy-a2a/tests/interop_launchers_test.go +++ b/integrations/agntcy-a2a/tests/interop_launchers_test.go @@ -3,10 +3,9 @@ package tests -// This file launches shared transport processes and provides fixtureServer factory -// functions for Rust, .NET, and Python. All fixtures use their toolchain's run -// command (go run / cargo run / uv run / dotnet run) so no explicit build step -// is required. The native Go fixture path lives in native_go_launchers_test.go. +// This file launches fixture server processes for all four SDKs (Go, Rust, Python, +// .NET). All fixtures use their toolchain's run command (go run / cargo run / +// uv run / dotnet run) so no explicit build step is required. import ( "context" @@ -128,6 +127,15 @@ func startNativeFixture(name string, port int, protocol transportProtocol, comma return process, baseURL, nil } +func startGoFixture(protocol transportProtocol) (*fixtureProcess, string, error) { + return startNativeFixture( + fmt.Sprintf("go-%s-server", protocol), + findFreePort(), + protocol, + "go", "run", "./fixtures/go-jsonrpc-server", + ) +} + func startRustFixture(protocol transportProtocol) (*fixtureProcess, string, error) { return startNativeFixture( fmt.Sprintf("rust-%s-server", protocol), diff --git a/integrations/agntcy-a2a/tests/native_go_launchers_test.go b/integrations/agntcy-a2a/tests/native_go_launchers_test.go deleted file mode 100644 index d7b40852..00000000 --- a/integrations/agntcy-a2a/tests/native_go_launchers_test.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -// This file isolates the native Go fixture startup path from the shared launcher -// helpers so the cross-SDK launcher layer stays focused on generic orchestration. - -import "fmt" - -func startGoFixture(protocol transportProtocol) (*fixtureProcess, string, error) { - return startNativeFixture( - fmt.Sprintf("go-%s-server", protocol), - findFreePort(), - protocol, - "go", "run", "./fixtures/go-jsonrpc-server", - ) -} From 62f76ef06ae7139f3ad35097b4bdaed64ec69724 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 14:20:25 +0100 Subject: [PATCH 13/18] refactor(a2a): merge probeClient interface into interop_probe_clients_test.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interop_client_test.go only held the probeClient interface and newClientFn type — both belong alongside their implementations. Move them into interop_probe_clients_test.go and delete the file. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../agntcy-a2a/tests/interop_client_test.go | 33 ------------------- .../tests/interop_probe_clients_test.go | 21 +++++++++++- 2 files changed, 20 insertions(+), 34 deletions(-) delete mode 100644 integrations/agntcy-a2a/tests/interop_client_test.go diff --git a/integrations/agntcy-a2a/tests/interop_client_test.go b/integrations/agntcy-a2a/tests/interop_client_test.go deleted file mode 100644 index fd29f1dd..00000000 --- a/integrations/agntcy-a2a/tests/interop_client_test.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -// This file defines the probeClient interface and the newClientFn type. -// All interop behavior specs are written against probeClient so the same spec tree -// exercises Go, Rust, Python, and .NET clients without duplication. -// Client implementations live in interop_probe_clients_test.go. - -import ( - "context" - - "github.com/a2aproject/a2a-go/v2/a2a" -) - -// probeClient is the unified A2A client interface used by all behavior specs. -// Both the native Go SDK and external language probes implement this. -type probeClient interface { - SendMessage(ctx context.Context, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) - SendStreamingMessage(ctx context.Context, req *a2a.SendMessageRequest) ([]a2a.Event, error) - GetTask(ctx context.Context, req *a2a.GetTaskRequest) (*a2a.Task, error) - CancelTask(ctx context.Context, req *a2a.CancelTaskRequest) (*a2a.Task, error) - ListTasks(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) - CreatePushConfig(ctx context.Context, cfg *a2a.PushConfig) (*a2a.PushConfig, error) - GetPushConfig(ctx context.Context, req *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) - ListPushConfigs(ctx context.Context, req *a2a.ListTaskPushConfigRequest) ([]*a2a.PushConfig, error) - DeletePushConfig(ctx context.Context, req *a2a.DeleteTaskPushConfigRequest) error - GetExtendedCard(ctx context.Context, req *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) -} - -// newClientFn creates a probeClient bound to a specific baseURL. -type newClientFn func(baseURL string) (probeClient, error) diff --git a/integrations/agntcy-a2a/tests/interop_probe_clients_test.go b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go index 84654838..26c0f685 100644 --- a/integrations/agntcy-a2a/tests/interop_probe_clients_test.go +++ b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go @@ -3,7 +3,8 @@ package tests -// This file implements the external language probe clients: Go, Rust, Python, and .NET. +// This file defines the probeClient interface, the newClientFn type, and the +// external language probe client implementations for Go, Rust, Python, and .NET. // Each client stores a basecmd (the command needed to invoke the probe binary) and a // baseURL. The call() method appends --card-url and the per-operation args, then // runs the command and returns stdout/stderr. All probe CLIs share the same subcommand @@ -22,6 +23,24 @@ import ( "github.com/a2aproject/a2a-go/v2/a2a" ) +// probeClient is the unified A2A client interface used by all behavior specs. +// Both the native Go SDK and external language probes implement this. +type probeClient interface { + SendMessage(ctx context.Context, req *a2a.SendMessageRequest) (a2a.SendMessageResult, error) + SendStreamingMessage(ctx context.Context, req *a2a.SendMessageRequest) ([]a2a.Event, error) + GetTask(ctx context.Context, req *a2a.GetTaskRequest) (*a2a.Task, error) + CancelTask(ctx context.Context, req *a2a.CancelTaskRequest) (*a2a.Task, error) + ListTasks(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) + CreatePushConfig(ctx context.Context, cfg *a2a.PushConfig) (*a2a.PushConfig, error) + GetPushConfig(ctx context.Context, req *a2a.GetTaskPushConfigRequest) (*a2a.PushConfig, error) + ListPushConfigs(ctx context.Context, req *a2a.ListTaskPushConfigRequest) ([]*a2a.PushConfig, error) + DeletePushConfig(ctx context.Context, req *a2a.DeleteTaskPushConfigRequest) error + GetExtendedCard(ctx context.Context, req *a2a.GetExtendedAgentCardRequest) (*a2a.AgentCard, error) +} + +// newClientFn creates a probeClient bound to a specific baseURL. +type newClientFn func(baseURL string) (probeClient, error) + // probeError is returned when an external probe exits with code 1. type probeError struct { Code int `json:"code"` From ba982c131c007e15c759e842e09f606dece3f551 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 15:26:13 +0100 Subject: [PATCH 14/18] refactor(a2a): restructure behavior specs to follow When/Context/It BDD pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group related specs under shared When blocks with Context siblings instead of using flat parallel When blocks for each variant: - When("the client sends a message"): all unary send-message scenarios are now Context siblings — server creates a task, message-only response, task failure, rich data types, multi-turn input, long-running request - When("the client streams a message"): basic task creation and artifact streaming are now Context siblings - When("the client gets a task by ID"): task-exists and task-not-found are now Context siblings instead of separate top-level When blocks - When("the client cancels a task"): working-task and completed-task cancel paths are now Context siblings instead of separate top-level When blocks - When("the client manages push notification config"): the if/else over two separate When blocks is collapsed into one When with a conditional Context No specs are added or removed — dry-run confirms 1296 specs. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_behaviors_test.go | 622 +++++++++--------- 1 file changed, 313 insertions(+), 309 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_behaviors_test.go b/integrations/agntcy-a2a/tests/interop_behaviors_test.go index da3f8200..46d14a76 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -242,8 +242,8 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec // ── send message (unary) ───────────────────────────────────────────────── - When("the client sends a message", Ordered, func() { - Context("with return_immediately=false", Ordered, func() { + When("the client sends a message", func() { + Context("and the server creates a task", Ordered, func() { var task *a2a.Task BeforeAll(func(ctx SpecContext) { @@ -268,136 +268,246 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec gomega.Expect(text).To(gomega.Equal(expectedServerText(target().serverPrefix, requestText))) }) }) - }) - - // ── streaming send message ─────────────────────────────────────────────── - When("the client streams a message", Ordered, func() { - var events []a2a.Event + Context("and the server returns a message-only response", func() { + It("the client receives a Message result", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(messageOnlyRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + message, ok := result.(*a2a.Message) + gomega.Expect(ok).To(gomega.BeTrue(), "expected Message result from SendMessage") + text, err := firstMessageText(message) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server message-only response", target().serverPrefix))) + }) + }) - BeforeAll(func(ctx SpecContext) { - var err error - events, err = client.SendStreamingMessage(ctx, newInteropRequest(requestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) + Context("and the server fails the task", func() { + It("the client receives a failed task", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(taskFailureRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateFailed)) + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server failed task", target().serverPrefix))) + }) }) - It("receives at least one event with the server response text", func() { - gomega.Expect(events).NotTo(gomega.BeEmpty()) - var texts []string - for _, event := range events { - text, hasText, err := eventText(event) + Context("and the server returns rich data types", func() { + It("the completed task includes structured artifact data", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(dataTypesRequestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - if hasText { - texts = append(texts, text) - } - } - gomega.Expect(texts).To(gomega.ContainElement( - expectedServerText(target().serverPrefix, requestText), - )) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server data-types ready", target().serverPrefix))) + assertDataTypesTask(task, "data-types") + }) }) - }) - // ── get task ───────────────────────────────────────────────────────────── + Context("and the agent requires multi-turn input", Ordered, func() { + var inputRequiredTask, continuedTask *a2a.Task - When("the client fetches a task by ID", Ordered, func() { - var completedTask, fetchedTask *a2a.Task + BeforeAll(func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(multiTurnStartRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + inputRequiredTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(inputRequiredTask.Status.State).To(gomega.Equal(a2a.TaskStateInputRequired)) - BeforeAll(func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) + continueResult, err := client.SendMessage(ctx, newInteropRequestWithIDs( + multiTurnContinueRequestText, false, + inputRequiredTask.ID, inputRequiredTask.ContextID, + )) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + continuedTask, ok = continueResult.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + }) - fetchedTask, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: completedTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) + It("the first request returns input-required state", func() { + text, err := taskStatusText(inputRequiredTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", target().serverPrefix))) + assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") + }) - It("returns the task with completed state", func() { - gomega.Expect(fetchedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + It("the continued request completes the task", func() { + gomega.Expect(continuedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + text, err := taskStatusText(continuedTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server multi-turn completed", target().serverPrefix))) + assertTaskHistoryPayloads(continuedTask, + []string{multiTurnStartRequestText, multiTurnContinueRequestText}, + "multi-turn continuation", + ) + }) }) - It("returns the correct response text", func() { - text, err := taskStatusText(fetchedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(expectedServerText(target().serverPrefix, requestText))) - }) + Context("and the server handles a long-running request", Ordered, func() { + var completedTask *a2a.Task - It("returns the history payload", func() { - assertTaskHistoryPayload(fetchedTask, requestText, "fetched task history") + BeforeAll(func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(longRunningRequestText, true)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + + switch task.Status.State { + case a2a.TaskStateWorking: + text, textErr := taskStatusText(task) + gomega.Expect(textErr).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running started", target().serverPrefix))) + completedTask, err = waitForProbeTaskState(ctx, client, task.ID, a2a.TaskStateCompleted) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + case a2a.TaskStateCompleted: + completedTask = task + default: + Fail(fmt.Sprintf("unexpected long-running task initial state: %s", task.Status.State)) + } + }) + + It("completes with the expected text", func() { + text, err := taskStatusText(completedTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", target().serverPrefix))) + }) }) }) - // ── list tasks ─────────────────────────────────────────────────────────── + // ── streaming send message ─────────────────────────────────────────────── - When("the client lists tasks by context ID", Ordered, func() { - var completedTask *a2a.Task - var listedTasks *a2a.ListTasksResponse + When("the client streams a message", func() { + Context("and the server creates a task", Ordered, func() { + var events []a2a.Event - BeforeAll(func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) + BeforeAll(func(ctx SpecContext) { + var err error + events, err = client.SendStreamingMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) - listedTasks, err = client.ListTasks(ctx, &a2a.ListTasksRequest{ContextID: completedTask.ContextID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) + It("receives at least one event with the server response text", func() { + gomega.Expect(events).NotTo(gomega.BeEmpty()) + var texts []string + for _, event := range events { + text, hasText, err := eventText(event) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + if hasText { + texts = append(texts, text) + } + } + gomega.Expect(texts).To(gomega.ContainElement( + expectedServerText(target().serverPrefix, requestText), + )) + }) }) - It("includes the sent task in the list", func() { - gomega.Expect(listedTasks.Tasks).NotTo(gomega.BeEmpty()) - gomega.Expect(listedTasks.Tasks).To(gomega.ContainElement(gomega.HaveField("ID", completedTask.ID))) + Context("and the server streams artifacts", Ordered, func() { + var streamingEvents []a2a.Event + + BeforeAll(func(ctx SpecContext) { + var err error + streamingEvents, err = client.SendStreamingMessage(ctx, newInteropRequest(streamingRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + It("emits a working task at the start", func() { + sawStart := false + for _, event := range streamingEvents { + if task, ok := event.(*a2a.Task); ok { + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming started", target().serverPrefix))) + sawStart = true + break + } + } + gomega.Expect(sawStart).To(gomega.BeTrue(), "expected a working Task streaming-start event") + }) + + It("emits artifact chunks in order with at least one append", func() { + var chunks []string + sawAppend := false + for _, event := range streamingEvents { + if update, ok := event.(*a2a.TaskArtifactUpdateEvent); ok { + text, err := firstArtifactText(update.Artifact) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + chunks = append(chunks, text) + if update.Append { + sawAppend = true + } + } + } + gomega.Expect(sawAppend).To(gomega.BeTrue(), "expected an append artifact update") + gomega.Expect(chunks).To(gomega.Equal([]string{"streaming chunk 1", "streaming chunk 2"})) + }) + + It("emits a completed status update at the end", func() { + sawComplete := false + for _, event := range streamingEvents { + if update, ok := event.(*a2a.TaskStatusUpdateEvent); ok { + gomega.Expect(update.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + text, err := firstMessageText(update.Status.Message) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming complete", target().serverPrefix))) + sawComplete = true + } + } + gomega.Expect(sawComplete).To(gomega.BeTrue(), "expected a completed TaskStatusUpdateEvent") + }) }) }) - // ── cancel task ────────────────────────────────────────────────────────── + // ── get task ───────────────────────────────────────────────────────────── - When("the client cancels a working task", Ordered, func() { - var canceledTask, fetchedCanceledTask *a2a.Task + When("the client gets a task by ID", func() { + Context("and the task exists", Ordered, func() { + var completedTask, fetchedTask *a2a.Task - BeforeAll(func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(pendingRequestText, true)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - workingTask, ok := result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(workingTask.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) - - canceledTask, err = client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: workingTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) + BeforeAll(func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) - fetchedCanceledTask, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: workingTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) + fetchedTask, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: completedTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) - It("returns a canceled task", func() { - gomega.Expect(canceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) - }) + It("returns the task with completed state", func() { + gomega.Expect(fetchedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + }) - It("returns the cancel text in the task status", func() { - text, err := taskStatusText(canceledTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(expectedCancelText(target().serverPrefix))) - }) + It("returns the correct response text", func() { + text, err := taskStatusText(fetchedTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(expectedServerText(target().serverPrefix, requestText))) + }) - It("the canceled state persists on fetch", func() { - gomega.Expect(fetchedCanceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) + It("returns the history payload", func() { + assertTaskHistoryPayload(fetchedTask, requestText, "fetched task history") + }) }) - }) - // ── missing task error ─────────────────────────────────────────────────── - - When("the client requests a task that does not exist", func() { - It("returns a not-found error", func(ctx SpecContext) { - _, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: a2a.NewTaskID()}) - assertTaskNotFoundError(err, "missing task") + Context("and the task does not exist", func() { + It("returns a not-found error", func(ctx SpecContext) { + _, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: a2a.NewTaskID()}) + assertTaskNotFoundError(err, "missing task") + }) }) }) - // ── cancel completed task error ────────────────────────────────────────── + // ── list tasks ─────────────────────────────────────────────────────────── - When("the client attempts to cancel a completed task", Ordered, func() { + When("the client lists tasks by context ID", Ordered, func() { var completedTask *a2a.Task + var listedTasks *a2a.ListTasksResponse BeforeAll(func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) @@ -405,69 +515,53 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec var ok bool completedTask, ok = result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) + + listedTasks, err = client.ListTasks(ctx, &a2a.ListTasksRequest{ContextID: completedTask.ContextID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - It("returns an error", func(ctx SpecContext) { - _, err := client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: completedTask.ID}) - assertTaskNotCancelableError(err, "canceling a terminal task should fail") + It("includes the sent task in the list", func() { + gomega.Expect(listedTasks.Tasks).NotTo(gomega.BeEmpty()) + gomega.Expect(listedTasks.Tasks).To(gomega.ContainElement(gomega.HaveField("ID", completedTask.ID))) }) }) - // ── push notification config ───────────────────────────────────────────── + // ── cancel task ────────────────────────────────────────────────────────── - if expectPushSupported { - When("the client manages push notification config", Ordered, func() { - var completedTask *a2a.Task - var createdConfig *a2a.PushConfig + When("the client cancels a task", func() { + Context("and the task is working", Ordered, func() { + var canceledTask, fetchedCanceledTask *a2a.Task BeforeAll(func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + result, err := client.SendMessage(ctx, newInteropRequest(pendingRequestText, true)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) + workingTask, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(completedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + gomega.Expect(workingTask.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) - cfg := newInteropPushConfig() - cfg.TaskID = completedTask.ID - createdConfig, err = client.CreatePushConfig(ctx, cfg) + canceledTask, err = client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: workingTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - It("creates a push config with expected fields", func() { - assertTaskPushConfig(createdConfig, completedTask.ID, newInteropPushConfig(), "created push config") - }) - - It("fetches the push config by ID", func(ctx SpecContext) { - fetched, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ - TaskID: completedTask.ID, - ID: createdConfig.ID, - }) + fetchedCanceledTask, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: workingTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - assertTaskPushConfig(fetched, completedTask.ID, newInteropPushConfig(), "fetched push config") }) - It("lists exactly one push config for the task", func(ctx SpecContext) { - listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(listed).To(gomega.HaveLen(1)) - assertTaskPushConfig(listed[0], completedTask.ID, newInteropPushConfig(), "listed push config") + It("returns a canceled task", func() { + gomega.Expect(canceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) }) - It("deletes the push config and the list is empty afterward", func(ctx SpecContext) { - err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ - TaskID: completedTask.ID, - ID: createdConfig.ID, - }) + It("returns the cancel text in the task status", func() { + text, err := taskStatusText(canceledTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(expectedCancelText(target().serverPrefix))) + }) - listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(listed).To(gomega.BeEmpty()) + It("the canceled state persists on fetch", func() { + gomega.Expect(fetchedCanceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) }) }) - } else { - When("the server does not support push notifications", Ordered, func() { + + Context("and the task is already completed", Ordered, func() { var completedTask *a2a.Task BeforeAll(func(ctx SpecContext) { @@ -478,201 +572,111 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec gomega.Expect(ok).To(gomega.BeTrue()) }) - It("returns an error for CreatePushConfig", func(ctx SpecContext) { - cfg := newInteropPushConfig() - cfg.TaskID = completedTask.ID - _, err := client.CreatePushConfig(ctx, cfg) - assertPushUnsupportedError(err, "create push config unsupported") + It("returns an error", func(ctx SpecContext) { + _, err := client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: completedTask.ID}) + assertTaskNotCancelableError(err, "canceling a terminal task should fail") }) - - It("returns an error for GetPushConfig", func(ctx SpecContext) { - _, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ - TaskID: completedTask.ID, - ID: "interop-config", - }) - assertPushUnsupportedError(err, "get push config unsupported") - }) - - It("returns an error for ListPushConfigs", func(ctx SpecContext) { - _, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) - assertPushUnsupportedError(err, "list push config unsupported") - }) - - It("returns an error for DeletePushConfig", func(ctx SpecContext) { - err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ - TaskID: completedTask.ID, - ID: "interop-config", - }) - assertPushUnsupportedError(err, "delete push config unsupported") - }) - }) - } - - // ── scenario parity ────────────────────────────────────────────────────── - - When("the server returns a message-only response", func() { - It("the client receives a Message result", func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(messageOnlyRequestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - message, ok := result.(*a2a.Message) - gomega.Expect(ok).To(gomega.BeTrue(), "expected Message result from SendMessage") - text, err := firstMessageText(message) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server message-only response", target().serverPrefix))) - }) - }) - - When("the server fails the task", func() { - It("the client receives a failed task", func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(taskFailureRequestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - task, ok := result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateFailed)) - text, err := taskStatusText(task) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server failed task", target().serverPrefix))) }) }) - When("the agent requires multi-turn input", Ordered, func() { - var inputRequiredTask, continuedTask *a2a.Task - - BeforeAll(func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(multiTurnStartRequestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - inputRequiredTask, ok = result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(inputRequiredTask.Status.State).To(gomega.Equal(a2a.TaskStateInputRequired)) + // ── push notification config ───────────────────────────────────────────── - continueResult, err := client.SendMessage(ctx, newInteropRequestWithIDs( - multiTurnContinueRequestText, false, - inputRequiredTask.ID, inputRequiredTask.ContextID, - )) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - continuedTask, ok = continueResult.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - }) + When("the client manages push notification config", func() { + if expectPushSupported { + Context("and the server supports push notifications", Ordered, func() { + var completedTask *a2a.Task + var createdConfig *a2a.PushConfig - It("the first request returns input-required state", func() { - text, err := taskStatusText(inputRequiredTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", target().serverPrefix))) - assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") - }) + BeforeAll(func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(completedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + + cfg := newInteropPushConfig() + cfg.TaskID = completedTask.ID + createdConfig, err = client.CreatePushConfig(ctx, cfg) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) - It("the continued request completes the task", func() { - gomega.Expect(continuedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - text, err := taskStatusText(continuedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server multi-turn completed", target().serverPrefix))) - assertTaskHistoryPayloads(continuedTask, - []string{multiTurnStartRequestText, multiTurnContinueRequestText}, - "multi-turn continuation", - ) - }) - }) + It("creates a push config with expected fields", func() { + assertTaskPushConfig(createdConfig, completedTask.ID, newInteropPushConfig(), "created push config") + }) - When("the server streams artifacts", Ordered, func() { - var streamingEvents []a2a.Event + It("fetches the push config by ID", func(ctx SpecContext) { + fetched, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: createdConfig.ID, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + assertTaskPushConfig(fetched, completedTask.ID, newInteropPushConfig(), "fetched push config") + }) - BeforeAll(func(ctx SpecContext) { - var err error - streamingEvents, err = client.SendStreamingMessage(ctx, newInteropRequest(streamingRequestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) + It("lists exactly one push config for the task", func(ctx SpecContext) { + listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(listed).To(gomega.HaveLen(1)) + assertTaskPushConfig(listed[0], completedTask.ID, newInteropPushConfig(), "listed push config") + }) - It("emits a working task at the start", func() { - sawStart := false - for _, event := range streamingEvents { - if task, ok := event.(*a2a.Task); ok { - gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) - text, err := taskStatusText(task) + It("deletes the push config and the list is empty afterward", func(ctx SpecContext) { + err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: createdConfig.ID, + }) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming started", target().serverPrefix))) - sawStart = true - break - } - } - gomega.Expect(sawStart).To(gomega.BeTrue(), "expected a working Task streaming-start event") - }) - It("emits artifact chunks in order with at least one append", func() { - var chunks []string - sawAppend := false - for _, event := range streamingEvents { - if update, ok := event.(*a2a.TaskArtifactUpdateEvent); ok { - text, err := firstArtifactText(update.Artifact) + listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - chunks = append(chunks, text) - if update.Append { - sawAppend = true - } - } - } - gomega.Expect(sawAppend).To(gomega.BeTrue(), "expected an append artifact update") - gomega.Expect(chunks).To(gomega.Equal([]string{"streaming chunk 1", "streaming chunk 2"})) - }) + gomega.Expect(listed).To(gomega.BeEmpty()) + }) + }) + } else { + Context("and the server does not support push notifications", Ordered, func() { + var completedTask *a2a.Task - It("emits a completed status update at the end", func() { - sawComplete := false - for _, event := range streamingEvents { - if update, ok := event.(*a2a.TaskStatusUpdateEvent); ok { - gomega.Expect(update.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - text, err := firstMessageText(update.Status.Message) + BeforeAll(func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming complete", target().serverPrefix))) - sawComplete = true - } - } - gomega.Expect(sawComplete).To(gomega.BeTrue(), "expected a completed TaskStatusUpdateEvent") - }) - }) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + }) - When("the server handles a long-running request", Ordered, func() { - var completedTask *a2a.Task + It("returns an error for CreatePushConfig", func(ctx SpecContext) { + cfg := newInteropPushConfig() + cfg.TaskID = completedTask.ID + _, err := client.CreatePushConfig(ctx, cfg) + assertPushUnsupportedError(err, "create push config unsupported") + }) - BeforeAll(func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(longRunningRequestText, true)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - task, ok := result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) + It("returns an error for GetPushConfig", func(ctx SpecContext) { + _, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: "interop-config", + }) + assertPushUnsupportedError(err, "get push config unsupported") + }) - switch task.Status.State { - case a2a.TaskStateWorking: - text, textErr := taskStatusText(task) - gomega.Expect(textErr).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running started", target().serverPrefix))) - completedTask, err = waitForProbeTaskState(ctx, client, task.ID, a2a.TaskStateCompleted) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - case a2a.TaskStateCompleted: - completedTask = task - default: - Fail(fmt.Sprintf("unexpected long-running task initial state: %s", task.Status.State)) - } - }) + It("returns an error for ListPushConfigs", func(ctx SpecContext) { + _, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + assertPushUnsupportedError(err, "list push config unsupported") + }) - It("completes with the expected text", func() { - text, err := taskStatusText(completedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", target().serverPrefix))) - }) + It("returns an error for DeletePushConfig", func(ctx SpecContext) { + err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: "interop-config", + }) + assertPushUnsupportedError(err, "delete push config unsupported") + }) + }) + } }) - When("the server returns rich data types", func() { - It("the completed task includes structured artifact data", func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(dataTypesRequestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - task, ok := result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - text, err := taskStatusText(task) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server data-types ready", target().serverPrefix))) - assertDataTypesTask(task, "data-types") - }) - }) + // ── extended agent card ────────────────────────────────────────────────── When("the client fetches the extended agent card", func() { It("returns a valid extended card", func(ctx SpecContext) { From 7d0a30aaae9e376bf190458623dd49cb0de984f7 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 16:20:42 +0100 Subject: [PATCH 15/18] refactor(a2a): add --disable-push flag and test both push modes inline Add a --disable-push CLI flag to the Go, Rust, and Python fixture servers so each can be started with push notifications either enabled or disabled. Move server startup from the table into registerBehaviors itself. The outer BeforeAll now always starts the main server with push disabled. Within the push notification When block, both contexts are always registered: - push-enabled: starts a dedicated server with push on; skips via ErrUnsupportedConfig for .NET (which hardcodes push as disabled) - push-disabled: reuses the outer client (works for all four languages) The serverSupportsPush bool table parameter is removed entirely; push capability is discovered at runtime through the ErrUnsupportedConfig sentinel. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../fixtures/go-jsonrpc-server/main.go | 21 +- .../fixtures/python/interop_python_server.py | 19 +- .../rust/src/bin/interop-rust-server.rs | 29 ++- .../tests/interop_behaviors_test.go | 224 ++++++++++-------- .../tests/interop_launchers_test.go | 29 ++- .../agntcy-a2a/tests/interop_shared_test.go | 15 +- .../agntcy-a2a/tests/interop_table_test.go | 40 +--- 7 files changed, 204 insertions(+), 173 deletions(-) diff --git a/integrations/agntcy-a2a/fixtures/go-jsonrpc-server/main.go b/integrations/agntcy-a2a/fixtures/go-jsonrpc-server/main.go index cf038629..e18ec160 100644 --- a/integrations/agntcy-a2a/fixtures/go-jsonrpc-server/main.go +++ b/integrations/agntcy-a2a/fixtures/go-jsonrpc-server/main.go @@ -209,7 +209,7 @@ func parseTransportProtocol(raw string) (transportProtocol, error) { } } -func agentCard(cardPort int, protocol transportProtocol, grpcPort int, extended bool) *a2a.AgentCard { +func agentCard(cardPort int, protocol transportProtocol, grpcPort int, extended bool, pushNotifications bool) *a2a.AgentCard { baseURL := fmt.Sprintf("http://127.0.0.1:%d", cardPort) name := "CSIT Go JSON-RPC Agent" iface := a2a.NewAgentInterface(baseURL+"/rpc", a2a.TransportProtocolJSONRPC) @@ -239,7 +239,7 @@ func agentCard(cardPort int, protocol transportProtocol, grpcPort int, extended }, Capabilities: a2a.AgentCapabilities{ Streaming: true, - PushNotifications: true, + PushNotifications: pushNotifications, ExtendedAgentCard: true, }, DefaultInputModes: []string{"text/plain"}, @@ -303,6 +303,7 @@ func main() { port := flag.Int("port", 19091, "port for the fixture agent card server") grpcPort := flag.Int("grpc-port", 19092, "port for the gRPC fixture transport server") protocolFlag := flag.String("protocol", string(transportProtocolJSONRPC), "transport protocol to serve: jsonrpc, rest, or grpc") + disablePush := flag.Bool("disable-push", false, "disable push notification support") flag.Parse() protocol, err := parseTransportProtocol(*protocolFlag) @@ -310,16 +311,18 @@ func main() { log.Fatalf("failed to parse protocol: %v", err) } - handler := a2asrv.NewHandler( - &interopExecutor{}, + opts := []a2asrv.RequestHandlerOption{ a2asrv.WithTaskStore(taskstore.NewInMemory(&taskstore.InMemoryStoreConfig{ Authenticator: func(context.Context) (string, error) { return "csit-user", nil }, })), - a2asrv.WithPushNotifications(push.NewInMemoryStore(), push.NewHTTPPushSender(nil)), - a2asrv.WithExtendedAgentCard(agentCard(*port, protocol, *grpcPort, true)), - ) + a2asrv.WithExtendedAgentCard(agentCard(*port, protocol, *grpcPort, true, !*disablePush)), + } + if !*disablePush { + opts = append(opts, a2asrv.WithPushNotifications(push.NewInMemoryStore(), push.NewHTTPPushSender(nil))) + } + handler := a2asrv.NewHandler(&interopExecutor{}, opts...) if protocol == transportProtocolGRPC { cardListener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", *port)) @@ -332,7 +335,7 @@ func main() { log.Fatalf("failed to bind gRPC listener: %v", err) } - card := agentCard(*port, protocol, *grpcPort, false) + card := agentCard(*port, protocol, *grpcPort, false, !*disablePush) errCh := make(chan error, 2) go func() { @@ -368,7 +371,7 @@ func main() { mux.Handle("/", wrapRESTPushCreateCompat(a2asrv.NewRESTHandler(handler))) log.Printf("go rest fixture listening on http://127.0.0.1:%d", *port) } - mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(agentCard(*port, protocol, *grpcPort, false))) + mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(agentCard(*port, protocol, *grpcPort, false, !*disablePush))) if err := http.Serve(listener, mux); err != nil { log.Fatalf("server stopped: %v", err) diff --git a/integrations/agntcy-a2a/fixtures/python/interop_python_server.py b/integrations/agntcy-a2a/fixtures/python/interop_python_server.py index 27084381..59248f02 100644 --- a/integrations/agntcy-a2a/fixtures/python/interop_python_server.py +++ b/integrations/agntcy-a2a/fixtures/python/interop_python_server.py @@ -178,6 +178,7 @@ def build_agent_card( protocol: str, extended: bool, grpc_port: int | None = None, + disable_push: bool = False, ) -> a2a_pb2.AgentCard: if protocol == 'rest': name = 'CSIT Python REST Agent' @@ -214,7 +215,7 @@ def build_agent_card( supported_interfaces=[interface], capabilities=a2a_pb2.AgentCapabilities( streaming=True, - push_notifications=True, + push_notifications=not disable_push, extended_agent_card=True, ), default_input_modes=['text/plain'], @@ -652,14 +653,15 @@ def build_app( port: int, protocol: str, grpc_port: int | None = None, + disable_push: bool = False, ) -> tuple[Starlette, LegacyRequestHandler]: base_url = f'http://127.0.0.1:{port}' - public_card = build_agent_card(base_url, protocol, extended=False, grpc_port=grpc_port) - extended_card = build_agent_card(base_url, protocol, extended=True, grpc_port=grpc_port) + public_card = build_agent_card(base_url, protocol, extended=False, grpc_port=grpc_port, disable_push=disable_push) + extended_card = build_agent_card(base_url, protocol, extended=True, grpc_port=grpc_port, disable_push=disable_push) request_handler = LegacyRequestHandler( agent_executor=InteropExecutor(), task_store=InMemoryTaskStore(), - push_config_store=InteropPushNotificationConfigStore(), + push_config_store=None if disable_push else InteropPushNotificationConfigStore(), agent_card=public_card, extended_agent_card=extended_card, ) @@ -697,8 +699,8 @@ async def ListTasks( return await super().ListTasks(request, context) -async def run_grpc_fixture(http_port: int, grpc_port: int) -> None: - app, request_handler = build_app(http_port, 'grpc', grpc_port=grpc_port) +async def run_grpc_fixture(http_port: int, grpc_port: int, disable_push: bool = False) -> None: + app, request_handler = build_app(http_port, 'grpc', grpc_port=grpc_port, disable_push=disable_push) grpc_server = grpc.aio.server() grpc_address = f'127.0.0.1:{grpc_port}' @@ -736,6 +738,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument('--port', type=int, required=True) parser.add_argument('--protocol', choices=('jsonrpc', 'rest', 'grpc'), required=True) parser.add_argument('--grpc-port', type=int) + parser.add_argument('--disable-push', action='store_true', default=False) return parser.parse_args() @@ -745,10 +748,10 @@ def main() -> None: if args.protocol == 'grpc': if args.grpc_port is None: raise SystemExit('--grpc-port is required when --protocol=grpc') - asyncio.run(run_grpc_fixture(args.port, args.grpc_port)) + asyncio.run(run_grpc_fixture(args.port, args.grpc_port, disable_push=args.disable_push)) return - app, _ = build_app(args.port, args.protocol) + app, _ = build_app(args.port, args.protocol, disable_push=args.disable_push) LOGGER.info('python %s fixture listening on http://127.0.0.1:%d', args.protocol, args.port) uvicorn.run( app, diff --git a/integrations/agntcy-a2a/fixtures/rust/src/bin/interop-rust-server.rs b/integrations/agntcy-a2a/fixtures/rust/src/bin/interop-rust-server.rs index fa26ea33..378afd84 100644 --- a/integrations/agntcy-a2a/fixtures/rust/src/bin/interop-rust-server.rs +++ b/integrations/agntcy-a2a/fixtures/rust/src/bin/interop-rust-server.rs @@ -75,6 +75,7 @@ fn build_agent_card( protocol: TransportProtocol, grpc_port: Option, extended: bool, + push_notifications: bool, ) -> AgentCard { let base_url = format!("http://127.0.0.1:{card_port}"); let (name, interface_url, binding) = match protocol { @@ -109,7 +110,7 @@ fn build_agent_card( supported_interfaces: vec![AgentInterface::new(interface_url, binding)], capabilities: AgentCapabilities { streaming: Some(true), - push_notifications: Some(true), + push_notifications: Some(push_notifications), extensions: None, extended_agent_card: Some(true), }, @@ -498,11 +499,12 @@ impl a2a_server::AgentExecutor for InteropExecutor { } } -fn parse_args() -> (u16, TransportProtocol, Option) { +fn parse_args() -> (u16, TransportProtocol, Option, bool) { let mut args = env::args().skip(1); let mut port = 19092; let mut protocol = TransportProtocol::JsonRpc; let mut grpc_port = None; + let mut disable_push = false; while let Some(arg) = args.next() { match arg.as_str() { @@ -526,23 +528,28 @@ fn parse_args() -> (u16, TransportProtocol, Option) { .expect("--protocol requires either 'jsonrpc', 'rest', or 'grpc'"); protocol = TransportProtocol::from_str(&value); } + "--disable-push" => { + disable_push = true; + } other => panic!("unknown argument: {other}"), } } - (port, protocol, grpc_port) + (port, protocol, grpc_port, disable_push) } #[tokio::main] async fn main() { - let (port, protocol, grpc_port) = parse_args(); - let public_card = build_agent_card(port, protocol, grpc_port, false); - let extended_card = build_agent_card(port, protocol, grpc_port, true); - let handler = Arc::new(InteropHandler::new( - DefaultRequestHandler::new(InteropExecutor, InMemoryTaskStore::new()) - .with_push_config_store(InMemoryPushConfigStore::new()), - extended_card, - )); + let (port, protocol, grpc_port, disable_push) = parse_args(); + let public_card = build_agent_card(port, protocol, grpc_port, false, !disable_push); + let extended_card = build_agent_card(port, protocol, grpc_port, true, !disable_push); + let handler_builder = DefaultRequestHandler::new(InteropExecutor, InMemoryTaskStore::new()); + let handler_builder = if !disable_push { + handler_builder.with_push_config_store(InMemoryPushConfigStore::new()) + } else { + handler_builder + }; + let handler = Arc::new(InteropHandler::new(handler_builder, extended_card)); let card_producer = Arc::new(StaticAgentCard::new(public_card)); let app = match protocol { diff --git a/integrations/agntcy-a2a/tests/interop_behaviors_test.go b/integrations/agntcy-a2a/tests/interop_behaviors_test.go index 46d14a76..86ceddd6 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -228,17 +228,35 @@ func waitForProbeTaskState(ctx context.Context, client probeClient, taskID a2a.T // ─── registerBehaviors ─────────────────────────────────────────────────────── // registerBehaviors registers the full interop spec tree against the given client factory -// and server target. The containing Context MUST be Ordered so that the outer BeforeAll -// (which creates the client) executes before any It block. -func registerBehaviors(newClient newClientFn, target func() interopTarget, expectPushSupported bool) { - var client probeClient - - BeforeAll(func(ctx SpecContext) { - t := target() +// and server starter. It starts the main server (push-disabled) in the outer BeforeAll and +// stops it in AfterAll. The containing Context MUST be Ordered. +// +// The push notification When block always registers both a push-enabled context (which +// starts a dedicated push-enabled server and skips via ErrUnsupportedConfig if unavailable) +// and a push-disabled context (which reuses the outer client). +func registerBehaviors( + newClient newClientFn, + startServer serverStarter, + protocol transportProtocol, + serverPrefix string, + clientGRPC bool, + serverGRPC bool, +) { + var process *fixtureProcess + var client probeClient + + BeforeAll(func() { + if protocol == transportGRPC && (!clientGRPC || !serverGRPC) { + Skip("gRPC transport is not supported by this client/server combination") + } + var srvURL string var err error - client, err = newClient(t.baseURL) + process, srvURL, err = startServer(protocol, false) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + client, err = newClient(srvURL) gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) + AfterAll(func() { stopFixtureIfRunning(process) }) // ── send message (unary) ───────────────────────────────────────────────── @@ -265,7 +283,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec It("includes the response text from the server", func() { text, err := taskStatusText(task) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(expectedServerText(target().serverPrefix, requestText))) + gomega.Expect(text).To(gomega.Equal(expectedServerText(serverPrefix, requestText))) }) }) @@ -277,7 +295,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec gomega.Expect(ok).To(gomega.BeTrue(), "expected Message result from SendMessage") text, err := firstMessageText(message) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server message-only response", target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server message-only response", serverPrefix))) }) }) @@ -290,7 +308,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateFailed)) text, err := taskStatusText(task) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server failed task", target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server failed task", serverPrefix))) }) }) @@ -303,7 +321,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) text, err := taskStatusText(task) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server data-types ready", target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server data-types ready", serverPrefix))) assertDataTypesTask(task, "data-types") }) }) @@ -331,7 +349,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec It("the first request returns input-required state", func() { text, err := taskStatusText(inputRequiredTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", serverPrefix))) assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") }) @@ -339,7 +357,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec gomega.Expect(continuedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) text, err := taskStatusText(continuedTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server multi-turn completed", target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server multi-turn completed", serverPrefix))) assertTaskHistoryPayloads(continuedTask, []string{multiTurnStartRequestText, multiTurnContinueRequestText}, "multi-turn continuation", @@ -360,7 +378,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec case a2a.TaskStateWorking: text, textErr := taskStatusText(task) gomega.Expect(textErr).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running started", target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running started", serverPrefix))) completedTask, err = waitForProbeTaskState(ctx, client, task.ID, a2a.TaskStateCompleted) gomega.Expect(err).NotTo(gomega.HaveOccurred()) case a2a.TaskStateCompleted: @@ -373,7 +391,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec It("completes with the expected text", func() { text, err := taskStatusText(completedTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", serverPrefix))) }) }) }) @@ -401,7 +419,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec } } gomega.Expect(texts).To(gomega.ContainElement( - expectedServerText(target().serverPrefix, requestText), + expectedServerText(serverPrefix, requestText), )) }) }) @@ -422,7 +440,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) text, err := taskStatusText(task) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming started", target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming started", serverPrefix))) sawStart = true break } @@ -454,7 +472,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec gomega.Expect(update.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) text, err := firstMessageText(update.Status.Message) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming complete", target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server streaming complete", serverPrefix))) sawComplete = true } } @@ -487,7 +505,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec It("returns the correct response text", func() { text, err := taskStatusText(fetchedTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(expectedServerText(target().serverPrefix, requestText))) + gomega.Expect(text).To(gomega.Equal(expectedServerText(serverPrefix, requestText))) }) It("returns the history payload", func() { @@ -553,7 +571,7 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec It("returns the cancel text in the task status", func() { text, err := taskStatusText(canceledTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(expectedCancelText(target().serverPrefix))) + gomega.Expect(text).To(gomega.Equal(expectedCancelText(serverPrefix))) }) It("the canceled state persists on fetch", func() { @@ -582,98 +600,112 @@ func registerBehaviors(newClient newClientFn, target func() interopTarget, expec // ── push notification config ───────────────────────────────────────────── When("the client manages push notification config", func() { - if expectPushSupported { - Context("and the server supports push notifications", Ordered, func() { - var completedTask *a2a.Task - var createdConfig *a2a.PushConfig + // Push-enabled context: starts a dedicated push-enabled server. + // If startServer returns ErrUnsupportedConfig (e.g. .NET), the context is skipped. + Context("and the server supports push notifications", Ordered, func() { + var pushProcess *fixtureProcess + var pushClient probeClient + var completedTask *a2a.Task + var createdConfig *a2a.PushConfig - BeforeAll(func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(completedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - - cfg := newInteropPushConfig() - cfg.TaskID = completedTask.ID - createdConfig, err = client.CreatePushConfig(ctx, cfg) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) + BeforeAll(func(ctx SpecContext) { + var srvURL string + var err error + pushProcess, srvURL, err = startServer(protocol, true) + if errors.Is(err, ErrUnsupportedConfig) { + Skip("server does not support push notifications") + } + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + pushClient, err = newClient(srvURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) - It("creates a push config with expected fields", func() { - assertTaskPushConfig(createdConfig, completedTask.ID, newInteropPushConfig(), "created push config") - }) + result, err := pushClient.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(completedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - It("fetches the push config by ID", func(ctx SpecContext) { - fetched, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ - TaskID: completedTask.ID, - ID: createdConfig.ID, - }) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - assertTaskPushConfig(fetched, completedTask.ID, newInteropPushConfig(), "fetched push config") - }) + cfg := newInteropPushConfig() + cfg.TaskID = completedTask.ID + createdConfig, err = pushClient.CreatePushConfig(ctx, cfg) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + AfterAll(func() { stopFixtureIfRunning(pushProcess) }) - It("lists exactly one push config for the task", func(ctx SpecContext) { - listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(listed).To(gomega.HaveLen(1)) - assertTaskPushConfig(listed[0], completedTask.ID, newInteropPushConfig(), "listed push config") + It("creates a push config with expected fields", func() { + assertTaskPushConfig(createdConfig, completedTask.ID, newInteropPushConfig(), "created push config") + }) + + It("fetches the push config by ID", func(ctx SpecContext) { + fetched, err := pushClient.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: createdConfig.ID, }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + assertTaskPushConfig(fetched, completedTask.ID, newInteropPushConfig(), "fetched push config") + }) - It("deletes the push config and the list is empty afterward", func(ctx SpecContext) { - err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ - TaskID: completedTask.ID, - ID: createdConfig.ID, - }) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) + It("lists exactly one push config for the task", func(ctx SpecContext) { + listed, err := pushClient.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(listed).To(gomega.HaveLen(1)) + assertTaskPushConfig(listed[0], completedTask.ID, newInteropPushConfig(), "listed push config") + }) - listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(listed).To(gomega.BeEmpty()) + It("deletes the push config and the list is empty afterward", func(ctx SpecContext) { + err := pushClient.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: createdConfig.ID, }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + listed, err := pushClient.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(listed).To(gomega.BeEmpty()) }) - } else { - Context("and the server does not support push notifications", Ordered, func() { - var completedTask *a2a.Task + }) - BeforeAll(func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - }) + // Push-disabled context: reuses the outer push-disabled client (works for all 4 languages). + Context("and the server does not support push notifications", Ordered, func() { + var completedTask *a2a.Task - It("returns an error for CreatePushConfig", func(ctx SpecContext) { - cfg := newInteropPushConfig() - cfg.TaskID = completedTask.ID - _, err := client.CreatePushConfig(ctx, cfg) - assertPushUnsupportedError(err, "create push config unsupported") - }) + BeforeAll(func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var ok bool + completedTask, ok = result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + }) - It("returns an error for GetPushConfig", func(ctx SpecContext) { - _, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ - TaskID: completedTask.ID, - ID: "interop-config", - }) - assertPushUnsupportedError(err, "get push config unsupported") - }) + It("returns an error for CreatePushConfig", func(ctx SpecContext) { + cfg := newInteropPushConfig() + cfg.TaskID = completedTask.ID + _, err := client.CreatePushConfig(ctx, cfg) + assertPushUnsupportedError(err, "create push config unsupported") + }) - It("returns an error for ListPushConfigs", func(ctx SpecContext) { - _, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) - assertPushUnsupportedError(err, "list push config unsupported") + It("returns an error for GetPushConfig", func(ctx SpecContext) { + _, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: "interop-config", }) + assertPushUnsupportedError(err, "get push config unsupported") + }) + + It("returns an error for ListPushConfigs", func(ctx SpecContext) { + _, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + assertPushUnsupportedError(err, "list push config unsupported") + }) - It("returns an error for DeletePushConfig", func(ctx SpecContext) { - err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ - TaskID: completedTask.ID, - ID: "interop-config", - }) - assertPushUnsupportedError(err, "delete push config unsupported") + It("returns an error for DeletePushConfig", func(ctx SpecContext) { + err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: "interop-config", }) + assertPushUnsupportedError(err, "delete push config unsupported") }) - } + }) }) // ── extended agent card ────────────────────────────────────────────────── diff --git a/integrations/agntcy-a2a/tests/interop_launchers_test.go b/integrations/agntcy-a2a/tests/interop_launchers_test.go index 7770be22..42b989d5 100644 --- a/integrations/agntcy-a2a/tests/interop_launchers_test.go +++ b/integrations/agntcy-a2a/tests/interop_launchers_test.go @@ -127,28 +127,36 @@ func startNativeFixture(name string, port int, protocol transportProtocol, comma return process, baseURL, nil } -func startGoFixture(protocol transportProtocol) (*fixtureProcess, string, error) { +func startGoFixture(protocol transportProtocol, pushEnabled bool) (*fixtureProcess, string, error) { + args := []string{"run", "./fixtures/go-jsonrpc-server"} + if !pushEnabled { + args = append(args, "--disable-push") + } return startNativeFixture( fmt.Sprintf("go-%s-server", protocol), findFreePort(), protocol, - "go", "run", "./fixtures/go-jsonrpc-server", + "go", args..., ) } -func startRustFixture(protocol transportProtocol) (*fixtureProcess, string, error) { +func startRustFixture(protocol transportProtocol, pushEnabled bool) (*fixtureProcess, string, error) { + args := []string{"run", "--manifest-path", "fixtures/rust/Cargo.toml", "--bin", "interop-rust-server", "--"} + if !pushEnabled { + args = append(args, "--disable-push") + } return startNativeFixture( fmt.Sprintf("rust-%s-server", protocol), findFreePort(), protocol, - "cargo", "run", - "--manifest-path", "fixtures/rust/Cargo.toml", - "--bin", "interop-rust-server", - "--", + "cargo", args..., ) } -func startDotNetFixture(protocol transportProtocol) (*fixtureProcess, string, error) { +func startDotNetFixture(protocol transportProtocol, pushEnabled bool) (*fixtureProcess, string, error) { + if pushEnabled { + return nil, "", ErrUnsupportedConfig + } dotnetCmd, err := resolveDotNetCommand() if err != nil { return nil, "", err @@ -163,7 +171,7 @@ func startDotNetFixture(protocol transportProtocol) (*fixtureProcess, string, er ) } -func startPythonFixture(protocol transportProtocol) (*fixtureProcess, string, error) { +func startPythonFixture(protocol transportProtocol, pushEnabled bool) (*fixtureProcess, string, error) { uvCmd, err := resolveUvCommand() if err != nil { return nil, "", err @@ -173,6 +181,9 @@ func startPythonFixture(protocol transportProtocol) (*fixtureProcess, string, er fixtureDir := filepath.Join(componentRoot(), "fixtures", "python") baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) args, grpcAddress := protocolFixtureArgs(port, protocol) + if !pushEnabled { + args = append(args, "--disable-push") + } process, err := startFixtureProcess( fmt.Sprintf("python-%s-server", protocol), diff --git a/integrations/agntcy-a2a/tests/interop_shared_test.go b/integrations/agntcy-a2a/tests/interop_shared_test.go index 59f284cf..6d25c61c 100644 --- a/integrations/agntcy-a2a/tests/interop_shared_test.go +++ b/integrations/agntcy-a2a/tests/interop_shared_test.go @@ -55,12 +55,15 @@ const ( transportGRPC transportProtocol = "grpc" ) -type interopTarget struct { - baseURL string - serverPrefix string - expectPushSupported bool -} - +// ErrUnsupportedConfig is returned by a serverStarter when the requested +// configuration (e.g. push-enabled mode) is not supported by that server. +var ErrUnsupportedConfig = errors.New("unsupported server configuration") + +// serverStarter starts a fresh fixture process for a single (protocol, pushEnabled) pair. +// It returns the process handle, the server base URL, and any startup error. +// Implementations may return ErrUnsupportedConfig when the requested combination +// is not available (e.g. .NET with pushEnabled=true). +type serverStarter func(protocol transportProtocol, pushEnabled bool) (*fixtureProcess, string, error) type lockedBuffer struct { mu sync.Mutex diff --git a/integrations/agntcy-a2a/tests/interop_table_test.go b/integrations/agntcy-a2a/tests/interop_table_test.go index 9d9ee706..15036028 100644 --- a/integrations/agntcy-a2a/tests/interop_table_test.go +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -39,50 +39,22 @@ package tests // dotnet run) so no pre-build step or asset cache is needed. import ( - "fmt" - . "github.com/onsi/ginkgo/v2" - "github.com/onsi/gomega" ) -// ── server starter type ─────────────────────────────────────────────────────── - -// serverStarter starts a fresh fixture process for a single transport protocol. -// It returns the process handle, the server base URL, and any startup error. -type serverStarter func(protocol transportProtocol) (*fixtureProcess, string, error) - // ── interoperability matrix ─────────────────────────────────────────────────── var _ = DescribeTableSubtree( "A2A interoperability", func(newClient newClientFn, clientGRPC bool) { DescribeTableSubtree("server", - func(startServer serverStarter, serverPrefix string, serverGRPC bool, expectPushSupported bool) { + func(startServer serverStarter, serverPrefix string, serverGRPC bool) { // ContinueOnFailure is not set here: transport entries are nested // inside client entries (which are the outermost Ordered containers) // and inherit ContinueOnFailure behaviour from them. DescribeTableSubtree("transport", func(protocol transportProtocol) { - var srv *fixtureProcess - var srvURL string - BeforeAll(func() { - if protocol == transportGRPC && (!clientGRPC || !serverGRPC) { - Skip("gRPC transport is not supported by this client/server combination") - } - var err error - srv, srvURL, err = startServer(protocol) - gomega.Expect(err).NotTo(gomega.HaveOccurred(), fmt.Sprintf("start %s server", serverPrefix)) - }) - AfterAll(func() { - stopFixtureIfRunning(srv) - }) - registerBehaviors(newClient, func() interopTarget { - return interopTarget{ - baseURL: srvURL, - serverPrefix: serverPrefix, - expectPushSupported: expectPushSupported, - } - }, expectPushSupported) + registerBehaviors(newClient, startServer, protocol, serverPrefix, clientGRPC, serverGRPC) }, Entry("JSON-RPC", Ordered, Label("TRANSPORT:jsonrpc"), transportJSONRPC), Entry("REST", Ordered, Label("TRANSPORT:rest"), transportREST), @@ -91,10 +63,10 @@ var _ = DescribeTableSubtree( }, // ── server entries ──────────────────────────────────────────────── // ContinueOnFailure is inherited from the client entries (outermost Ordered). - Entry("go", Ordered, Label("SERVER:go"), startGoFixture, "go", true, true), - Entry("rust", Ordered, Label("SERVER:rust"), startRustFixture, "rust", true, true), - Entry("python", Ordered, Label("SERVER:python"), startPythonFixture, "python", true, true), - Entry("dotnet", Ordered, Label("SERVER:dotnet"), startDotNetFixture, "dotnet", false, false), + Entry("go", Ordered, Label("SERVER:go"), startGoFixture, "go", true), + Entry("rust", Ordered, Label("SERVER:rust"), startRustFixture, "rust", true), + Entry("python", Ordered, Label("SERVER:python"), startPythonFixture, "python", true), + Entry("dotnet", Ordered, Label("SERVER:dotnet"), startDotNetFixture, "dotnet", false), ) }, // ── client entries ──────────────────────────────────────────────────────── From 0e95cd824de17b5192ec658059f4f7c8e8d4a1fb Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 17:07:10 +0100 Subject: [PATCH 16/18] refactor(a2a): collapse Ordered inner containers to single It+By blocks Replace all inner Ordered+BeforeAll multi-It containers inside registerBehaviors with single It() blocks that use By() for step documentation. This allows Ginkgo to parallelise specs freely since no shared mutable state is held across Its. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_behaviors_test.go | 245 +++++++----------- 1 file changed, 89 insertions(+), 156 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_behaviors_test.go b/integrations/agntcy-a2a/tests/interop_behaviors_test.go index 86ceddd6..9b66b923 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -239,21 +239,23 @@ func registerBehaviors( startServer serverStarter, protocol transportProtocol, serverPrefix string, - clientGRPC bool, - serverGRPC bool, ) { var process *fixtureProcess var client probeClient BeforeAll(func() { - if protocol == transportGRPC && (!clientGRPC || !serverGRPC) { - Skip("gRPC transport is not supported by this client/server combination") - } var srvURL string var err error process, srvURL, err = startServer(protocol, false) + if errors.Is(err, ErrUnsupportedConfig) { + Skip("transport is not supported by this server") + } gomega.Expect(err).NotTo(gomega.HaveOccurred()) - client, err = newClient(srvURL) + + client, err = newClient(srvURL, protocol) + if errors.Is(err, ErrUnsupportedConfig) { + Skip("transport is not supported by this client") + } gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) AfterAll(func() { stopFixtureIfRunning(process) }) @@ -261,26 +263,20 @@ func registerBehaviors( // ── send message (unary) ───────────────────────────────────────────────── When("the client sends a message", func() { - Context("and the server creates a task", Ordered, func() { - var task *a2a.Task - - BeforeAll(func(ctx SpecContext) { + Context("and the server creates a task", func() { + It("receives the expected completed Task", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - task, ok = result.(*a2a.Task) + task, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue(), "expected Task result from SendMessage") - }) - It("receives a completed Task", func() { + By("task status is completed") gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - }) - It("includes the request payload in task history", func() { + By("task history contains the request payload") assertTaskHistoryPayload(task, requestText, "task history") - }) - It("includes the response text from the server", func() { + By("task status message contains the server response text") text, err := taskStatusText(task) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(expectedServerText(serverPrefix, requestText))) @@ -326,36 +322,29 @@ func registerBehaviors( }) }) - Context("and the agent requires multi-turn input", Ordered, func() { - var inputRequiredTask, continuedTask *a2a.Task - - BeforeAll(func(ctx SpecContext) { + Context("and the agent requires multi-turn input", func() { + It("completes a multi-turn conversation", func(ctx SpecContext) { + By("first turn returns input-required state") result, err := client.SendMessage(ctx, newInteropRequest(multiTurnStartRequestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - inputRequiredTask, ok = result.(*a2a.Task) + inputRequiredTask, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) gomega.Expect(inputRequiredTask.Status.State).To(gomega.Equal(a2a.TaskStateInputRequired)) + text, err := taskStatusText(inputRequiredTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", serverPrefix))) + assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") + By("second turn completes the task") continueResult, err := client.SendMessage(ctx, newInteropRequestWithIDs( multiTurnContinueRequestText, false, inputRequiredTask.ID, inputRequiredTask.ContextID, )) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - continuedTask, ok = continueResult.(*a2a.Task) + continuedTask, ok := continueResult.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) - }) - - It("the first request returns input-required state", func() { - text, err := taskStatusText(inputRequiredTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", serverPrefix))) - assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") - }) - - It("the continued request completes the task", func() { gomega.Expect(continuedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - text, err := taskStatusText(continuedTask) + text, err = taskStatusText(continuedTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server multi-turn completed", serverPrefix))) assertTaskHistoryPayloads(continuedTask, @@ -365,20 +354,20 @@ func registerBehaviors( }) }) - Context("and the server handles a long-running request", Ordered, func() { - var completedTask *a2a.Task - - BeforeAll(func(ctx SpecContext) { + Context("and the server handles a long-running request", func() { + It("returns a completed long-running Task", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(longRunningRequestText, true)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) task, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) + var completedTask *a2a.Task switch task.Status.State { case a2a.TaskStateWorking: - text, textErr := taskStatusText(task) - gomega.Expect(textErr).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running started", serverPrefix))) + By("polling until the task reaches completed state") + startText, startTextErr := taskStatusText(task) + gomega.Expect(startTextErr).NotTo(gomega.HaveOccurred()) + gomega.Expect(startText).To(gomega.Equal(fmt.Sprintf("%s server long-running started", serverPrefix))) completedTask, err = waitForProbeTaskState(ctx, client, task.ID, a2a.TaskStateCompleted) gomega.Expect(err).NotTo(gomega.HaveOccurred()) case a2a.TaskStateCompleted: @@ -386,9 +375,7 @@ func registerBehaviors( default: Fail(fmt.Sprintf("unexpected long-running task initial state: %s", task.Status.State)) } - }) - It("completes with the expected text", func() { text, err := taskStatusText(completedTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", serverPrefix))) @@ -399,16 +386,10 @@ func registerBehaviors( // ── streaming send message ─────────────────────────────────────────────── When("the client streams a message", func() { - Context("and the server creates a task", Ordered, func() { - var events []a2a.Event - - BeforeAll(func(ctx SpecContext) { - var err error - events, err = client.SendStreamingMessage(ctx, newInteropRequest(requestText, false)) + Context("and the server creates a task", func() { + It("receives at least one event with the server response text", func(ctx SpecContext) { + events, err := client.SendStreamingMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - It("receives at least one event with the server response text", func() { gomega.Expect(events).NotTo(gomega.BeEmpty()) var texts []string for _, event := range events { @@ -424,16 +405,12 @@ func registerBehaviors( }) }) - Context("and the server streams artifacts", Ordered, func() { - var streamingEvents []a2a.Event - - BeforeAll(func(ctx SpecContext) { - var err error - streamingEvents, err = client.SendStreamingMessage(ctx, newInteropRequest(streamingRequestText, false)) + Context("and the server streams artifacts", func() { + It("emits the complete streaming sequence", func(ctx SpecContext) { + streamingEvents, err := client.SendStreamingMessage(ctx, newInteropRequest(streamingRequestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - It("emits a working task at the start", func() { + By("a working task event at the start") sawStart := false for _, event := range streamingEvents { if task, ok := event.(*a2a.Task); ok { @@ -446,9 +423,8 @@ func registerBehaviors( } } gomega.Expect(sawStart).To(gomega.BeTrue(), "expected a working Task streaming-start event") - }) - It("emits artifact chunks in order with at least one append", func() { + By("artifact chunks emitted in order with at least one append") var chunks []string sawAppend := false for _, event := range streamingEvents { @@ -463,9 +439,8 @@ func registerBehaviors( } gomega.Expect(sawAppend).To(gomega.BeTrue(), "expected an append artifact update") gomega.Expect(chunks).To(gomega.Equal([]string{"streaming chunk 1", "streaming chunk 2"})) - }) - It("emits a completed status update at the end", func() { + By("a completed status update at the end") sawComplete := false for _, event := range streamingEvents { if update, ok := event.(*a2a.TaskStatusUpdateEvent); ok { @@ -484,31 +459,25 @@ func registerBehaviors( // ── get task ───────────────────────────────────────────────────────────── When("the client gets a task by ID", func() { - Context("and the task exists", Ordered, func() { - var completedTask, fetchedTask *a2a.Task - - BeforeAll(func(ctx SpecContext) { + Context("and the task exists", func() { + It("returns the completed task with full response details", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) + completedTask, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) - fetchedTask, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: completedTask.ID}) + fetchedTask, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: completedTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - It("returns the task with completed state", func() { + By("task status is completed") gomega.Expect(fetchedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - }) - It("returns the correct response text", func() { + By("response text matches the server echo") text, err := taskStatusText(fetchedTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(expectedServerText(serverPrefix, requestText))) - }) - It("returns the history payload", func() { + By("history contains the request payload") assertTaskHistoryPayload(fetchedTask, requestText, "fetched task history") }) }) @@ -523,22 +492,15 @@ func registerBehaviors( // ── list tasks ─────────────────────────────────────────────────────────── - When("the client lists tasks by context ID", Ordered, func() { - var completedTask *a2a.Task - var listedTasks *a2a.ListTasksResponse - - BeforeAll(func(ctx SpecContext) { + When("the client lists tasks by context ID", func() { + It("includes the sent task in the list", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) + completedTask, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) - listedTasks, err = client.ListTasks(ctx, &a2a.ListTasksRequest{ContextID: completedTask.ContextID}) + listedTasks, err := client.ListTasks(ctx, &a2a.ListTasksRequest{ContextID: completedTask.ContextID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - It("includes the sent task in the list", func() { gomega.Expect(listedTasks.Tasks).NotTo(gomega.BeEmpty()) gomega.Expect(listedTasks.Tasks).To(gomega.ContainElement(gomega.HaveField("ID", completedTask.ID))) }) @@ -547,51 +509,41 @@ func registerBehaviors( // ── cancel task ────────────────────────────────────────────────────────── When("the client cancels a task", func() { - Context("and the task is working", Ordered, func() { - var canceledTask, fetchedCanceledTask *a2a.Task - - BeforeAll(func(ctx SpecContext) { + Context("and the task is working", func() { + It("cancels the working task and the canceled state persists", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(pendingRequestText, true)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) workingTask, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) gomega.Expect(workingTask.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) - canceledTask, err = client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: workingTask.ID}) + canceledTask, err := client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: workingTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - fetchedCanceledTask, err = client.GetTask(ctx, &a2a.GetTaskRequest{ID: workingTask.ID}) + fetchedCanceledTask, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: workingTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - It("returns a canceled task", func() { + By("cancel response returns a canceled task") gomega.Expect(canceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) - }) - It("returns the cancel text in the task status", func() { + By("cancel response includes the server cancel text") text, err := taskStatusText(canceledTask) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(text).To(gomega.Equal(expectedCancelText(serverPrefix))) - }) - It("the canceled state persists on fetch", func() { + By("fetched task confirms the canceled state persists") gomega.Expect(fetchedCanceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) }) }) - Context("and the task is already completed", Ordered, func() { - var completedTask *a2a.Task - - BeforeAll(func(ctx SpecContext) { + Context("and the task is already completed", func() { + It("returns an error when canceling a completed task", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) + completedTask, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) - }) - It("returns an error", func(ctx SpecContext) { - _, err := client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: completedTask.ID}) + _, err = client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: completedTask.ID}) assertTaskNotCancelableError(err, "canceling a terminal task should fail") }) }) @@ -602,104 +554,85 @@ func registerBehaviors( When("the client manages push notification config", func() { // Push-enabled context: starts a dedicated push-enabled server. // If startServer returns ErrUnsupportedConfig (e.g. .NET), the context is skipped. - Context("and the server supports push notifications", Ordered, func() { - var pushProcess *fixtureProcess - var pushClient probeClient - var completedTask *a2a.Task - var createdConfig *a2a.PushConfig - - BeforeAll(func(ctx SpecContext) { - var srvURL string - var err error - pushProcess, srvURL, err = startServer(protocol, true) + Context("and the server supports push notifications", func() { + It("manages the push config lifecycle", func(ctx SpecContext) { + pushProcess, srvURL, err := startServer(protocol, true) if errors.Is(err, ErrUnsupportedConfig) { Skip("server does not support push notifications") } gomega.Expect(err).NotTo(gomega.HaveOccurred()) - pushClient, err = newClient(srvURL) + DeferCleanup(func() { stopFixtureIfRunning(pushProcess) }) + + pushClient, err := newClient(srvURL, protocol) gomega.Expect(err).NotTo(gomega.HaveOccurred()) result, err := pushClient.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) + completedTask, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) gomega.Expect(completedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) cfg := newInteropPushConfig() cfg.TaskID = completedTask.ID - createdConfig, err = pushClient.CreatePushConfig(ctx, cfg) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - AfterAll(func() { stopFixtureIfRunning(pushProcess) }) - It("creates a push config with expected fields", func() { + By("creating a push config") + createdConfig, err := pushClient.CreatePushConfig(ctx, cfg) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) assertTaskPushConfig(createdConfig, completedTask.ID, newInteropPushConfig(), "created push config") - }) - It("fetches the push config by ID", func(ctx SpecContext) { + By("fetching the push config by ID") fetched, err := pushClient.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ TaskID: completedTask.ID, ID: createdConfig.ID, }) gomega.Expect(err).NotTo(gomega.HaveOccurred()) assertTaskPushConfig(fetched, completedTask.ID, newInteropPushConfig(), "fetched push config") - }) - It("lists exactly one push config for the task", func(ctx SpecContext) { + By("listing exactly one push config for the task") listed, err := pushClient.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(listed).To(gomega.HaveLen(1)) assertTaskPushConfig(listed[0], completedTask.ID, newInteropPushConfig(), "listed push config") - }) - It("deletes the push config and the list is empty afterward", func(ctx SpecContext) { - err := pushClient.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + By("deleting the push config and confirming the list is empty") + err = pushClient.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ TaskID: completedTask.ID, ID: createdConfig.ID, }) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - listed, err := pushClient.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + listed, err = pushClient.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(listed).To(gomega.BeEmpty()) }) }) // Push-disabled context: reuses the outer push-disabled client (works for all 4 languages). - Context("and the server does not support push notifications", Ordered, func() { - var completedTask *a2a.Task - - BeforeAll(func(ctx SpecContext) { + Context("and the server does not support push notifications", func() { + It("rejects all push config operations", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - var ok bool - completedTask, ok = result.(*a2a.Task) + completedTask, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) - }) - It("returns an error for CreatePushConfig", func(ctx SpecContext) { + By("CreatePushConfig returns an unsupported error") cfg := newInteropPushConfig() cfg.TaskID = completedTask.ID - _, err := client.CreatePushConfig(ctx, cfg) + _, err = client.CreatePushConfig(ctx, cfg) assertPushUnsupportedError(err, "create push config unsupported") - }) - It("returns an error for GetPushConfig", func(ctx SpecContext) { - _, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + By("GetPushConfig returns an unsupported error") + _, err = client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ TaskID: completedTask.ID, ID: "interop-config", }) assertPushUnsupportedError(err, "get push config unsupported") - }) - It("returns an error for ListPushConfigs", func(ctx SpecContext) { - _, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + By("ListPushConfigs returns an unsupported error") + _, err = client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) assertPushUnsupportedError(err, "list push config unsupported") - }) - It("returns an error for DeletePushConfig", func(ctx SpecContext) { - err := client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + By("DeletePushConfig returns an unsupported error") + err = client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ TaskID: completedTask.ID, ID: "interop-config", }) From 2c51e8e8d078dd4791250cc8dde15ef704c0916b Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 18:44:57 +0100 Subject: [PATCH 17/18] refactor(a2a): create a fresh server per spec via JustBeforeEach Replace the outer BeforeAll/AfterAll in registerBehaviors with BeforeEach/JustBeforeEach/AfterEach so every spec gets its own isolated server and client, preventing state crosstalk between specs. An enablePushSupport bool (defaulting to false in BeforeEach) lets the push-enabled Context override the flag before JustBeforeEach starts the server, removing the need for a dedicated inline startServer call and DeferCleanup inside the push-enabled It block. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_behaviors_test.go | 64 ++++++++++--------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_behaviors_test.go b/integrations/agntcy-a2a/tests/interop_behaviors_test.go index 9b66b923..79390ddb 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -228,25 +228,32 @@ func waitForProbeTaskState(ctx context.Context, client probeClient, taskID a2a.T // ─── registerBehaviors ─────────────────────────────────────────────────────── // registerBehaviors registers the full interop spec tree against the given client factory -// and server starter. It starts the main server (push-disabled) in the outer BeforeAll and -// stops it in AfterAll. The containing Context MUST be Ordered. +// and server starter. A fresh server and client are created for every spec via +// JustBeforeEach and torn down in AfterEach, preventing any state crosstalk between specs. // -// The push notification When block always registers both a push-enabled context (which -// starts a dedicated push-enabled server and skips via ErrUnsupportedConfig if unavailable) -// and a push-disabled context (which reuses the outer client). +// enablePushSupport defaults to false in the outer BeforeEach; the push-enabled +// Context overrides it to true in its own BeforeEach so that JustBeforeEach +// starts the server with push support enabled for those specs. func registerBehaviors( newClient newClientFn, startServer serverStarter, protocol transportProtocol, serverPrefix string, ) { - var process *fixtureProcess - var client probeClient + var ( + process *fixtureProcess + client probeClient + enablePushSupport bool + ) + + BeforeEach(func() { + enablePushSupport = false + }) - BeforeAll(func() { + JustBeforeEach(func() { var srvURL string var err error - process, srvURL, err = startServer(protocol, false) + process, srvURL, err = startServer(protocol, enablePushSupport) if errors.Is(err, ErrUnsupportedConfig) { Skip("transport is not supported by this server") } @@ -258,7 +265,8 @@ func registerBehaviors( } gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - AfterAll(func() { stopFixtureIfRunning(process) }) + + AfterEach(func() { stopFixtureIfRunning(process) }) // ── send message (unary) ───────────────────────────────────────────────── @@ -552,21 +560,15 @@ func registerBehaviors( // ── push notification config ───────────────────────────────────────────── When("the client manages push notification config", func() { - // Push-enabled context: starts a dedicated push-enabled server. - // If startServer returns ErrUnsupportedConfig (e.g. .NET), the context is skipped. + // Push-enabled context: JustBeforeEach starts the server with push enabled. + // If startServer returns ErrUnsupportedConfig (e.g. .NET), the spec is skipped. Context("and the server supports push notifications", func() { - It("manages the push config lifecycle", func(ctx SpecContext) { - pushProcess, srvURL, err := startServer(protocol, true) - if errors.Is(err, ErrUnsupportedConfig) { - Skip("server does not support push notifications") - } - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - DeferCleanup(func() { stopFixtureIfRunning(pushProcess) }) - - pushClient, err := newClient(srvURL, protocol) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) + BeforeEach(func() { + enablePushSupport = true + }) - result, err := pushClient.SendMessage(ctx, newInteropRequest(requestText, false)) + It("manages the push config lifecycle", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) completedTask, ok := result.(*a2a.Task) gomega.Expect(ok).To(gomega.BeTrue()) @@ -576,12 +578,12 @@ func registerBehaviors( cfg.TaskID = completedTask.ID By("creating a push config") - createdConfig, err := pushClient.CreatePushConfig(ctx, cfg) + createdConfig, err := client.CreatePushConfig(ctx, cfg) gomega.Expect(err).NotTo(gomega.HaveOccurred()) assertTaskPushConfig(createdConfig, completedTask.ID, newInteropPushConfig(), "created push config") By("fetching the push config by ID") - fetched, err := pushClient.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + fetched, err := client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ TaskID: completedTask.ID, ID: createdConfig.ID, }) @@ -589,25 +591,29 @@ func registerBehaviors( assertTaskPushConfig(fetched, completedTask.ID, newInteropPushConfig(), "fetched push config") By("listing exactly one push config for the task") - listed, err := pushClient.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + listed, err := client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(listed).To(gomega.HaveLen(1)) assertTaskPushConfig(listed[0], completedTask.ID, newInteropPushConfig(), "listed push config") By("deleting the push config and confirming the list is empty") - err = pushClient.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + err = client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ TaskID: completedTask.ID, ID: createdConfig.ID, }) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - listed, err = pushClient.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + listed, err = client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) gomega.Expect(listed).To(gomega.BeEmpty()) }) }) - // Push-disabled context: reuses the outer push-disabled client (works for all 4 languages). + // Push-disabled context: server started without push support (works for all 4 languages). Context("and the server does not support push notifications", func() { + BeforeEach(func() { + enablePushSupport = false + }) + It("rejects all push config operations", func(ctx SpecContext) { result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) From 041d35e1f09083d3768b9d82f5fd408024176f78 Mon Sep 17 00:00:00 2001 From: Sam Betts <1769706+Tehsmash@users.noreply.github.com> Date: Thu, 28 May 2026 18:55:16 +0100 Subject: [PATCH 18/18] refactor(a2a): group send-message specs under returnImmediately=false/true contexts Add a Context("and returnImmediately=false") wrapper around the five existing synchronous specs and a Context("and returnImmediately=true") wrapper around the long-running spec. Add a new spec under the true context that sends pendingRequestText and asserts the server returns a Task in Working state immediately. Signed-off-by: Sam Betts <1769706+Tehsmash@users.noreply.github.com> --- .../tests/interop_behaviors_test.go | 212 ++++++++++-------- 1 file changed, 113 insertions(+), 99 deletions(-) diff --git a/integrations/agntcy-a2a/tests/interop_behaviors_test.go b/integrations/agntcy-a2a/tests/interop_behaviors_test.go index 79390ddb..1a271493 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -271,122 +271,136 @@ func registerBehaviors( // ── send message (unary) ───────────────────────────────────────────────── When("the client sends a message", func() { - Context("and the server creates a task", func() { - It("receives the expected completed Task", func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - task, ok := result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue(), "expected Task result from SendMessage") + Context("and returnImmediately=false", func() { + Context("and the server creates a task", func() { + It("receives the expected completed Task", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(requestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue(), "expected Task result from SendMessage") - By("task status is completed") - gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + By("task status is completed") + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - By("task history contains the request payload") - assertTaskHistoryPayload(task, requestText, "task history") + By("task history contains the request payload") + assertTaskHistoryPayload(task, requestText, "task history") - By("task status message contains the server response text") - text, err := taskStatusText(task) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(expectedServerText(serverPrefix, requestText))) + By("task status message contains the server response text") + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(expectedServerText(serverPrefix, requestText))) + }) }) - }) - Context("and the server returns a message-only response", func() { - It("the client receives a Message result", func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(messageOnlyRequestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - message, ok := result.(*a2a.Message) - gomega.Expect(ok).To(gomega.BeTrue(), "expected Message result from SendMessage") - text, err := firstMessageText(message) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server message-only response", serverPrefix))) + Context("and the server returns a message-only response", func() { + It("the client receives a Message result", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(messageOnlyRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + message, ok := result.(*a2a.Message) + gomega.Expect(ok).To(gomega.BeTrue(), "expected Message result from SendMessage") + text, err := firstMessageText(message) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server message-only response", serverPrefix))) + }) }) - }) - Context("and the server fails the task", func() { - It("the client receives a failed task", func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(taskFailureRequestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - task, ok := result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateFailed)) - text, err := taskStatusText(task) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server failed task", serverPrefix))) + Context("and the server fails the task", func() { + It("the client receives a failed task", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(taskFailureRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateFailed)) + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server failed task", serverPrefix))) + }) }) - }) - Context("and the server returns rich data types", func() { - It("the completed task includes structured artifact data", func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(dataTypesRequestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - task, ok := result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - text, err := taskStatusText(task) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server data-types ready", serverPrefix))) - assertDataTypesTask(task, "data-types") + Context("and the server returns rich data types", func() { + It("the completed task includes structured artifact data", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(dataTypesRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + text, err := taskStatusText(task) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server data-types ready", serverPrefix))) + assertDataTypesTask(task, "data-types") + }) }) - }) - - Context("and the agent requires multi-turn input", func() { - It("completes a multi-turn conversation", func(ctx SpecContext) { - By("first turn returns input-required state") - result, err := client.SendMessage(ctx, newInteropRequest(multiTurnStartRequestText, false)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - inputRequiredTask, ok := result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(inputRequiredTask.Status.State).To(gomega.Equal(a2a.TaskStateInputRequired)) - text, err := taskStatusText(inputRequiredTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", serverPrefix))) - assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") - By("second turn completes the task") - continueResult, err := client.SendMessage(ctx, newInteropRequestWithIDs( - multiTurnContinueRequestText, false, - inputRequiredTask.ID, inputRequiredTask.ContextID, - )) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - continuedTask, ok := continueResult.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(continuedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) - text, err = taskStatusText(continuedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server multi-turn completed", serverPrefix))) - assertTaskHistoryPayloads(continuedTask, - []string{multiTurnStartRequestText, multiTurnContinueRequestText}, - "multi-turn continuation", - ) + Context("and the agent requires multi-turn input", func() { + It("completes a multi-turn conversation", func(ctx SpecContext) { + By("first turn returns input-required state") + result, err := client.SendMessage(ctx, newInteropRequest(multiTurnStartRequestText, false)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + inputRequiredTask, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(inputRequiredTask.Status.State).To(gomega.Equal(a2a.TaskStateInputRequired)) + text, err := taskStatusText(inputRequiredTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server needs more input", serverPrefix))) + assertTaskHistoryPayload(inputRequiredTask, multiTurnStartRequestText, "multi-turn start") + + By("second turn completes the task") + continueResult, err := client.SendMessage(ctx, newInteropRequestWithIDs( + multiTurnContinueRequestText, false, + inputRequiredTask.ID, inputRequiredTask.ContextID, + )) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + continuedTask, ok := continueResult.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(continuedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + text, err = taskStatusText(continuedTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server multi-turn completed", serverPrefix))) + assertTaskHistoryPayloads(continuedTask, + []string{multiTurnStartRequestText, multiTurnContinueRequestText}, + "multi-turn continuation", + ) + }) }) }) - Context("and the server handles a long-running request", func() { - It("returns a completed long-running Task", func(ctx SpecContext) { - result, err := client.SendMessage(ctx, newInteropRequest(longRunningRequestText, true)) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - task, ok := result.(*a2a.Task) - gomega.Expect(ok).To(gomega.BeTrue()) + Context("and returnImmediately=true", func() { + Context("and the server creates a task", func() { + It("receives a working task", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(pendingRequestText, true)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue(), "expected Task result from SendMessage") + gomega.Expect(task.Status.State).To(gomega.Equal(a2a.TaskStateWorking)) + }) + }) - var completedTask *a2a.Task - switch task.Status.State { - case a2a.TaskStateWorking: - By("polling until the task reaches completed state") - startText, startTextErr := taskStatusText(task) - gomega.Expect(startTextErr).NotTo(gomega.HaveOccurred()) - gomega.Expect(startText).To(gomega.Equal(fmt.Sprintf("%s server long-running started", serverPrefix))) - completedTask, err = waitForProbeTaskState(ctx, client, task.ID, a2a.TaskStateCompleted) + Context("and the server handles a long-running request", func() { + It("returns a completed long-running Task", func(ctx SpecContext) { + result, err := client.SendMessage(ctx, newInteropRequest(longRunningRequestText, true)) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - case a2a.TaskStateCompleted: - completedTask = task - default: - Fail(fmt.Sprintf("unexpected long-running task initial state: %s", task.Status.State)) - } + task, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + + var completedTask *a2a.Task + switch task.Status.State { + case a2a.TaskStateWorking: + By("polling until the task reaches completed state") + startText, startTextErr := taskStatusText(task) + gomega.Expect(startTextErr).NotTo(gomega.HaveOccurred()) + gomega.Expect(startText).To(gomega.Equal(fmt.Sprintf("%s server long-running started", serverPrefix))) + completedTask, err = waitForProbeTaskState(ctx, client, task.ID, a2a.TaskStateCompleted) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + case a2a.TaskStateCompleted: + completedTask = task + default: + Fail(fmt.Sprintf("unexpected long-running task initial state: %s", task.Status.State)) + } - text, err := taskStatusText(completedTask) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", serverPrefix))) + text, err := taskStatusText(completedTask) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(text).To(gomega.Equal(fmt.Sprintf("%s server long-running complete", serverPrefix))) + }) }) }) })