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
4 changes: 2 additions & 2 deletions a2a-ruby.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ Gem::Specification.new do |spec|
# Core runtime dependencies - minimal and compatible
spec.add_dependency "activesupport", ">= 6.0", "< 8.0"
spec.add_dependency "concurrent-ruby", "~> 1.0"
spec.add_dependency "faraday", "~> 2.0"
spec.add_dependency "faraday", ">= 1.8", "< 3.0"
spec.add_dependency "jwt", "~> 2.0"
spec.add_dependency "rack", ">= 2.0", "< 4.0"

# Optional dependencies for enhanced functionality
spec.add_dependency "redis", ">= 4.0", "< 6.0"

# Web server support
spec.add_dependency "puma", ">= 5.0", "< 7.0"
spec.add_dependency "puma", ">= 5.0", "< 8.0"

# Development dependencies
spec.add_development_dependency "rspec", "~> 3.12"
Expand Down
6 changes: 3 additions & 3 deletions lib/a2a/client/http_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def get_card(context: nil, authenticated: false)
else
# Use HTTP GET for basic agent card
response = execute_with_middleware({}, context || {}) do |_req, _ctx|
@connection.get("/agent-card") do |request|
@connection.get("/.well-known/agent-card.json") do |request|
request.headers.merge!(@config.all_headers)
end
end
Expand Down Expand Up @@ -305,7 +305,7 @@ def build_connection
# @param context [Hash] The request context
# @return [Message] The response message
def send_sync_message(message, context)
request = build_json_rpc_request("message/send", message.to_h)
request = build_json_rpc_request("message/send", { message: message.to_h })
response = execute_with_middleware(request, context) do |req, _ctx|
send_json_rpc_request(req)
end
Expand All @@ -320,7 +320,7 @@ def send_sync_message(message, context)
# @param context [Hash] The request context
# @return [Enumerator] Stream of response messages
def send_streaming_message(message, context)
request = build_json_rpc_request("message/stream", message.to_h)
request = build_json_rpc_request("message/stream", { message: message.to_h })

execute_with_middleware(request, context) do |req, _ctx|
send_streaming_request(req)
Expand Down
2 changes: 1 addition & 1 deletion lib/a2a/rails/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
#
module A2A
module Rails
class Engine < Rails::Engine
class Engine < ::Rails::Engine
extend A2A::Utils::RailsDetection

