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/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/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/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/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-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/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 569c7575..1a271493 100644 --- a/integrations/agntcy-a2a/tests/interop_behaviors_test.go +++ b/integrations/agntcy-a2a/tests/interop_behaviors_test.go @@ -3,332 +3,671 @@ 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" - ginkgo "github.com/onsi/ginkgo/v2" + "github.com/a2aproject/a2a-go/v2/a2a" + . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) -type interopTarget struct { - baseURL string - serverPrefix string - expectPushSupported bool -} - -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) -} - -type interopSpecCase struct { - name string - labels []string - harness interopHarness - target func() interopTarget -} +// ─── helpers ───────────────────────────────────────────────────────────────── -type interopBehaviorSpec struct { - name string - labels []string - run func(ctx context.Context, harness interopHarness, target interopTarget) +func newInteropRequest(text string, returnImmediately bool) *a2a.SendMessageRequest { + return newInteropRequestWithIDs(text, returnImmediately, "", "") } -type interopClientMatrixSpec struct { - label string - displayName string - harness interopHarness -} +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, + } -type interopServerMatrixSpec struct { - label string - displayName string - serverPrefix string - expectPushSupported bool - urls map[transportProtocol]func() string + return &a2a.SendMessageRequest{ + Message: message, + Config: &a2a.SendMessageConfig{ + ReturnImmediately: returnImmediately, + }, + } } -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) +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", }, - }, - { - 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 interopTargetFor(getBaseURL func() string, serverPrefix string, expectPushSupported bool) func() interopTarget { - return func() interopTarget { - return interopTarget{ - baseURL: getBaseURL(), - serverPrefix: serverPrefix, - expectPushSupported: expectPushSupported, +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 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() - - behavior.run(requestCtx, harness, target()) +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 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 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 registerInteropTransportMatrix( - protocols []transportProtocol, - clients []interopClientMatrixSpec, - servers []interopServerMatrixSpec, - pairLabelOverrides map[string]string, -) { - registerInteropTransportMatrixFull(protocols, clients, servers, pairLabelOverrides, nil, true) +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 registerInteropTransportMatrixWithOverrides( - protocols []transportProtocol, - clients []interopClientMatrixSpec, - servers []interopServerMatrixSpec, - pairLabelOverrides map[string]string, - harnessOverrides map[string]interopHarness, -) { - registerInteropTransportMatrixFull(protocols, clients, servers, pairLabelOverrides, harnessOverrides, true) -} +func assertMessageInteropPayload(message *a2a.Message, expectedText string, kind string) { + gomega.Expect(message).NotTo(gomega.BeNil(), kind) -func registerInteropSelfTestMatrix( - protocols []transportProtocol, - clients []interopClientMatrixSpec, - servers []interopServerMatrixSpec, -) { - registerInteropTransportMatrixFull(protocols, clients, servers, nil, nil, false) -} + 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) -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 - } + 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) +} - ginkgo.Context(interopTransportContextName(protocol), func() { - registerInteropCaseContexts(cases...) - }) - } +func assertTaskHistoryPayload(task *a2a.Task, expectedText string, kind string) { + assertTaskHistoryPayloads(task, []string{expectedText}, 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 - } - - harness := client.harness - if override, ok := harnessOverrides[pairKey]; ok { - harness = override - } - - 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 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) } - - return cases } -func interopTransportContextName(protocol transportProtocol) string { - return string(protocol) +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 interopCaseName(_ transportProtocol, clientDisplayName string, serverDisplayName string) string { - return fmt.Sprintf("%s→%s", clientDisplayName, serverDisplayName) +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 interopPairLabel(clientLabel string, serverLabel string, overrides map[string]string) string { - if label, ok := overrides[interopPairKey(clientLabel, serverLabel)]; ok { - return label - } +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) +} - return clientLabel + "-" + serverLabel +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) } -func interopPairKey(clientLabel string, serverLabel string) string { - return clientLabel + ":" + serverLabel +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) } -type probeHarnessRunner func( - ctx context.Context, - baseURL string, - serverPrefix string, - options rustProbeOptions, -) (string, error) +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) + } -type externalProbeHarness struct { - options rustProbeOptions - run probeHarnessRunner + for _, expectedSkill := range expectedSkillIDs { + gomega.Expect(card.Skills).To(gomega.ContainElement(gomega.HaveField("ID", expectedSkill)), kind) + } } -func (harness externalProbeHarness) optionsForScenario(scenario probeScenario) rustProbeOptions { - options := harness.options - options.scenario = scenario - return options +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 nil, fmt.Errorf("timed out waiting for task %s to reach state %s", taskID, expectedState) } -func (harness externalProbeHarness) assertScenario( - ctx context.Context, - target interopTarget, - scenario probeScenario, +// ─── registerBehaviors ─────────────────────────────────────────────────────── + +// registerBehaviors registers the full interop spec tree against the given client factory +// 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. +// +// 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, ) { - output, err := harness.run( - ctx, - target.baseURL, - target.serverPrefix, - harness.optionsForScenario(scenario), + var ( + process *fixtureProcess + client probeClient + enablePushSupport bool ) - gomega.Expect(err).NotTo(gomega.HaveOccurred(), output) -} -func (harness externalProbeHarness) AssertTaskStreaming(ctx context.Context, target interopTarget) { - harness.assertScenario(ctx, target, probeScenarioTaskStreaming) -} + BeforeEach(func() { + enablePushSupport = false + }) -func (harness externalProbeHarness) AssertTaskLifecycle(ctx context.Context, target interopTarget) { - harness.assertScenario(ctx, target, probeScenarioTaskLifecycle) -} + JustBeforeEach(func() { + var srvURL string + var err error + process, srvURL, err = startServer(protocol, enablePushSupport) + if errors.Is(err, ErrUnsupportedConfig) { + Skip("transport is not supported by this server") + } + gomega.Expect(err).NotTo(gomega.HaveOccurred()) -func (harness externalProbeHarness) AssertPushConfig(ctx context.Context, target interopTarget) { - harness.assertScenario(ctx, target, probeScenarioPushConfig) -} + client, err = newClient(srvURL, protocol) + if errors.Is(err, ErrUnsupportedConfig) { + Skip("transport is not supported by this client") + } + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) -func (harness externalProbeHarness) AssertScenarioParity(ctx context.Context, target interopTarget) { - harness.assertScenario(ctx, target, probeScenarioParity) -} + AfterEach(func() { stopFixtureIfRunning(process) }) -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) - }, - } -} + // ── send message (unary) ───────────────────────────────────────────────── -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) - }, - } -} + When("the client sends a message", func() { + 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") -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) - }, - } + 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 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 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 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 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)) + }) + }) + + 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: + 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))) + }) + }) + }) + }) + + // ── streaming send message ─────────────────────────────────────────────── + + When("the client streams a message", func() { + 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()) + 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(serverPrefix, requestText), + )) + }) + }) + + 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()) + + By("a working task event at the start") + 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", serverPrefix))) + sawStart = true + break + } + } + gomega.Expect(sawStart).To(gomega.BeTrue(), "expected a working Task streaming-start event") + + By("artifact chunks emitted in order with at least one append") + 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"})) + + By("a completed status update at the end") + 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", serverPrefix))) + sawComplete = true + } + } + gomega.Expect(sawComplete).To(gomega.BeTrue(), "expected a completed TaskStatusUpdateEvent") + }) + }) + }) + + // ── get task ───────────────────────────────────────────────────────────── + + When("the client gets a task by ID", func() { + 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()) + completedTask, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + + fetchedTask, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: completedTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + By("task status is completed") + gomega.Expect(fetchedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + + 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))) + + By("history contains the request payload") + assertTaskHistoryPayload(fetchedTask, requestText, "fetched task history") + }) + }) + + 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") + }) + }) + }) + + // ── list tasks ─────────────────────────────────────────────────────────── + + 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()) + 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()) + gomega.Expect(listedTasks.Tasks).NotTo(gomega.BeEmpty()) + gomega.Expect(listedTasks.Tasks).To(gomega.ContainElement(gomega.HaveField("ID", completedTask.ID))) + }) + }) + + // ── cancel task ────────────────────────────────────────────────────────── + + When("the client cancels a task", func() { + 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}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + fetchedCanceledTask, err := client.GetTask(ctx, &a2a.GetTaskRequest{ID: workingTask.ID}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + By("cancel response returns a canceled task") + gomega.Expect(canceledTask.Status.State).To(gomega.Equal(a2a.TaskStateCanceled)) + + 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))) + + 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", 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()) + completedTask, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + + _, err = client.CancelTask(ctx, &a2a.CancelTaskRequest{ID: completedTask.ID}) + assertTaskNotCancelableError(err, "canceling a terminal task should fail") + }) + }) + }) + + // ── push notification config ───────────────────────────────────────────── + + When("the client manages push notification config", func() { + // 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() { + BeforeEach(func() { + enablePushSupport = true + }) + + 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()) + gomega.Expect(completedTask.Status.State).To(gomega.Equal(a2a.TaskStateCompleted)) + + cfg := newInteropPushConfig() + cfg.TaskID = completedTask.ID + + By("creating a push config") + 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 := 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") + + By("listing exactly one push config for the task") + 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 = 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()) + }) + }) + + // 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()) + completedTask, ok := result.(*a2a.Task) + gomega.Expect(ok).To(gomega.BeTrue()) + + By("CreatePushConfig returns an unsupported error") + cfg := newInteropPushConfig() + cfg.TaskID = completedTask.ID + _, err = client.CreatePushConfig(ctx, cfg) + assertPushUnsupportedError(err, "create push config unsupported") + + By("GetPushConfig returns an unsupported error") + _, err = client.GetPushConfig(ctx, &a2a.GetTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: "interop-config", + }) + assertPushUnsupportedError(err, "get push config unsupported") + + By("ListPushConfigs returns an unsupported error") + _, err = client.ListPushConfigs(ctx, &a2a.ListTaskPushConfigRequest{TaskID: completedTask.ID}) + assertPushUnsupportedError(err, "list push config unsupported") + + By("DeletePushConfig returns an unsupported error") + err = client.DeletePushConfig(ctx, &a2a.DeleteTaskPushConfigRequest{ + TaskID: completedTask.ID, + ID: "interop-config", + }) + assertPushUnsupportedError(err, "delete push config unsupported") + }) + }) + }) + + // ── extended agent card ────────────────────────────────────────────────── + + 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_go_dotnet_test.go b/integrations/agntcy-a2a/tests/interop_go_dotnet_test.go deleted file mode 100644 index 93f97f3f..00000000 --- a/integrations/agntcy-a2a/tests/interop_go_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 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. - -import ( - 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 - ) - - 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) - }) - - registerInteropTransportMatrixWithOverrides( - protocols, - clients, - servers, - nil, - clientHarnessOverrides, - ) -}) diff --git a/integrations/agntcy-a2a/tests/interop_launchers_test.go b/integrations/agntcy-a2a/tests/interop_launchers_test.go index 3c22d0a5..42b989d5 100644 --- a/integrations/agntcy-a2a/tests/interop_launchers_test.go +++ b/integrations/agntcy-a2a/tests/interop_launchers_test.go @@ -3,11 +3,9 @@ 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. -// 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. +// 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" @@ -18,75 +16,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")), - 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 { - _ = 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 @@ -100,33 +29,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 @@ -156,105 +58,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...) @@ -301,7 +104,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( @@ -309,7 +112,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 @@ -324,36 +127,70 @@ 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 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", args..., + ) } -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( - fmt.Sprintf("dotnet-%s-server", protocol), - componentRoot(), - baseURL+"/.well-known/agent-card.json", - binaries.dotnetCommand, - append([]string{binaries.dotnetServerDL}, args...)..., +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", args..., ) +} + +func startDotNetFixture(protocol transportProtocol, pushEnabled bool) (*fixtureProcess, string, error) { + if pushEnabled { + return nil, "", ErrUnsupportedConfig + } + dotnetCmd, err := resolveDotNetCommand() if err != nil { return nil, "", err } - - return process, baseURL, nil + return startNativeFixture( + fmt.Sprintf("dotnet-%s-server", protocol), + findFreePort(), + protocol, + dotnetCmd, "run", + "--project", "./fixtures/dotnet/InteropServer", + "--", + ) } -func startPythonFixture(assets pythonFixtureAssets, port int, protocol transportProtocol) (*fixtureProcess, string, error) { +func startPythonFixture(protocol transportProtocol, pushEnabled bool) (*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) + if !pushEnabled { + args = append(args, "--disable-push") + } + 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 @@ -367,101 +204,3 @@ 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 -} - -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 -} - -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 -} 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..26c0f685 --- /dev/null +++ b/integrations/agntcy-a2a/tests/interop_probe_clients_test.go @@ -0,0 +1,361 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package tests + +// 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 +// interface, so no per-language serialization logic is needed beyond what call() does. + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "strings" + + "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"` + 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") +} + +// 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 + 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. +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.call(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.call(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.call(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.call(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.call(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.call(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.call(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.call(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.call(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.call(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 newGoProbeClient(baseURL string) (probeClient, error) { + return &externalProbeClient{ + basecmd: []string{"go", "run", "./fixtures/go-probe"}, + baseURL: baseURL, + }, nil +} + +func newRustProbeClient(baseURL string) (probeClient, error) { + return &externalProbeClient{ + basecmd: []string{ + "cargo", "run", + "--manifest-path", "fixtures/rust/Cargo.toml", + "--bin", "interop-rust-probe", + "--", + }, + baseURL: baseURL, + }, nil +} + +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, + dir: filepath.Join(componentRoot(), "fixtures", "python"), + }, nil +} + +func newDotNetProbeClient(baseURL string) (probeClient, error) { + dotnetCmd, err := resolveDotNetCommand() + if err != nil { + return nil, err + } + return &externalProbeClient{ + basecmd: []string{ + dotnetCmd, "run", + "--project", "./fixtures/dotnet/InteropProbe", + "--", + }, + baseURL: baseURL, + }, nil +} 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 55833905..00000000 --- a/integrations/agntcy-a2a/tests/interop_python_dotnet_test.go +++ /dev/null @@ -1,114 +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. 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. - -import ( - 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 - ) - - 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() { - runtime.stopFixtures(fixtures) - removeTempDir(dotNetAssets.tempDir) - }) - - registerInteropTransportMatrixWithOverrides( - protocols, - clients, - servers, - nil, - clientHarnessOverrides, - ) -}) 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 6e20f980..00000000 --- a/integrations/agntcy-a2a/tests/interop_python_go_test.go +++ /dev/null @@ -1,110 +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. 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. - -import ( - 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 - ) - - 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() { - runtime.stopFixtures(fixtures) - removeTempDir(goAssets.tempDir) - }) - - registerInteropTransportMatrix( - protocols, - clients, - servers, - nil, - ) -}) 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 b9a926dd..00000000 --- a/integrations/agntcy-a2a/tests/interop_rust_dotnet_test.go +++ /dev/null @@ -1,114 +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. 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. - -import ( - 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() { - runtime.stopFixtures(fixtures) - removeTempDir(binaries.tempDir) - }) - - registerInteropTransportMatrixWithOverrides( - protocols, - clients, - servers, - map[string]string{interopPairKey("rust", "rust"): "rust-rust-dotnet"}, - clientHarnessOverrides, - ) -}) 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 36610dab..00000000 --- a/integrations/agntcy-a2a/tests/interop_rust_go_test.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -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 ( - 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 - - 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) - }) - - registerInteropTransportMatrix( - protocols, - clients, - servers, - nil, - ) -}) 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 266fa5ac..00000000 --- a/integrations/agntcy-a2a/tests/interop_rust_python_test.go +++ /dev/null @@ -1,115 +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. It keeps the matrix declaration, -// fixture lifecycle, and transport coverage local to this slice while reusing -// the shared Go-authored behavior assertions. - -import ( - 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 - ) - - 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() { - runtime.stopFixtures(fixtures) - removeTempDir(rustAssets.tempDir) - }) - - registerInteropTransportMatrix( - protocols, - clients, - servers, - nil, - ) -}) 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 3d92a04e..00000000 --- a/integrations/agntcy-a2a/tests/interop_self_dotnet_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -import ( - 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...), - } - - ginkgo.BeforeAll(func() { - var err error - 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) - }) - - registerInteropSelfTestMatrix(protocols, clients, servers) -}) 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 b90f358e..00000000 --- a/integrations/agntcy-a2a/tests/interop_self_go_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -import ( - 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 - - 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) - }) - - registerInteropSelfTestMatrix(protocols, clients, servers) -}) 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 df80f1b2..00000000 --- a/integrations/agntcy-a2a/tests/interop_self_python_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -import ( - 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...), - } - - ginkgo.BeforeAll(func() { - var err error - pythonAssets, err = buildPythonFixtureAssets() - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - err = startInteropSuiteFixtures(runtime, fixtures) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - }) - - ginkgo.AfterAll(func() { - runtime.stopFixtures(fixtures) - }) - - 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 deleted file mode 100644 index 73d1bcbd..00000000 --- a/integrations/agntcy-a2a/tests/interop_self_rust_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -package tests - -import ( - 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 - - 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) - }) - - registerInteropSelfTestMatrix(protocols, clients, servers) -}) diff --git a/integrations/agntcy-a2a/tests/interop_shared_test.go b/integrations/agntcy-a2a/tests/interop_shared_test.go index b58e6d4e..6d25c61c 100644 --- a/integrations/agntcy-a2a/tests/interop_shared_test.go +++ b/integrations/agntcy-a2a/tests/interop_shared_test.go @@ -3,11 +3,9 @@ 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 -// wiring belongs in the suite wrapper files. +// This file holds the reusable interop test primitives: shared constants, transport +// enums, fixture process management, and low-level helpers. +// New behavior assertions belong in interop_behaviors_test.go. import ( "bytes" @@ -16,7 +14,6 @@ import ( "fmt" "net" "net/http" - "os" "os/exec" "path/filepath" "runtime" @@ -29,8 +26,6 @@ import ( const ( fixtureReadyTimeout = 20 * time.Second - probeTimeout = 2 * time.Minute - buildTimeout = 3 * time.Minute stopTimeout = 5 * time.Second requestText = "ping" pendingRequestText = "pending" @@ -60,65 +55,15 @@ const ( transportGRPC transportProtocol = "grpc" ) -type probeScenario string +// 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") -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 dotNetProbeOptions = rustProbeOptions - -type fixtureBinaries struct { - tempDir string - goServer 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, - } -} +// 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 @@ -254,106 +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 - 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) 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] - 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 -} - -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...), - } -} - 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 new file mode 100644 index 00000000..15036028 --- /dev/null +++ b/integrations/agntcy-a2a/tests/interop_table_test.go @@ -0,0 +1,77 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +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 +// +// 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}' +// +// 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. +// +// 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 ( + . "github.com/onsi/ginkgo/v2" +) + +// ── interoperability matrix ─────────────────────────────────────────────────── + +var _ = DescribeTableSubtree( + "A2A interoperability", + func(newClient newClientFn, clientGRPC bool) { + DescribeTableSubtree("server", + 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) { + registerBehaviors(newClient, startServer, protocol, serverPrefix, clientGRPC, serverGRPC) + }, + 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, 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 ──────────────────────────────────────────────────────── + 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), +) 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 deleted file mode 100644 index f570a7d2..00000000 --- a/integrations/agntcy-a2a/tests/native_go_launchers_test.go +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright AGNTCY Contributors (https://github.com/agntcy) -// SPDX-License-Identifier: Apache-2.0 - -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, outputPath 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)) - } - - 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")), - } - - if err := buildGoFixture(root, binaries.goServer); 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) -} 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") }