Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion lib/a2a/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
Expand Down
10 changes: 10 additions & 0 deletions lib/a2a/jsonrpc/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
32 changes: 30 additions & 2 deletions lib/a2a/plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions test/a2a/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions test/a2a/jsonrpc/error_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
81 changes: 81 additions & 0 deletions test/a2a/plug_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down