diff --git a/lib/a2a/agent_card.ex b/lib/a2a/agent_card.ex index dee0795..519df20 100644 --- a/lib/a2a/agent_card.ex +++ b/lib/a2a/agent_card.ex @@ -14,17 +14,22 @@ defmodule A2A.AgentCard do """ @type skill :: %{ - id: String.t(), - name: String.t(), - description: String.t(), - tags: [String.t()] + required(:id) => String.t(), + required(:name) => String.t(), + required(:description) => String.t(), + required(:tags) => [String.t()], + optional(:examples) => [String.t()], + optional(:input_modes) => [String.t()], + optional(:output_modes) => [String.t()], + optional(:security_requirements) => [map()] } @type capabilities :: %{ optional(:streaming) => boolean(), optional(:push_notifications) => boolean(), optional(:state_transition_history) => boolean(), - optional(:extended_agent_card) => boolean() + optional(:extended_agent_card) => boolean(), + optional(:extensions) => [A2A.AgentExtension.t()] } @type provider :: %{ @@ -33,9 +38,10 @@ defmodule A2A.AgentCard do } @type supported_interface :: %{ - url: String.t(), - protocol_binding: String.t(), - protocol_version: String.t() + required(:url) => String.t(), + required(:protocol_binding) => String.t(), + required(:protocol_version) => String.t(), + optional(:tenant) => String.t() } @type t :: %__MODULE__{ @@ -53,7 +59,9 @@ defmodule A2A.AgentCard do protocol_version: String.t() | nil, supported_interfaces: [supported_interface()], security_schemes: %{String.t() => A2A.SecurityScheme.t()}, - security: [%{String.t() => [String.t()]}] + security: [%{String.t() => [String.t()]}], + signatures: [A2A.AgentCardSignature.t()], + security_requirements: [map()] } @enforce_keys [:name, :description, :url, :version, :skills] @@ -72,6 +80,8 @@ defmodule A2A.AgentCard do default_output_modes: ["text/plain"], supported_interfaces: [], security_schemes: %{}, - security: [] + security: [], + signatures: [], + security_requirements: [] ] end diff --git a/lib/a2a/agent_card_signature.ex b/lib/a2a/agent_card_signature.ex new file mode 100644 index 0000000..0daa9ee --- /dev/null +++ b/lib/a2a/agent_card_signature.ex @@ -0,0 +1,22 @@ +defmodule A2A.AgentCardSignature do + @moduledoc """ + A JWS signature of an AgentCard (RFC 7515). + + ## Proto Reference + + message AgentCardSignature { + string protected = 1; // base64url-encoded JSON header + string signature = 2; // base64url-encoded signature + google.protobuf.Struct header = 3; // unprotected header + } + """ + + @type t :: %__MODULE__{ + protected: String.t(), + signature: String.t(), + header: map() | nil + } + + @enforce_keys [:protected, :signature] + defstruct [:protected, :signature, :header] +end diff --git a/lib/a2a/agent_extension.ex b/lib/a2a/agent_extension.ex new file mode 100644 index 0000000..a4bfb6f --- /dev/null +++ b/lib/a2a/agent_extension.ex @@ -0,0 +1,24 @@ +defmodule A2A.AgentExtension do + @moduledoc """ + A declaration of a protocol extension supported by an Agent. + + ## Proto Reference + + message AgentExtension { + string uri = 1; + string description = 2; + bool required = 3; + google.protobuf.Struct params = 4; + } + """ + + @type t :: %__MODULE__{ + uri: String.t(), + description: String.t() | nil, + required: boolean(), + params: map() | nil + } + + @enforce_keys [:uri] + defstruct [:uri, :description, :params, required: false] +end diff --git a/lib/a2a/artifact.ex b/lib/a2a/artifact.ex index 7981574..b5b8420 100644 --- a/lib/a2a/artifact.ex +++ b/lib/a2a/artifact.ex @@ -10,7 +10,8 @@ defmodule A2A.Artifact do name: String.t() | nil, description: String.t() | nil, parts: [A2A.Part.t()], - metadata: map() + metadata: map(), + extensions: [String.t()] } @enforce_keys [:parts] @@ -19,7 +20,8 @@ defmodule A2A.Artifact do :name, :description, parts: [], - metadata: %{} + metadata: %{}, + extensions: [] ] @doc """ diff --git a/lib/a2a/authentication_info.ex b/lib/a2a/authentication_info.ex new file mode 100644 index 0000000..c076ca9 --- /dev/null +++ b/lib/a2a/authentication_info.ex @@ -0,0 +1,22 @@ +defmodule A2A.AuthenticationInfo do + @moduledoc """ + Authentication details used for push notifications. + + Contains an HTTP authentication scheme and optional credentials. + + ## Proto Reference + + message AuthenticationInfo { + string scheme = 1; // e.g. "Bearer", "Basic" + string credentials = 2; + } + """ + + @type t :: %__MODULE__{ + scheme: String.t(), + credentials: String.t() | nil + } + + @enforce_keys [:scheme] + defstruct [:scheme, :credentials] +end diff --git a/lib/a2a/json.ex b/lib/a2a/json.ex index 015a303..1eb7010 100644 --- a/lib/a2a/json.ex +++ b/lib/a2a/json.ex @@ -124,6 +124,7 @@ defmodule A2A.JSON do |> put_unless_nil("contextId", msg.context_id) |> put_unless_empty("metadata", msg.metadata) |> put_unless_empty("extensions", msg.extensions) + |> put_unless_empty("referenceTaskIds", msg.reference_task_ids) {:ok, map} end @@ -135,6 +136,7 @@ defmodule A2A.JSON do |> put_unless_nil("name", artifact.name) |> put_unless_nil("description", artifact.description) |> put_unless_empty("metadata", artifact.metadata) + |> put_unless_empty("extensions", artifact.extensions) {:ok, map} end @@ -143,6 +145,8 @@ defmodule A2A.JSON do map = %{"kind" => "text", "text" => part.text} |> put_unless_empty("metadata", part.metadata) + |> put_unless_nil("mediaType", part.media_type) + |> put_unless_nil("filename", part.filename) {:ok, map} end @@ -153,6 +157,8 @@ defmodule A2A.JSON do map = %{"kind" => "file", "file" => file} |> put_unless_empty("metadata", part.metadata) + |> put_unless_nil("mediaType", part.media_type) + |> put_unless_nil("filename", part.filename) {:ok, map} end @@ -161,6 +167,8 @@ defmodule A2A.JSON do map = %{"kind" => "data", "data" => part.data} |> put_unless_empty("metadata", part.metadata) + |> put_unless_nil("mediaType", part.media_type) + |> put_unless_nil("filename", part.filename) {:ok, map} end @@ -209,6 +217,63 @@ defmodule A2A.JSON do {:ok, map} end + def encode(%A2A.AuthenticationInfo{} = auth) do + map = + %{"scheme" => auth.scheme} + |> put_unless_nil("credentials", auth.credentials) + + {:ok, map} + end + + def encode(%A2A.TaskPushNotificationConfig{} = config) do + map = + %{"url" => config.url} + |> put_unless_nil("tenant", config.tenant) + |> put_unless_nil("id", config.id) + |> put_unless_nil("taskId", config.task_id) + |> put_unless_nil("token", config.token) + |> put_unless_nil_nested("authentication", config.authentication) + + {:ok, map} + end + + def encode(%A2A.SendMessageConfiguration{} = config) do + map = + %{} + |> put_unless_empty("acceptedOutputModes", config.accepted_output_modes) + |> put_unless_nil_nested( + "taskPushNotificationConfig", + config.task_push_notification_config + ) + |> put_unless_nil("historyLength", config.history_length) + + map = + if config.return_immediately, + do: Map.put(map, "returnImmediately", true), + else: map + + {:ok, map} + end + + def encode(%A2A.AgentExtension{} = ext) do + map = + %{"uri" => ext.uri} + |> put_unless_nil("description", ext.description) + |> put_unless_nil("params", ext.params) + + map = if ext.required, do: Map.put(map, "required", true), else: map + + {:ok, map} + end + + def encode(%A2A.AgentCardSignature{} = sig) do + map = + %{"protected" => sig.protected, "signature" => sig.signature} + |> put_unless_empty("header", sig.header) + + {:ok, map} + end + def encode(%{__struct__: mod}) do {:error, {:unsupported_type, mod}} end @@ -265,6 +330,13 @@ defmodule A2A.JSON do "description" => skill.description, "tags" => skill.tags } + |> put_unless_empty("examples", Map.get(skill, :examples, [])) + |> put_unless_empty("inputModes", Map.get(skill, :input_modes, [])) + |> put_unless_empty("outputModes", Map.get(skill, :output_modes, [])) + |> put_unless_empty( + "securityRequirements", + Map.get(skill, :security_requirements, []) + ) end) caps = encode_capabilities(capabilities) @@ -287,6 +359,11 @@ defmodule A2A.JSON do |> put_unless_nil("protocolVersion", Keyword.get(opts, :protocol_version)) |> put_unless_empty("securitySchemes", encode_security_schemes(security_schemes)) |> put_unless_empty("security", security) + |> put_unless_empty("signatures", encode_signatures(Keyword.get(opts, :signatures, []))) + |> put_unless_empty( + "securityRequirements", + Keyword.get(opts, :security_requirements, []) + ) map end @@ -335,7 +412,9 @@ defmodule A2A.JSON do protocol_version: Map.get(map, "protocolVersion"), supported_interfaces: decode_card_interfaces(Map.get(map, "supportedInterfaces", [])), security_schemes: decode_card_security_schemes(Map.get(map, "securitySchemes", %{})), - security: Map.get(map, "security", []) + security: Map.get(map, "security", []), + signatures: decode_card_signatures(Map.get(map, "signatures", [])), + security_requirements: Map.get(map, "securityRequirements", []) }} end end @@ -354,6 +433,9 @@ defmodule A2A.JSON do | :event | :status_update_event | :artifact_update_event + | :authentication_info + | :push_notification_config + | :send_message_configuration @doc """ Decodes a JSON map into an Elixir struct of the given type. @@ -413,7 +495,8 @@ defmodule A2A.JSON do task_id: Map.get(map, "taskId"), context_id: Map.get(map, "contextId"), metadata: Map.get(map, "metadata", %{}), - extensions: Map.get(map, "extensions", %{}) + extensions: Map.get(map, "extensions", []), + reference_task_ids: Map.get(map, "referenceTaskIds", []) }} end end @@ -427,7 +510,8 @@ defmodule A2A.JSON do name: Map.get(map, "name"), description: Map.get(map, "description"), parts: parts, - metadata: Map.get(map, "metadata", %{}) + metadata: Map.get(map, "metadata", %{}), + extensions: Map.get(map, "extensions", []) }} end end @@ -483,6 +567,52 @@ defmodule A2A.JSON do end end + def decode(map, :authentication_info) do + with {:ok, scheme} <- require_field(map, "scheme") do + {:ok, + %A2A.AuthenticationInfo{ + scheme: scheme, + credentials: Map.get(map, "credentials") + }} + end + end + + def decode(map, :push_notification_config) do + with {:ok, url} <- require_field(map, "url") do + auth = + case Map.get(map, "authentication") do + nil -> nil + auth_map -> decode!(auth_map, :authentication_info) + end + + {:ok, + %A2A.TaskPushNotificationConfig{ + tenant: Map.get(map, "tenant"), + id: Map.get(map, "id"), + task_id: Map.get(map, "taskId"), + url: url, + token: Map.get(map, "token"), + authentication: auth + }} + end + end + + def decode(map, :send_message_configuration) do + push_config = + case Map.get(map, "taskPushNotificationConfig") do + nil -> nil + config_map -> decode!(config_map, :push_notification_config) + end + + {:ok, + %A2A.SendMessageConfiguration{ + accepted_output_modes: Map.get(map, "acceptedOutputModes", []), + task_push_notification_config: push_config, + history_length: Map.get(map, "historyLength"), + return_immediately: Map.get(map, "returnImmediately", false) + }} + end + def decode(map, :artifact_update_event) do with {:ok, task_id} <- require_field(map, "taskId"), {:ok, artifact_map} <- require_field(map, "artifact"), @@ -532,11 +662,34 @@ defmodule A2A.JSON do end defp encode_capabilities(caps) when is_map(caps) do - encode_known_keys(caps, [ - {"streaming", :streaming}, - {"pushNotifications", :push_notifications}, - {"stateTransitionHistory", :state_transition_history}, - {"extendedAgentCard", :extended_agent_card} + base = + encode_known_keys(caps, [ + {"streaming", :streaming}, + {"pushNotifications", :push_notifications}, + {"stateTransitionHistory", :state_transition_history}, + {"extendedAgentCard", :extended_agent_card} + ]) + + extensions = Map.get(caps, :extensions, []) + + if extensions != [] do + Map.put(base, "extensions", Enum.map(extensions, &encode_agent_extension/1)) + else + base + end + end + + defp encode_agent_extension(%A2A.AgentExtension{} = ext) do + {:ok, map} = encode(ext) + map + end + + defp encode_agent_extension(ext) when is_map(ext) do + encode_known_keys(ext, [ + {"uri", :uri}, + {"description", :description}, + {"required", :required}, + {"params", :params} ]) end @@ -544,12 +697,30 @@ defmodule A2A.JSON do mappings = [ {"url", :url}, {"protocolBinding", :protocol_binding}, - {"protocolVersion", :protocol_version} + {"protocolVersion", :protocol_version}, + {"tenant", :tenant} ] Enum.map(interfaces, &encode_known_keys(&1, mappings)) end + defp encode_signatures([]), do: [] + + defp encode_signatures(sigs) when is_list(sigs) do + Enum.map(sigs, fn + %A2A.AgentCardSignature{} = sig -> + {:ok, map} = encode(sig) + map + + sig when is_map(sig) -> + encode_known_keys(sig, [ + {"protected", :protected}, + {"signature", :signature}, + {"header", :header} + ]) + end) + end + defp encode_provider(nil), do: nil defp encode_provider(provider) when is_map(provider) do @@ -684,11 +855,15 @@ defmodule A2A.JSON do end # v0.3: parts may omit "kind" — infer from content field presence + # v1.0: flat Part with oneof (text, raw, url, data) + metadata + media_type + filename defp infer_part_type(map) do cond do Map.has_key?(map, "text") -> decode_text_part(map) Map.has_key?(map, "file") -> decode_file_part(map) Map.has_key?(map, "data") -> decode_data_part(map) + # v1.0 flat format: "raw" or "url" without "file" wrapper + Map.has_key?(map, "raw") -> decode_flat_file_part(map, :raw) + Map.has_key?(map, "url") -> decode_flat_file_part(map, :url) true -> {:error, {:missing_field, "kind"}} end end @@ -698,7 +873,9 @@ defmodule A2A.JSON do {:ok, %A2A.Part.Text{ text: text, - metadata: Map.get(map, "metadata", %{}) + metadata: Map.get(map, "metadata", %{}), + media_type: Map.get(map, "mediaType") || Map.get(map, "media_type"), + filename: Map.get(map, "filename") }} end end @@ -709,7 +886,9 @@ defmodule A2A.JSON do {:ok, %A2A.Part.File{ file: file, - metadata: Map.get(map, "metadata", %{}) + metadata: Map.get(map, "metadata", %{}), + media_type: Map.get(map, "mediaType") || Map.get(map, "media_type"), + filename: Map.get(map, "filename") }} end end @@ -719,11 +898,48 @@ defmodule A2A.JSON do {:ok, %A2A.Part.Data{ data: data, - metadata: Map.get(map, "metadata", %{}) + metadata: Map.get(map, "metadata", %{}), + media_type: Map.get(map, "mediaType") || Map.get(map, "media_type"), + filename: Map.get(map, "filename") + }} + end + end + + # v1.0 flat Part format: "raw" (base64 bytes) or "url" directly on the part + defp decode_flat_file_part(map, :raw) do + with {:ok, bytes} <- decode_base64(Map.get(map, "raw")) do + file = %A2A.FileContent{ + bytes: bytes, + mime_type: Map.get(map, "mediaType") || Map.get(map, "media_type"), + name: Map.get(map, "filename") + } + + {:ok, + %A2A.Part.File{ + file: file, + metadata: Map.get(map, "metadata", %{}), + media_type: Map.get(map, "mediaType") || Map.get(map, "media_type"), + filename: Map.get(map, "filename") }} end end + defp decode_flat_file_part(map, :url) do + file = %A2A.FileContent{ + uri: Map.get(map, "url"), + mime_type: Map.get(map, "mediaType") || Map.get(map, "media_type"), + name: Map.get(map, "filename") + } + + {:ok, + %A2A.Part.File{ + file: file, + metadata: Map.get(map, "metadata", %{}), + media_type: Map.get(map, "mediaType") || Map.get(map, "media_type"), + filename: Map.get(map, "filename") + }} + end + # ------------------------------------------------------------------- # Private — AgentCard decoding helpers # ------------------------------------------------------------------- @@ -733,12 +949,17 @@ defmodule A2A.JSON do with {:ok, id} <- require_field(skill, "id"), {:ok, name} <- require_field(skill, "name"), {:ok, description} <- require_field(skill, "description") do - decoded = %{ - id: id, - name: name, - description: description, - tags: Map.get(skill, "tags", []) - } + decoded = + %{ + id: id, + name: name, + description: description, + tags: Map.get(skill, "tags", []) + } + |> put_skill_field(:examples, Map.get(skill, "examples")) + |> put_skill_field(:input_modes, Map.get(skill, "inputModes")) + |> put_skill_field(:output_modes, Map.get(skill, "outputModes")) + |> put_skill_field(:security_requirements, Map.get(skill, "securityRequirements")) {:cont, {:ok, [decoded | acc]}} else @@ -756,19 +977,39 @@ defmodule A2A.JSON do defp decode_card_capabilities(nil), do: %{} defp decode_card_capabilities(map) when is_map(map) do - decode_known_keys(map, [ - {"streaming", :streaming}, - {"pushNotifications", :push_notifications}, - {"stateTransitionHistory", :state_transition_history}, - {"extendedAgentCard", :extended_agent_card} - ]) + base = + decode_known_keys(map, [ + {"streaming", :streaming}, + {"pushNotifications", :push_notifications}, + {"stateTransitionHistory", :state_transition_history}, + {"extendedAgentCard", :extended_agent_card} + ]) + + extensions = Map.get(map, "extensions", []) + + if extensions != [] do + decoded_exts = + Enum.map(extensions, fn ext -> + %A2A.AgentExtension{ + uri: Map.fetch!(ext, "uri"), + description: Map.get(ext, "description"), + required: Map.get(ext, "required", false), + params: Map.get(ext, "params") + } + end) + + Map.put(base, :extensions, decoded_exts) + else + base + end end defp decode_card_interfaces(interfaces) when is_list(interfaces) do mappings = [ {"url", :url}, {"protocolBinding", :protocol_binding}, - {"protocolVersion", :protocol_version} + {"protocolVersion", :protocol_version}, + {"tenant", :tenant} ] Enum.map(interfaces, &decode_known_keys(&1, mappings)) @@ -821,6 +1062,22 @@ defmodule A2A.JSON do %A2A.SecurityScheme.MutualTLS{} end + defp decode_card_signatures(sigs) when is_list(sigs) do + Enum.map(sigs, fn sig -> + %A2A.AgentCardSignature{ + protected: Map.fetch!(sig, "protected"), + signature: Map.fetch!(sig, "signature"), + header: Map.get(sig, "header") + } + end) + end + + defp decode_card_signatures(_), do: [] + + defp put_skill_field(skill, _key, nil), do: skill + defp put_skill_field(skill, _key, []), do: skill + defp put_skill_field(skill, key, value), do: Map.put(skill, key, value) + defp decode_known_keys(source, mappings) do Enum.reduce(mappings, %{}, fn {json_key, atom_key}, acc -> case Map.get(source, json_key) do diff --git a/lib/a2a/message.ex b/lib/a2a/message.ex index 53729fe..c2d741c 100644 --- a/lib/a2a/message.ex +++ b/lib/a2a/message.ex @@ -14,7 +14,8 @@ defmodule A2A.Message do task_id: String.t() | nil, context_id: String.t() | nil, metadata: map(), - extensions: map() + extensions: [String.t()] | map(), + reference_task_ids: [String.t()] } @enforce_keys [:role, :parts] @@ -25,7 +26,8 @@ defmodule A2A.Message do :context_id, parts: [], metadata: %{}, - extensions: %{} + extensions: [], + reference_task_ids: [] ] @doc """ diff --git a/lib/a2a/part.ex b/lib/a2a/part.ex index 5b02303..8cd49ad 100644 --- a/lib/a2a/part.ex +++ b/lib/a2a/part.ex @@ -19,10 +19,12 @@ defmodule A2A.Part.Text do @type t :: %__MODULE__{ text: String.t(), - metadata: map() + metadata: map(), + media_type: String.t() | nil, + filename: String.t() | nil } - defstruct text: "", metadata: %{} + defstruct text: "", metadata: %{}, media_type: nil, filename: nil @doc """ Creates a new text part. @@ -40,11 +42,13 @@ defmodule A2A.Part.File do @type t :: %__MODULE__{ file: A2A.FileContent.t(), - metadata: map() + metadata: map(), + media_type: String.t() | nil, + filename: String.t() | nil } @enforce_keys [:file] - defstruct file: nil, metadata: %{} + defstruct file: nil, metadata: %{}, media_type: nil, filename: nil @doc """ Creates a new file part. @@ -62,10 +66,12 @@ defmodule A2A.Part.Data do @type t :: %__MODULE__{ data: map(), - metadata: map() + metadata: map(), + media_type: String.t() | nil, + filename: String.t() | nil } - defstruct data: %{}, metadata: %{} + defstruct data: %{}, metadata: %{}, media_type: nil, filename: nil @doc """ Creates a new data part. diff --git a/lib/a2a/push_notification_config.ex b/lib/a2a/push_notification_config.ex new file mode 100644 index 0000000..96c25cd --- /dev/null +++ b/lib/a2a/push_notification_config.ex @@ -0,0 +1,28 @@ +defmodule A2A.TaskPushNotificationConfig do + @moduledoc """ + A push notification configuration associated with a specific task. + + ## Proto Reference + + message TaskPushNotificationConfig { + string tenant = 1; + string id = 2; + string task_id = 3; + string url = 4; // REQUIRED + string token = 5; + AuthenticationInfo authentication = 6; + } + """ + + @type t :: %__MODULE__{ + tenant: String.t() | nil, + id: String.t() | nil, + task_id: String.t() | nil, + url: String.t(), + token: String.t() | nil, + authentication: A2A.AuthenticationInfo.t() | nil + } + + @enforce_keys [:url] + defstruct [:tenant, :id, :task_id, :url, :token, :authentication] +end diff --git a/lib/a2a/send_message_configuration.ex b/lib/a2a/send_message_configuration.ex new file mode 100644 index 0000000..2ccf45e --- /dev/null +++ b/lib/a2a/send_message_configuration.ex @@ -0,0 +1,26 @@ +defmodule A2A.SendMessageConfiguration do + @moduledoc """ + Configuration for a send message request. + + ## Proto Reference + + message SendMessageConfiguration { + repeated string accepted_output_modes = 1; + TaskPushNotificationConfig task_push_notification_config = 2; + optional int32 history_length = 3; + bool return_immediately = 4; + } + """ + + @type t :: %__MODULE__{ + accepted_output_modes: [String.t()], + task_push_notification_config: A2A.TaskPushNotificationConfig.t() | nil, + history_length: integer() | nil, + return_immediately: boolean() + } + + defstruct accepted_output_modes: [], + task_push_notification_config: nil, + history_length: nil, + return_immediately: false +end diff --git a/test/a2a/message_test.exs b/test/a2a/message_test.exs index db5ffd3..1c62ae8 100644 --- a/test/a2a/message_test.exs +++ b/test/a2a/message_test.exs @@ -52,10 +52,11 @@ defmodule A2A.MessageTest do end describe "struct defaults" do - test "metadata and extensions default to empty maps" do + test "metadata defaults to empty map, extensions to empty list" do msg = Message.new_user("test") assert msg.metadata == %{} - assert msg.extensions == %{} + assert msg.extensions == [] + assert msg.reference_task_ids == [] end test "task_id and context_id default to nil" do diff --git a/test/a2a/v1_data_model_test.exs b/test/a2a/v1_data_model_test.exs new file mode 100644 index 0000000..5805482 --- /dev/null +++ b/test/a2a/v1_data_model_test.exs @@ -0,0 +1,592 @@ +defmodule A2A.V1DataModelTest do + @moduledoc """ + Tests for v1.0 data model fields added from the proto spec. + Verifies encode/decode round-trip for all new fields and structs. + """ + + use ExUnit.Case, async: true + + alias A2A.JSON + + # ------------------------------------------------------------------- + # New Structs + # ------------------------------------------------------------------- + + describe "AuthenticationInfo" do + test "struct creation" do + auth = %A2A.AuthenticationInfo{scheme: "Bearer", credentials: "token123"} + assert auth.scheme == "Bearer" + assert auth.credentials == "token123" + end + + test "encode/decode round-trip" do + auth = %A2A.AuthenticationInfo{scheme: "Bearer", credentials: "tok"} + {:ok, map} = JSON.encode(auth) + assert map == %{"scheme" => "Bearer", "credentials" => "tok"} + + {:ok, decoded} = JSON.decode(map, :authentication_info) + assert decoded.scheme == "Bearer" + assert decoded.credentials == "tok" + end + + test "encode omits nil credentials" do + auth = %A2A.AuthenticationInfo{scheme: "Basic"} + {:ok, map} = JSON.encode(auth) + assert map == %{"scheme" => "Basic"} + refute Map.has_key?(map, "credentials") + end + end + + describe "AgentExtension" do + test "struct creation with defaults" do + ext = %A2A.AgentExtension{uri: "urn:a2a:ext:test"} + assert ext.uri == "urn:a2a:ext:test" + assert ext.required == false + assert ext.description == nil + assert ext.params == nil + end + + test "encode/decode round-trip" do + ext = %A2A.AgentExtension{ + uri: "urn:a2a:ext:test", + description: "A test extension", + required: true, + params: %{"key" => "value"} + } + + {:ok, map} = JSON.encode(ext) + assert map["uri"] == "urn:a2a:ext:test" + assert map["description"] == "A test extension" + assert map["required"] == true + assert map["params"] == %{"key" => "value"} + end + + test "encode omits false required and nil fields" do + ext = %A2A.AgentExtension{uri: "urn:a2a:ext:simple"} + {:ok, map} = JSON.encode(ext) + assert map == %{"uri" => "urn:a2a:ext:simple"} + refute Map.has_key?(map, "required") + refute Map.has_key?(map, "description") + refute Map.has_key?(map, "params") + end + end + + describe "AgentCardSignature" do + test "struct creation" do + sig = %A2A.AgentCardSignature{ + protected: "eyJhbGciOiJSUzI1NiJ9", + signature: "abc123" + } + + assert sig.protected == "eyJhbGciOiJSUzI1NiJ9" + assert sig.signature == "abc123" + assert sig.header == nil + end + + test "encode/decode round-trip" do + sig = %A2A.AgentCardSignature{ + protected: "eyJhbGciOiJSUzI1NiJ9", + signature: "abc123", + header: %{"kid" => "key-1"} + } + + {:ok, map} = JSON.encode(sig) + assert map["protected"] == "eyJhbGciOiJSUzI1NiJ9" + assert map["signature"] == "abc123" + assert map["header"] == %{"kid" => "key-1"} + end + end + + describe "TaskPushNotificationConfig" do + test "struct creation" do + config = %A2A.TaskPushNotificationConfig{ + url: "https://webhook.example.com/notify", + task_id: "task-1", + token: "secret" + } + + assert config.url == "https://webhook.example.com/notify" + assert config.task_id == "task-1" + assert config.tenant == nil + end + + test "encode/decode round-trip" do + config = %A2A.TaskPushNotificationConfig{ + tenant: "tenant-1", + id: "config-1", + task_id: "task-1", + url: "https://hook.example.com", + token: "tok", + authentication: %A2A.AuthenticationInfo{scheme: "Bearer", credentials: "jwt"} + } + + {:ok, map} = JSON.encode(config) + assert map["url"] == "https://hook.example.com" + assert map["tenant"] == "tenant-1" + assert map["id"] == "config-1" + assert map["taskId"] == "task-1" + assert map["token"] == "tok" + assert map["authentication"]["scheme"] == "Bearer" + + {:ok, decoded} = JSON.decode(map, :push_notification_config) + assert decoded.url == "https://hook.example.com" + assert decoded.tenant == "tenant-1" + assert decoded.id == "config-1" + assert decoded.task_id == "task-1" + assert decoded.token == "tok" + assert decoded.authentication.scheme == "Bearer" + assert decoded.authentication.credentials == "jwt" + end + + test "encode omits nil optional fields" do + config = %A2A.TaskPushNotificationConfig{url: "https://hook.example.com"} + {:ok, map} = JSON.encode(config) + assert map == %{"url" => "https://hook.example.com"} + end + end + + describe "SendMessageConfiguration" do + test "struct defaults" do + config = %A2A.SendMessageConfiguration{} + assert config.accepted_output_modes == [] + assert config.task_push_notification_config == nil + assert config.history_length == nil + assert config.return_immediately == false + end + + test "encode/decode round-trip" do + config = %A2A.SendMessageConfiguration{ + accepted_output_modes: ["text/plain", "application/json"], + history_length: 10, + return_immediately: true + } + + {:ok, map} = JSON.encode(config) + assert map["acceptedOutputModes"] == ["text/plain", "application/json"] + assert map["historyLength"] == 10 + assert map["returnImmediately"] == true + + {:ok, decoded} = JSON.decode(map, :send_message_configuration) + assert decoded.accepted_output_modes == ["text/plain", "application/json"] + assert decoded.history_length == 10 + assert decoded.return_immediately == true + end + + test "encode omits empty/default fields" do + config = %A2A.SendMessageConfiguration{} + {:ok, map} = JSON.encode(config) + assert map == %{} + end + + test "with push notification config" do + config = %A2A.SendMessageConfiguration{ + accepted_output_modes: ["text/plain"], + task_push_notification_config: %A2A.TaskPushNotificationConfig{ + url: "https://hook.example.com", + token: "tok" + } + } + + {:ok, map} = JSON.encode(config) + assert map["taskPushNotificationConfig"]["url"] == "https://hook.example.com" + + {:ok, decoded} = JSON.decode(map, :send_message_configuration) + assert decoded.task_push_notification_config.url == "https://hook.example.com" + assert decoded.task_push_notification_config.token == "tok" + end + end + + # ------------------------------------------------------------------- + # Updated Structs — New Fields + # ------------------------------------------------------------------- + + describe "Message extensions and reference_task_ids" do + test "encode includes reference_task_ids when non-empty" do + msg = %A2A.Message{ + message_id: "msg-1", + role: :user, + parts: [A2A.Part.Text.new("hello")], + reference_task_ids: ["task-1", "task-2"], + extensions: ["urn:ext:1"] + } + + {:ok, map} = JSON.encode(msg) + assert map["referenceTaskIds"] == ["task-1", "task-2"] + assert map["extensions"] == ["urn:ext:1"] + end + + test "encode omits empty reference_task_ids and extensions" do + msg = A2A.Message.new_user("hello") + {:ok, map} = JSON.encode(msg) + refute Map.has_key?(map, "referenceTaskIds") + refute Map.has_key?(map, "extensions") + end + + test "decode extracts reference_task_ids" do + map = %{ + "messageId" => "msg-1", + "role" => "ROLE_USER", + "parts" => [%{"kind" => "text", "text" => "hi"}], + "referenceTaskIds" => ["task-a"], + "extensions" => ["urn:ext:1"] + } + + {:ok, msg} = JSON.decode(map, :message) + assert msg.reference_task_ids == ["task-a"] + assert msg.extensions == ["urn:ext:1"] + end + end + + describe "Artifact extensions" do + test "encode includes extensions when non-empty" do + artifact = %A2A.Artifact{ + artifact_id: "art-1", + parts: [A2A.Part.Text.new("result")], + extensions: ["urn:ext:1"] + } + + {:ok, map} = JSON.encode(artifact) + assert map["extensions"] == ["urn:ext:1"] + end + + test "decode extracts extensions" do + map = %{ + "artifactId" => "art-1", + "parts" => [%{"kind" => "text", "text" => "result"}], + "extensions" => ["urn:ext:artifact"] + } + + {:ok, artifact} = JSON.decode(map, :artifact) + assert artifact.extensions == ["urn:ext:artifact"] + end + end + + describe "Part media_type and filename" do + test "text part with media_type and filename" do + part = %A2A.Part.Text{text: "hello", media_type: "text/plain", filename: "hello.txt"} + {:ok, map} = JSON.encode(part) + assert map["mediaType"] == "text/plain" + assert map["filename"] == "hello.txt" + + {:ok, decoded} = JSON.decode(map, :part) + assert decoded.media_type == "text/plain" + assert decoded.filename == "hello.txt" + end + + test "file part with media_type and filename" do + file = A2A.FileContent.from_uri("https://example.com/doc.pdf") + + part = %A2A.Part.File{ + file: file, + media_type: "application/pdf", + filename: "doc.pdf" + } + + {:ok, map} = JSON.encode(part) + assert map["mediaType"] == "application/pdf" + assert map["filename"] == "doc.pdf" + end + + test "data part with media_type" do + part = %A2A.Part.Data{data: %{"key" => "val"}, media_type: "application/json"} + {:ok, map} = JSON.encode(part) + assert map["mediaType"] == "application/json" + end + + test "encode omits nil media_type and filename" do + part = A2A.Part.Text.new("plain") + {:ok, map} = JSON.encode(part) + refute Map.has_key?(map, "mediaType") + refute Map.has_key?(map, "filename") + end + end + + describe "v1.0 flat Part format decoding" do + test "decode flat Part with raw bytes" do + raw_b64 = Base.encode64("binary content") + + map = %{ + "raw" => raw_b64, + "mediaType" => "application/octet-stream", + "filename" => "data.bin" + } + + {:ok, part} = JSON.decode(map, :part) + assert %A2A.Part.File{} = part + assert part.file.bytes == "binary content" + assert part.file.mime_type == "application/octet-stream" + assert part.file.name == "data.bin" + assert part.media_type == "application/octet-stream" + assert part.filename == "data.bin" + end + + test "decode flat Part with url" do + map = %{ + "url" => "https://example.com/image.png", + "mediaType" => "image/png", + "filename" => "image.png" + } + + {:ok, part} = JSON.decode(map, :part) + assert %A2A.Part.File{} = part + assert part.file.uri == "https://example.com/image.png" + assert part.file.mime_type == "image/png" + assert part.media_type == "image/png" + assert part.filename == "image.png" + end + + test "v0.3 format still works (kind-based)" do + map = %{"kind" => "text", "text" => "hello"} + {:ok, part} = JSON.decode(map, :part) + assert %A2A.Part.Text{text: "hello"} = part + end + + test "v0.3 format without kind still works (inference)" do + map = %{"text" => "hello"} + {:ok, part} = JSON.decode(map, :part) + assert %A2A.Part.Text{text: "hello"} = part + end + end + + # ------------------------------------------------------------------- + # AgentCard — New Fields + # ------------------------------------------------------------------- + + describe "AgentCard signatures" do + test "struct defaults" do + card = %A2A.AgentCard{ + name: "test", + description: "test agent", + url: "https://example.com", + version: "1.0", + skills: [] + } + + assert card.signatures == [] + assert card.security_requirements == [] + end + + test "decode agent card with signatures" do + map = %{ + "name" => "test", + "description" => "test agent", + "url" => "https://example.com", + "version" => "1.0", + "skills" => [], + "signatures" => [ + %{ + "protected" => "eyJhbGciOiJSUzI1NiJ9", + "signature" => "abc123", + "header" => %{"kid" => "key-1"} + } + ], + "securityRequirements" => [ + %{"oauth2" => ["read", "write"]} + ] + } + + {:ok, card} = JSON.decode_agent_card(map) + assert length(card.signatures) == 1 + [sig] = card.signatures + assert %A2A.AgentCardSignature{} = sig + assert sig.protected == "eyJhbGciOiJSUzI1NiJ9" + assert sig.signature == "abc123" + assert sig.header == %{"kid" => "key-1"} + assert card.security_requirements == [%{"oauth2" => ["read", "write"]}] + end + end + + describe "AgentCard skill v1.0 fields" do + test "decode skills with examples, input/output modes, security_requirements" do + map = %{ + "name" => "test", + "description" => "test agent", + "url" => "https://example.com", + "version" => "1.0", + "skills" => [ + %{ + "id" => "s1", + "name" => "Skill One", + "description" => "Does things", + "tags" => ["general"], + "examples" => ["Do something", "Help me"], + "inputModes" => ["text/plain", "application/json"], + "outputModes" => ["text/plain"], + "securityRequirements" => [%{"oauth2" => ["read"]}] + } + ] + } + + {:ok, card} = JSON.decode_agent_card(map) + [skill] = card.skills + assert skill.examples == ["Do something", "Help me"] + assert skill.input_modes == ["text/plain", "application/json"] + assert skill.output_modes == ["text/plain"] + assert skill.security_requirements == [%{"oauth2" => ["read"]}] + end + + test "decode skills without v1.0 fields (backward compat)" do + map = %{ + "name" => "test", + "description" => "test agent", + "url" => "https://example.com", + "version" => "1.0", + "skills" => [ + %{ + "id" => "s1", + "name" => "Skill One", + "description" => "Does things", + "tags" => [] + } + ] + } + + {:ok, card} = JSON.decode_agent_card(map) + [skill] = card.skills + assert skill.id == "s1" + refute Map.has_key?(skill, :examples) + refute Map.has_key?(skill, :input_modes) + end + + test "encode skills with v1.0 fields" do + card = %{ + name: "test", + description: "test agent", + version: "1.0", + skills: [ + %{ + id: "s1", + name: "Skill One", + description: "Does things", + tags: ["general"], + examples: ["Do something"], + input_modes: ["text/plain"], + output_modes: ["application/json"], + security_requirements: [%{"key" => ["scope"]}] + } + ] + } + + map = JSON.encode_agent_card(card, url: "https://example.com") + [skill_map] = map["skills"] + assert skill_map["examples"] == ["Do something"] + assert skill_map["inputModes"] == ["text/plain"] + assert skill_map["outputModes"] == ["application/json"] + assert skill_map["securityRequirements"] == [%{"key" => ["scope"]}] + end + end + + describe "AgentCard capabilities extensions" do + test "decode capabilities with extensions" do + map = %{ + "name" => "test", + "description" => "test agent", + "url" => "https://example.com", + "version" => "1.0", + "skills" => [], + "capabilities" => %{ + "streaming" => true, + "extensions" => [ + %{ + "uri" => "urn:a2a:ext:test", + "description" => "Test extension", + "required" => true, + "params" => %{"key" => "val"} + } + ] + } + } + + {:ok, card} = JSON.decode_agent_card(map) + assert card.capabilities.streaming == true + assert length(card.capabilities.extensions) == 1 + [ext] = card.capabilities.extensions + assert %A2A.AgentExtension{} = ext + assert ext.uri == "urn:a2a:ext:test" + assert ext.description == "Test extension" + assert ext.required == true + assert ext.params == %{"key" => "val"} + end + end + + describe "AgentCard interface tenant" do + test "decode interface with tenant" do + map = %{ + "name" => "test", + "description" => "test agent", + "url" => "https://example.com", + "version" => "1.0", + "skills" => [], + "supportedInterfaces" => [ + %{ + "url" => "https://api.example.com/a2a", + "protocolBinding" => "JSONRPC", + "protocolVersion" => "1.0", + "tenant" => "tenant-1" + } + ] + } + + {:ok, card} = JSON.decode_agent_card(map) + [iface] = card.supported_interfaces + assert iface.tenant == "tenant-1" + assert iface.url == "https://api.example.com/a2a" + assert iface.protocol_binding == "JSONRPC" + assert iface.protocol_version == "1.0" + end + + test "encode interface with tenant" do + card = %{name: "test", description: "d", version: "1.0", skills: []} + + map = + JSON.encode_agent_card(card, + url: "https://example.com", + supported_interfaces: [ + %{ + url: "https://api.example.com", + protocol_binding: "JSONRPC", + protocol_version: "1.0", + tenant: "t1" + } + ] + ) + + [iface] = map["supportedInterfaces"] + assert iface["tenant"] == "t1" + end + end + + # ------------------------------------------------------------------- + # TaskState enum — verify all v1.0 values + # ------------------------------------------------------------------- + + describe "TaskState enum values" do + test "all v1.0 states encode correctly" do + states = [ + :submitted, + :working, + :completed, + :failed, + :canceled, + :input_required, + :rejected, + :auth_required, + :unknown + ] + + for state <- states do + status = A2A.Task.Status.new(state) + {:ok, map} = JSON.encode(status) + assert is_binary(map["state"]) + {:ok, decoded} = JSON.decode(map, :status) + assert decoded.state == state + end + end + + test "decode TASK_STATE_UNSPECIFIED" do + # TASK_STATE_UNSPECIFIED maps to :unknown + {:ok, state} = JSON.decode_state("TASK_STATE_UNKNOWN") + assert state == :unknown + end + end +end