diff --git a/lib/a2a/client.ex b/lib/a2a/client.ex index bf08c3f..be1e3dd 100644 --- a/lib/a2a/client.ex +++ b/lib/a2a/client.ex @@ -75,7 +75,13 @@ if Code.ensure_loaded?(Req) do req = Req.new( Keyword.merge( - [base_url: url, headers: [{"content-type", "application/json"}]], + [ + base_url: url, + headers: [ + {"content-type", "application/json"}, + {"a2a-version", "1.0"} + ] + ], req_opts ) ) diff --git a/lib/a2a/jsonrpc/error.ex b/lib/a2a/jsonrpc/error.ex index 678b6da..61951ce 100644 --- a/lib/a2a/jsonrpc/error.ex +++ b/lib/a2a/jsonrpc/error.ex @@ -119,6 +119,16 @@ defmodule A2A.JSONRPC.Error do } end + @doc "Builds a version not supported error (-32009)." + @spec version_not_supported(term()) :: t() + def version_not_supported(data \\ nil) do + %__MODULE__{ + code: -32_009, + message: "Version not supported", + data: data + } + end + @doc """ Converts an error struct to a JSON-ready map. diff --git a/lib/a2a/plug.ex b/lib/a2a/plug.ex index 2a48474..84455f9 100644 --- a/lib/a2a/plug.ex +++ b/lib/a2a/plug.ex @@ -116,7 +116,8 @@ if Code.ensure_loaded?(Plug) do agent_card_path: Keyword.get(opts, :agent_card_path, [".well-known", "agent-card.json"]), json_rpc_path: Keyword.get(opts, :json_rpc_path, []), agent_card_opts: Keyword.get(opts, :agent_card_opts, []), - metadata: Keyword.get(opts, :metadata, %{}) + metadata: Keyword.get(opts, :metadata, %{}), + supported_versions: Keyword.get(opts, :supported_versions, ["1.0", "1.0.0"]) } end @@ -129,7 +130,20 @@ if Code.ensure_loaded?(Plug) do def call(%{method: "POST", path_info: path} = conn, %{json_rpc_path: path} = opts) do resolved = resolve_opts(conn, opts) - handle_json_rpc(conn, resolved) + + case validate_version(conn, resolved) do + :ok -> + conn + |> put_resp_header("a2a-version", hd(resolved.supported_versions)) + |> handle_json_rpc(resolved) + + {:error, version} -> + error = Error.version_not_supported("Unsupported version: #{version}") + + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(Response.error(nil, error))) + end end def call(%{path_info: path} = conn, %{agent_card_path: path}) do @@ -142,6 +156,20 @@ if Code.ensure_loaded?(Plug) do send_resp(conn, 404, "Not Found") end + # -- Version validation ---------------------------------------------------- + + defp validate_version(conn, opts) do + case get_req_header(conn, "a2a-version") do + [] -> + :ok + + [version | _] -> + if version in opts.supported_versions, + do: :ok, + else: {:error, version} + end + end + # -- Option resolution ----------------------------------------------------- defp resolve_opts(conn, opts) do diff --git a/test/a2a/client_test.exs b/test/a2a/client_test.exs index 081b5c8..6e835a9 100644 --- a/test/a2a/client_test.exs +++ b/test/a2a/client_test.exs @@ -373,4 +373,47 @@ defmodule A2A.ClientTest do assert {:ok, %A2A.Task{}} = Client.send_message(client, "Hello!") end end + + # ------------------------------------------------------------------- + # A2A-Version header + # ------------------------------------------------------------------- + + describe "A2A-Version header" do + test "send_message includes A2A-Version: 1.0 header" do + plug = fn conn -> + version = Plug.Conn.get_req_header(conn, "a2a-version") + assert version == ["1.0"] + + result = %{"task" => @task_json} + json_resp(conn, 200, jsonrpc_success(result)) + end + + client = Client.new("https://agent.example.com", plug: plug) + assert {:ok, %A2A.Task{}} = Client.send_message(client, "Hello!") + end + + test "get_task includes A2A-Version: 1.0 header" do + plug = fn conn -> + version = Plug.Conn.get_req_header(conn, "a2a-version") + assert version == ["1.0"] + + json_resp(conn, 200, jsonrpc_success(@task_json)) + end + + client = Client.new("https://agent.example.com", plug: plug) + assert {:ok, %A2A.Task{}} = Client.get_task(client, "tsk-123") + end + + test "cancel_task includes A2A-Version: 1.0 header" do + plug = fn conn -> + version = Plug.Conn.get_req_header(conn, "a2a-version") + assert version == ["1.0"] + + json_resp(conn, 200, jsonrpc_success(@task_json)) + end + + client = Client.new("https://agent.example.com", plug: plug) + assert {:ok, %A2A.Task{}} = Client.cancel_task(client, "tsk-123") + end + end end diff --git a/test/a2a/jsonrpc/error_test.exs b/test/a2a/jsonrpc/error_test.exs index 8c785ef..929a442 100644 --- a/test/a2a/jsonrpc/error_test.exs +++ b/test/a2a/jsonrpc/error_test.exs @@ -79,6 +79,12 @@ defmodule A2A.JSONRPC.ErrorTest do assert e.message == "Authenticated Extended Card is not configured" end + test "version_not_supported/0" do + e = Error.version_not_supported() + assert e.code == -32_009 + assert e.message == "Version not supported" + end + test "constructors accept optional data" do e = Error.parse_error("unexpected token") assert e.data == "unexpected token" diff --git a/test/a2a/plug_test.exs b/test/a2a/plug_test.exs index 7a98bfb..713d79b 100644 --- a/test/a2a/plug_test.exs +++ b/test/a2a/plug_test.exs @@ -422,6 +422,87 @@ defmodule A2A.PlugTest do end end + # -- A2A-Version header ------------------------------------------------------- + + describe "A2A-Version header" do + test "accepted when absent (per spec)", %{agent: agent} do + conn = + json_rpc_conn("message/send", message_params()) + |> A2A.Plug.call(plug_opts(agent)) + + assert conn.status == 200 + body = json_body(conn) + assert body["result"]["task"]["kind"] == "task" + assert get_resp_header(conn, "a2a-version") == ["1.0"] + end + + test "accepted for supported version 1.0", %{agent: agent} do + conn = + json_rpc_conn("message/send", message_params()) + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> A2A.Plug.call(plug_opts(agent)) + + assert conn.status == 200 + body = json_body(conn) + assert body["result"]["task"]["kind"] == "task" + assert get_resp_header(conn, "a2a-version") == ["1.0"] + end + + test "accepted for supported version 1.0.0", %{agent: agent} do + conn = + json_rpc_conn("message/send", message_params()) + |> Plug.Conn.put_req_header("a2a-version", "1.0.0") + |> A2A.Plug.call(plug_opts(agent)) + + assert conn.status == 200 + body = json_body(conn) + assert body["result"]["task"]["kind"] == "task" + end + + test "unsupported version returns VersionNotSupportedError", %{agent: agent} do + conn = + json_rpc_conn("message/send", message_params()) + |> Plug.Conn.put_req_header("a2a-version", "99.0") + |> A2A.Plug.call(plug_opts(agent)) + + assert conn.status == 200 + body = json_body(conn) + assert body["error"]["code"] == -32_009 + assert body["error"]["message"] == "Version not supported" + assert body["error"]["data"] =~ "99.0" + end + + test "response includes A2A-Version header", %{agent: agent} do + conn = + json_rpc_conn("message/send", message_params()) + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> A2A.Plug.call(plug_opts(agent)) + + assert get_resp_header(conn, "a2a-version") == ["1.0"] + end + + test "custom supported_versions option", %{agent: agent} do + opts = plug_opts(agent, supported_versions: ["2.0"]) + + conn = + json_rpc_conn("message/send", message_params()) + |> Plug.Conn.put_req_header("a2a-version", "1.0") + |> A2A.Plug.call(opts) + + body = json_body(conn) + assert body["error"]["code"] == -32_009 + + conn2 = + json_rpc_conn("message/send", message_params()) + |> Plug.Conn.put_req_header("a2a-version", "2.0") + |> A2A.Plug.call(opts) + + body2 = json_body(conn2) + assert body2["result"]["task"]["kind"] == "task" + assert get_resp_header(conn2, "a2a-version") == ["2.0"] + end + end + defp get_resp_header(conn, key) do for {k, v} <- conn.resp_headers, k == key, do: v end