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
57 changes: 50 additions & 7 deletions lib/a2a/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -45,21 +53,28 @@ 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: :legacy]

@doc """
Creates a new client struct.

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` — `: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

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 \\ [])
Expand All @@ -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, :legacy)

{req_opts, _rest} =
Keyword.split(opts, [:headers, :connect_options, :retry, :plug])

Expand All @@ -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 """
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -256,14 +276,37 @@ 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)
{:error, _} = error -> error
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
# -------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion lib/a2a/jsonrpc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions test/a2a/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,74 @@ defmodule A2A.ClientTest do
end
end

# -------------------------------------------------------------------
# Method style
# -------------------------------------------------------------------

describe "method_style" 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"] == "message/send"
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 "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)
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
# -------------------------------------------------------------------
Expand Down
28 changes: 28 additions & 0 deletions test/a2a/jsonrpc_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------------------------------
Expand Down