isolate_namespace A2A::Rails
Expand Down
16 changes: 9 additions & 7 deletions lib/a2a/types/agent_card.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ class AgentCard < A2A::Types::BaseModel
# @param description [String] Agent description (required)
# @param version [String] Agent version (required)
# @param url [String] Primary agent URL (required)
# @param preferred_transport [String] Preferred transport protocol (required)
# @param preferred_transport [String] Preferred transport protocol (defaults to "JSONRPC")
# @param skills [Array<AgentSkill>] Agent skills (required)
# @param capabilities [AgentCapabilities] Agent capabilities (required)
# @param default_input_modes [Array<String>] Default input modes (required)
# @param default_output_modes [Array<String>] Default output modes (required)
# @param default_input_modes [Array<String>] Default input modes (defaults to ["text"])
# @param default_output_modes [Array<String>] Default output modes (defaults to ["text"])
# @param additional_interfaces [Array<AgentInterface>, nil] Additional interfaces
# @param security [Array<String>, nil] Security requirements
# @param security_schemes [Hash<String, SecurityScheme>, nil] Security scheme definitions
Expand All @@ -51,8 +51,8 @@ class AgentCard < A2A::Types::BaseModel
# @param signatures [Array<AgentCardSignature>, nil] JWS signatures
# @param documentation_url [String, nil] Documentation URL
# @param icon_url [String, nil] Icon URL
def initialize(name:, description:, version:, url:, preferred_transport:, skills:,
capabilities:, default_input_modes:, default_output_modes:,
def initialize(name:, description:, version:, url:, preferred_transport: "JSONRPC", skills:,
capabilities:, default_input_modes: ["text"], default_output_modes: ["text"],
additional_interfaces: nil, security: nil, security_schemes: nil,
provider: nil, protocol_version: nil, supports_authenticated_extended_card: nil,
signatures: nil, documentation_url: nil, icon_url: nil)
Expand Down Expand Up @@ -132,8 +132,10 @@ def process_security_schemes(schemes)
end

def validate!
validate_required(:name, :description, :version, :url, :preferred_transport,
:skills, :capabilities, :default_input_modes, :default_output_modes)
validate_required(:name, :description, :version, :url)
# Skills and capabilities are required but can be empty
raise ArgumentError, "skills is required" if @skills.nil?
raise ArgumentError, "capabilities is required" if @capabilities.nil?
validate_inclusion(:preferred_transport, VALID_TRANSPORTS)
validate_array_type(:skills, AgentSkill)
validate_type(:capabilities, AgentCapabilities)
Expand Down
6 changes: 4 additions & 2 deletions lib/a2a/types/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def validate!
# Represents the status of a task
#
class TaskStatus < A2A::Types::BaseModel
attr_reader :state, :message, :progress, :result, :error, :updated_at
attr_reader :state, :message, :progress, :result, :error, :updated_at, :timestamp

##
# Initialize a new task status
Expand All @@ -101,13 +101,15 @@ class TaskStatus < A2A::Types::BaseModel
# @param result [Object, nil] Task result (for completed tasks)
# @param error [Hash, nil] Error information (for failed tasks)
# @param updated_at [String, nil] ISO 8601 timestamp of last update
def initialize(state:, message: nil, progress: nil, result: nil, error: nil, updated_at: nil)
# @param timestamp [String, nil] ISO 8601 timestamp (A2A spec field)
def initialize(state:, message: nil, progress: nil, result: nil, error: nil, updated_at: nil, timestamp: nil)
@state = state
@message = message
@progress = progress
@result = result
@error = error
@updated_at = updated_at || Time.now.utc.iso8601
@timestamp = timestamp

validate!
end
Expand Down
254 changes: 254 additions & 0 deletions spec/compliance/fasta2a_compatibility_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# frozen_string_literal: true

##
# FastA2A Compatibility Test Suite
#
# This test suite validates compatibility with FastA2A (PydanticAI's A2A server implementation)
# for A2A Protocol v0.3.0.
#
# Tests cover the 5 bugs identified during integration testing:
# 1. Rails::Engine constant resolution
# 2. Agent card endpoint path (/.well-known/agent-card.json)
# 3. send_message JSON-RPC params structure
# 4. AgentCard optional fields with defaults
# 5. TaskStatus timestamp field
#
RSpec.describe "FastA2A Compatibility", :compliance do
describe "Bug 2: Agent Card Endpoint Path" do
it "uses .well-known/agent-card.json per A2A spec §5.3" do
# Verify the endpoint path is correct in the HTTP client
client_code = File.read("lib/a2a/client/http_client.rb")
expect(client_code).to include('/.well-known/agent-card.json')
expect(client_code).not_to include('@connection.get("/agent-card")')
end
end

describe "Bug 3: send_message JSON-RPC Params Structure" do
it "wraps message in { message: ... } per A2A spec §7.1.1" do
# Verify both sync and streaming methods wrap params correctly
client_code = File.read("lib/a2a/client/http_client.rb")
expect(client_code).to include('build_json_rpc_request("message/send", { message: message.to_h })')
expect(client_code).to include('build_json_rpc_request("message/stream", { message: message.to_h })')
end
end

describe "Bug 4: AgentCard Optional Fields" do
context "with minimal required fields only" do
it "uses defaults for preferredTransport, defaultInputModes, defaultOutputModes" do
card = A2A::Types::AgentCard.new(
name: "Test Agent",
description: "Test description",
version: "1.0.0",
url: "https://example.com",
skills: [],
capabilities: A2A::Types::AgentCapabilities.new
)

expect(card.preferred_transport).to eq("JSONRPC")
expect(card.default_input_modes).to eq(["text"])
expect(card.default_output_modes).to eq(["text"])
end
end

context "parsing FastA2A response (camelCase)" do
it "parses agent card with all fields present" do
fast_a2a_card = {
"name" => "FastA2A Agent",
"description" => "Test agent from FastA2A",
"version" => "1.0.0",
"url" => "https://example.com",
"preferredTransport" => "JSONRPC",
"skills" => [],
"capabilities" => {},
"defaultInputModes" => ["text"],
"defaultOutputModes" => ["text"]
}

card = A2A::Types::AgentCard.from_h(fast_a2a_card)
expect(card.preferred_transport).to eq("JSONRPC")
expect(card.default_input_modes).to eq(["text"])
expect(card.default_output_modes).to eq(["text"])
end

it "parses agent card without optional fields (using defaults)" do
minimal_fast_a2a_card = {
"name" => "FastA2A Agent",
"description" => "Minimal agent from FastA2A",
"version" => "1.0.0",
"url" => "https://example.com",
"skills" => [],
"capabilities" => {}
# Note: preferredTransport, defaultInputModes, defaultOutputModes omitted
}

card = A2A::Types::AgentCard.from_h(minimal_fast_a2a_card)
expect(card.preferred_transport).to eq("JSONRPC")
expect(card.default_input_modes).to eq(["text"])
expect(card.default_output_modes).to eq(["text"])
end

it "accepts empty arrays for skills and capabilities" do
card_with_empty_arrays = {
"name" => "Test Agent",
"description" => "Test",
"version" => "1.0.0",
"url" => "https://example.com",
"skills" => [],
"capabilities" => {}
}

expect do
A2A::Types::AgentCard.from_h(card_with_empty_arrays)
end.not_to raise_error
end
end
end

describe "Bug 5: TaskStatus Timestamp Field" do
context "parsing FastA2A task status response" do
it "accepts timestamp field per A2A spec §6.2" do
fast_a2a_status = {
"state" => "working",
"message" => "Processing request",
"timestamp" => "2026-02-18T11:30:41.470908"
}

status = A2A::Types::TaskStatus.from_h(fast_a2a_status)
expect(status.state).to eq("working")
expect(status.timestamp).to eq("2026-02-18T11:30:41.470908")
end

it "works without timestamp field (optional)" do
status_without_timestamp = {
"state" => "completed",
"message" => "Done"
}

status = A2A::Types::TaskStatus.from_h(status_without_timestamp)
expect(status.state).to eq("completed")
expect(status.timestamp).to be_nil
end
end

context "TaskStatus serialization" do
it "includes timestamp in to_h output when present" do
status = A2A::Types::TaskStatus.new(
state: "working",
timestamp: "2026-02-18T11:30:41.470908"
)

# to_h returns camelCase keys by default
hash = status.to_h
expect(hash["timestamp"]).to eq("2026-02-18T11:30:41.470908")
end

it "omits timestamp from to_h output when nil (default behavior)" do
status = A2A::Types::TaskStatus.new(state: "working")
hash = status.to_h
# BaseModel.to_h excludes nil values by default
expect(hash).not_to have_key("timestamp")
end
end
end

describe "Full FastA2A Integration Scenario" do
it "handles complete agent card response from FastA2A" do
# Simulate a real FastA2A agent card response
fast_a2a_response = {
"name" => "FastA2A Test Agent",
"description" => "PydanticAI-based A2A agent",
"version" => "0.3.0",
"url" => "https://fasta2a.example.com",
"preferredTransport" => "JSONRPC",
"skills" => [
{
"id" => "test_skill",
"name" => "Test Skill",
"description" => "A test skill from FastA2A"
}
],
"capabilities" => {
"streaming" => true,
"pushNotifications" => false
},
"defaultInputModes" => ["text/plain"],
"defaultOutputModes" => ["application/json"]
}

card = A2A::Types::AgentCard.from_h(fast_a2a_response)
expect(card.name).to eq("FastA2A Test Agent")
expect(card.preferred_transport).to eq("JSONRPC")
expect(card.skills.length).to eq(1)
expect(card.skills.first.id).to eq("test_skill")
expect(card.capabilities.streaming?).to be true
expect(card.default_input_modes).to eq(["text/plain"])
expect(card.default_output_modes).to eq(["application/json"])
end

it "handles task status with timestamp from FastA2A" do
# Simulate a FastA2A task status update
fast_a2a_task = {
"id" => "task-123",
"contextId" => "context-456",
"kind" => "task",
"status" => {
"state" => "working",
"message" => "Processing with FastA2A",
"progress" => 0.5,
"timestamp" => "2026-02-18T11:30:41.470908"
}
}

task = A2A::Types::Task.from_h(fast_a2a_task)
expect(task.status.state).to eq("working")
expect(task.status.timestamp).to eq("2026-02-18T11:30:41.470908")
expect(task.status.progress).to eq(0.5)
end
end

describe "camelCase/snake_case Conversion" do
it "converts FastA2A camelCase to Ruby snake_case automatically" do
camel_case_data = {
"preferredTransport" => "JSONRPC",
"defaultInputModes" => ["text"],
"defaultOutputModes" => ["text"],
"supportsAuthenticatedExtendedCard" => true
}

# Test that BaseModel.from_h correctly converts keys
card_data = camel_case_data.merge({
"name" => "Test",
"description" => "Test",
"version" => "1.0.0",
"url" => "https://example.com",
"skills" => [],
"capabilities" => {}
})

card = A2A::Types::AgentCard.from_h(card_data)
expect(card.preferred_transport).to eq("JSONRPC")
expect(card.default_input_modes).to eq(["text"])
expect(card.default_output_modes).to eq(["text"])
expect(card.supports_authenticated_extended_card).to be true
end

it "converts Ruby snake_case to camelCase for FastA2A responses" do
card = A2A::Types::AgentCard.new(
name: "Test",
description: "Test",
version: "1.0.0",
url: "https://example.com",
skills: [],
capabilities: A2A::Types::AgentCapabilities.new
)

hash = card.to_h(camel_case: true)
expect(hash).to have_key("preferredTransport")
expect(hash).to have_key("defaultInputModes")
expect(hash).to have_key("defaultOutputModes")
expect(hash).not_to have_key(:preferred_transport)
expect(hash).not_to have_key(:default_input_modes)
expect(hash).not_to have_key(:default_output_modes)
end
end
end