From 4427a19cd3ce10af1d7adf465809cd27f5d4fb1b Mon Sep 17 00:00:00 2001 From: Zaf Agent Date: Sat, 21 Mar 2026 05:47:32 +0000 Subject: [PATCH 1/2] feat: client sends v1.0 PascalCase method names by default - Client now sends PascalCase method names (SendMessage, GetTask, etc.) matching the v1.0 A2A spec by default - Add method_style option (:v1 default, :legacy for v0.3 servers) - Server already accepted both formats via @method_aliases (fix comment) - Add 4 missing PascalCase server dispatch tests (SubscribeToTask, GetTaskPushNotificationConfig, ListTaskPushNotificationConfigs, DeleteTaskPushNotificationConfig) - Add 4 client method_style tests (v1 default + legacy fallback) - All 397 tests pass, 0 failures --- lib/a2a/client.ex | 57 ++++++++++++++++++++++++++++++----- lib/a2a/jsonrpc.ex | 2 +- test/a2a/client_test.exs | 62 +++++++++++++++++++++++++++++++++++++-- test/a2a/jsonrpc_test.exs | 28 ++++++++++++++++++ 4 files changed, 138 insertions(+), 11 deletions(-) diff --git a/lib/a2a/client.ex b/lib/a2a/client.ex index bf08c3f..69e7872 100644 --- a/lib/a2a/client.ex +++ b/lib/a2a/client.ex @@ -37,6 +37,14 @@ if Code.ensure_loaded?(Req) do - `:metadata` — arbitrary metadata map - `:headers` — additional HTTP headers - `:timeout` — HTTP request timeout in ms + + ## Method Style + + By default the client sends v1.0 PascalCase method names (e.g. + `SendMessage`). To communicate with a v0.3 server, pass + `method_style: :legacy` when creating the client: + + client = A2A.Client.new(url, method_style: :legacy) """ alias A2A.JSONRPC.Error @@ -45,10 +53,11 @@ if Code.ensure_loaded?(Req) do @type t :: %__MODULE__{ url: String.t(), - req: Req.Request.t() + req: Req.Request.t(), + method_style: :v1 | :legacy } - defstruct [:url, :req] + defstruct [:url, :req, method_style: :v1] @doc """ Creates a new client struct. @@ -56,10 +65,16 @@ if Code.ensure_loaded?(Req) do Accepts a URL string or `%A2A.AgentCard{}`. Options are forwarded to `Req.new/1` for customizing the HTTP client (headers, timeouts, etc.). + ## Options + + - `:method_style` — `:v1` (default, PascalCase) or `:legacy` (slash-style for v0.3 servers) + - All other options are forwarded to `Req.new/1` + ## Examples client = A2A.Client.new("https://agent.example.com") client = A2A.Client.new(card, headers: [{"authorization", "Bearer token"}]) + client = A2A.Client.new(url, method_style: :legacy) """ @spec new(A2A.AgentCard.t() | String.t(), keyword()) :: t() def new(url_or_card, opts \\ []) @@ -69,6 +84,8 @@ if Code.ensure_loaded?(Req) do end def new(url, opts) when is_binary(url) do + method_style = Keyword.get(opts, :method_style, :v1) + {req_opts, _rest} = Keyword.split(opts, [:headers, :connect_options, :retry, :plug]) @@ -80,7 +97,7 @@ if Code.ensure_loaded?(Req) do ) ) - %__MODULE__{url: url, req: req} + %__MODULE__{url: url, req: req, method_style: method_style} end @doc """ @@ -152,7 +169,8 @@ if Code.ensure_loaded?(Req) do def send_message(target, message, opts \\ []) do client = ensure_client(target) {params, req_opts} = build_send_params(message, opts) - body = jsonrpc_request("message/send", params) + method = resolve_method("message/send", client.method_style) + body = jsonrpc_request(method, params) case post(client, body, req_opts) do {:ok, response} -> decode_jsonrpc_result(response, :task) @@ -185,7 +203,8 @@ if Code.ensure_loaded?(Req) do def stream_message(target, message, opts \\ []) do client = ensure_client(target) {params, req_opts} = build_send_params(message, opts) - body = jsonrpc_request("message/stream", params) + method = resolve_method("message/stream", client.method_style) + body = jsonrpc_request(method, params) json_body = Jason.encode!(body) req = merge_req_opts(client.req, req_opts) @@ -230,7 +249,8 @@ if Code.ensure_loaded?(Req) do %{"id" => task_id} |> put_opt("historyLength", opts[:history_length]) - body = jsonrpc_request("tasks/get", params) + method = resolve_method("tasks/get", client.method_style) + body = jsonrpc_request(method, params) case post(client, body, req_opts) do {:ok, response} -> decode_jsonrpc_result(response, :task) @@ -256,7 +276,8 @@ if Code.ensure_loaded?(Req) do client = ensure_client(target) req_opts = take_req_opts(opts) params = %{"id" => task_id} - body = jsonrpc_request("tasks/cancel", params) + method = resolve_method("tasks/cancel", client.method_style) + body = jsonrpc_request(method, params) case post(client, body, req_opts) do {:ok, response} -> decode_jsonrpc_result(response, :task) @@ -264,6 +285,28 @@ if Code.ensure_loaded?(Req) do end end + # ------------------------------------------------------------------- + # Private — Method name mapping + # ------------------------------------------------------------------- + + # v1.0 PascalCase equivalents for legacy slash-style method names + @v1_method_names %{ + "message/send" => "SendMessage", + "message/stream" => "SendStreamingMessage", + "tasks/get" => "GetTask", + "tasks/cancel" => "CancelTask", + "tasks/list" => "ListTasks", + "tasks/resubscribe" => "SubscribeToTask", + "tasks/pushNotificationConfig/set" => "CreateTaskPushNotificationConfig", + "tasks/pushNotificationConfig/get" => "GetTaskPushNotificationConfig", + "tasks/pushNotificationConfig/list" => "ListTaskPushNotificationConfigs", + "tasks/pushNotificationConfig/delete" => "DeleteTaskPushNotificationConfig", + "agent/getAuthenticatedExtendedCard" => "GetExtendedAgentCard" + } + + defp resolve_method(method, :v1), do: Map.get(@v1_method_names, method, method) + defp resolve_method(method, :legacy), do: method + # ------------------------------------------------------------------- # Private — Request building # ------------------------------------------------------------------- diff --git a/lib/a2a/jsonrpc.ex b/lib/a2a/jsonrpc.ex index 31708c1..0cfe3d1 100644 --- a/lib/a2a/jsonrpc.ex +++ b/lib/a2a/jsonrpc.ex @@ -39,7 +39,7 @@ defmodule A2A.JSONRPC do alias A2A.JSONRPC.{Error, Request, Response} - # v0.3.0 PascalCase method names → internal slash-style names + # v1.0 PascalCase method names → internal slash-style names @method_aliases %{ "SendMessage" => "message/send", "SendStreamingMessage" => "message/stream", diff --git a/test/a2a/client_test.exs b/test/a2a/client_test.exs index 081b5c8..56d4997 100644 --- a/test/a2a/client_test.exs +++ b/test/a2a/client_test.exs @@ -103,7 +103,7 @@ defmodule A2A.ClientTest do plug = fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) decoded = Jason.decode!(body) - assert decoded["method"] == "message/send" + assert decoded["method"] == "SendMessage" assert decoded["params"]["message"]["role"] == "ROLE_USER" json_resp(conn, 200, jsonrpc_success(%{"task" => @task_json})) @@ -211,7 +211,7 @@ defmodule A2A.ClientTest do plug = fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) decoded = Jason.decode!(body) - assert decoded["method"] == "tasks/get" + assert decoded["method"] == "GetTask" assert decoded["params"]["id"] == "tsk-123" json_resp(conn, 200, jsonrpc_success(@task_json)) @@ -244,7 +244,7 @@ defmodule A2A.ClientTest do plug = fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) decoded = Jason.decode!(body) - assert decoded["method"] == "tasks/cancel" + assert decoded["method"] == "CancelTask" assert decoded["params"]["id"] == "tsk-123" json_resp(conn, 200, jsonrpc_success(canceled_json)) @@ -341,6 +341,62 @@ defmodule A2A.ClientTest do end end + # ------------------------------------------------------------------- + # Method style + # ------------------------------------------------------------------- + + describe "method_style" do + test "default (:v1) sends PascalCase method names" do + plug = fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["method"] == "SendMessage" + json_resp(conn, 200, jsonrpc_success(%{"task" => @task_json})) + end + + client = Client.new("https://agent.example.com", plug: plug) + assert {:ok, _task} = Client.send_message(client, "Hello!") + end + + test "legacy mode sends slash-style method names" do + plug = fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["method"] == "message/send" + json_resp(conn, 200, jsonrpc_success(%{"task" => @task_json})) + end + + client = Client.new("https://agent.example.com", plug: plug, method_style: :legacy) + assert {:ok, _task} = Client.send_message(client, "Hello!") + end + + test "legacy get_task sends tasks/get" do + plug = fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["method"] == "tasks/get" + json_resp(conn, 200, jsonrpc_success(@task_json)) + end + + client = Client.new("https://agent.example.com", plug: plug, method_style: :legacy) + assert {:ok, _task} = Client.get_task(client, "tsk-123") + end + + test "legacy cancel_task sends tasks/cancel" do + canceled_json = put_in(@task_json["status"]["state"], "canceled") + + plug = fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["method"] == "tasks/cancel" + json_resp(conn, 200, jsonrpc_success(canceled_json)) + end + + client = Client.new("https://agent.example.com", plug: plug, method_style: :legacy) + assert {:ok, _task} = Client.cancel_task(client, "tsk-123") + end + end + # ------------------------------------------------------------------- # Convenience overloads # ------------------------------------------------------------------- diff --git a/test/a2a/jsonrpc_test.exs b/test/a2a/jsonrpc_test.exs index 648b445..15ff53e 100644 --- a/test/a2a/jsonrpc_test.exs +++ b/test/a2a/jsonrpc_test.exs @@ -201,6 +201,34 @@ defmodule A2A.JSONRPCTest do assert response["error"]["code"] == -32_601 end + + test "SubscribeToTask dispatches as tasks/resubscribe stream" do + params = %{"id" => "tsk-1"} + result = JSONRPC.handle(rpc("SubscribeToTask", params), @handler) + + assert {:stream, "tasks/resubscribe", ^params, 1} = result + end + + test "GetTaskPushNotificationConfig returns push_notification_not_supported" do + {:reply, response} = + JSONRPC.handle(rpc("GetTaskPushNotificationConfig"), @handler) + + assert response["error"]["code"] == -32_003 + end + + test "ListTaskPushNotificationConfigs returns push_notification_not_supported" do + {:reply, response} = + JSONRPC.handle(rpc("ListTaskPushNotificationConfigs"), @handler) + + assert response["error"]["code"] == -32_003 + end + + test "DeleteTaskPushNotificationConfig returns push_notification_not_supported" do + {:reply, response} = + JSONRPC.handle(rpc("DeleteTaskPushNotificationConfig"), @handler) + + assert response["error"]["code"] == -32_003 + end end # -- unknown method -------------------------------------------------------- From ed2f20e62005073d714f767bdefa10638ebf4ef6 Mon Sep 17 00:00:00 2001 From: Zaf Agent Date: Sat, 21 Mar 2026 14:30:03 +0000 Subject: [PATCH 2/2] fix: default client method_style to :legacy for v0.3 backward compat Existing users talking to v0.3 servers should not break when upgrading. PascalCase is opt-in via method_style: :v1. --- lib/a2a/client.ex | 6 +++--- test/a2a/client_test.exs | 22 +++++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/a2a/client.ex b/lib/a2a/client.ex index 69e7872..911b061 100644 --- a/lib/a2a/client.ex +++ b/lib/a2a/client.ex @@ -57,7 +57,7 @@ if Code.ensure_loaded?(Req) do method_style: :v1 | :legacy } - defstruct [:url, :req, method_style: :v1] + defstruct [:url, :req, method_style: :legacy] @doc """ Creates a new client struct. @@ -67,7 +67,7 @@ if Code.ensure_loaded?(Req) do ## Options - - `:method_style` — `:v1` (default, PascalCase) or `:legacy` (slash-style for v0.3 servers) + - `:method_style` — `:legacy` (default, slash-style for v0.3 compat) or `:v1` (PascalCase for v1.0 servers) - All other options are forwarded to `Req.new/1` ## Examples @@ -84,7 +84,7 @@ if Code.ensure_loaded?(Req) do end def new(url, opts) when is_binary(url) do - method_style = Keyword.get(opts, :method_style, :v1) + method_style = Keyword.get(opts, :method_style, :legacy) {req_opts, _rest} = Keyword.split(opts, [:headers, :connect_options, :retry, :plug]) diff --git a/test/a2a/client_test.exs b/test/a2a/client_test.exs index 56d4997..1db3d87 100644 --- a/test/a2a/client_test.exs +++ b/test/a2a/client_test.exs @@ -103,7 +103,7 @@ defmodule A2A.ClientTest do plug = fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) decoded = Jason.decode!(body) - assert decoded["method"] == "SendMessage" + assert decoded["method"] == "message/send" assert decoded["params"]["message"]["role"] == "ROLE_USER" json_resp(conn, 200, jsonrpc_success(%{"task" => @task_json})) @@ -211,7 +211,7 @@ defmodule A2A.ClientTest do plug = fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) decoded = Jason.decode!(body) - assert decoded["method"] == "GetTask" + assert decoded["method"] == "tasks/get" assert decoded["params"]["id"] == "tsk-123" json_resp(conn, 200, jsonrpc_success(@task_json)) @@ -244,7 +244,7 @@ defmodule A2A.ClientTest do plug = fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) decoded = Jason.decode!(body) - assert decoded["method"] == "CancelTask" + assert decoded["method"] == "tasks/cancel" assert decoded["params"]["id"] == "tsk-123" json_resp(conn, 200, jsonrpc_success(canceled_json)) @@ -346,11 +346,11 @@ defmodule A2A.ClientTest do # ------------------------------------------------------------------- describe "method_style" do - test "default (:v1) sends PascalCase method names" do + test "default (:legacy) sends slash-style method names" do plug = fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) decoded = Jason.decode!(body) - assert decoded["method"] == "SendMessage" + assert decoded["method"] == "message/send" json_resp(conn, 200, jsonrpc_success(%{"task" => @task_json})) end @@ -358,6 +358,18 @@ defmodule A2A.ClientTest do assert {:ok, _task} = Client.send_message(client, "Hello!") end + test "v1 mode sends PascalCase method names" do + plug = fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["method"] == "SendMessage" + json_resp(conn, 200, jsonrpc_success(%{"task" => @task_json})) + end + + client = Client.new("https://agent.example.com", plug: plug, method_style: :v1) + assert {:ok, _task} = Client.send_message(client, "Hello!") + end + test "legacy mode sends slash-style method names" do plug = fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn)