diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..5678d62 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +MSG_FILE="${1:?commit message path is required}" +MSG="$(head -n 1 "$MSG_FILE" | tr -d '\r')" + +if [[ "$MSG" =~ ^(Merge|Revert)\ ]]; then + exit 0 +fi + +PATTERN='^(feat|fix|docs|refactor|test|chore)(\([a-z0-9][a-z0-9._/-]*\))?!?: .+$' + +if [[ ! "$MSG" =~ $PATTERN ]]; then + cat >&2 <<'EOF' +commit message must follow Conventional Commits: + (): + +Examples: + feat(types): add AgentInterface serde model + fix(server): return proper JSON-RPC error code + docs(readme): update protocol version note +EOF + exit 1 +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 54903b3..080e4cb 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -4,5 +4,8 @@ set -euo pipefail echo "[pre-commit] cargo fmt --all -- --check" cargo fmt --all -- --check -echo "[pre-commit] cargo clippy --all-targets --all-features -- -D warnings" -cargo clippy --all-targets --all-features -- -D warnings +echo "[pre-commit] cargo clippy --all-targets --no-default-features -- -D warnings" +cargo clippy --all-targets --no-default-features -- -D warnings + +echo "[pre-commit] cargo check --all-features" +cargo check --all-features diff --git a/.githooks/pre-push b/.githooks/pre-push index c58421a..0ee7acc 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,5 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +echo "[pre-push] cargo test --no-default-features" +cargo test --no-default-features + echo "[pre-push] cargo test --all-features" cargo test --all-features diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87eeceb..a272919 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,14 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true env: CARGO_TERM_COLOR: always @@ -29,6 +37,22 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo test --all-features + minimal: + name: Minimal Feature Set + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo check --no-default-features + - run: cargo test --no-default-features + - run: cargo clippy --all-targets --no-default-features -- -D warnings + - run: cargo doc --no-deps --no-default-features + env: + RUSTDOCFLAGS: -Dwarnings + clippy: name: Clippy runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 041d818..c89cd66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,309 +1,230 @@ # AGENTS.md -This file provides guidance to AI coding agents when working with code in this repository. +This file provides guidance to AI coding agents working in this repository. ## Project Overview -`a2a-rust` is a generic Rust SDK for the Google A2A (Agent-to-Agent) protocol. It provides a complete type system, an axum-based server framework, and an HTTP client library. This is the **first Rust A2A SDK** and the **first v1.0 RC implementation** in any language. +`a2a-rust` is a generic Rust SDK for A2A (Agent-to-Agent) Protocol v1.0 RC. -**This crate has ZERO Clawhive-specific logic.** It is a pure protocol SDK intended for any Rust project. +Current implementation status: -## Protocol Version Lock +- implemented now: `types`, `error`, `jsonrpc`, `server`, `client`, and `store` +- remaining work: docs, examples, and release polish -All types and behavior strictly align with **A2A Protocol v1.0 RC**: -- Git tag: `v1.0.0-rc`, commit `6292104`, dated 2026-01-29 -- Proto package: `lf.a2a.v1` -- Spec: https://a2a-protocol.org/latest/specification/ -- Proto source: https://github.com/a2aproject/A2A/blob/v1.0.0-rc/specification/a2a.proto +This crate has zero Clawhive-specific logic. Keep it generic and protocol-focused. -**The proto file is the single source of truth.** When in doubt about a type, field, or behavior, check the proto definition. +## Source of Truth -## Build / Test / Lint +Protocol lock: -```bash -# Build -cargo build +- tag: `v1.0.0-rc` +- commit: `6292104` +- proto package: `a2a.v1` + +Normative source precedence: -# Run all tests -cargo test +1. tagged proto +2. current spec prose +3. repository-local design docs -# Lint (zero warnings required) -cargo clippy --all-targets -- -D warnings +Use the repo-local design contract at [docs/proto-first-design.md](docs/proto-first-design.md). -# Format check -cargo fmt -- --check +Do not treat the old external planning note as the implementation contract. -# Generate docs -cargo doc --no-deps --open +## Build, Test, and Lint -# Run a single test by name -cargo test -- test_agent_card_deserialization -v +Use these commands before considering a change done: -# Run tests for a specific module -cargo test types:: +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --no-default-features -- -D warnings +cargo check --all-features +cargo test --no-default-features +cargo test --all-features +``` + +Useful variants: + +```bash +# Types-only compile +cargo check --no-default-features -# Build with specific features -cargo build --no-default-features # types only -cargo build --no-default-features --features server # server only -cargo build --no-default-features --features client # client only +# Feature combinations +cargo check --no-default-features --features server +cargo check --no-default-features --features client +cargo check --no-default-features --features server,client ``` -CI runs 4 parallel jobs: check, test, clippy, fmt. `RUSTFLAGS=-Dwarnings` is set — **all warnings are errors**. +CI now checks: -## Project Structure +- all-features build, test, clippy, docs +- no-default-features build, test, clippy, docs +- feature-combination compile matrix +## Git Hooks + +Install local hooks with: + +```bash +just install-hooks ``` + +Current hooks: + +- `pre-commit`: format check, minimal clippy, all-features compile check +- `commit-msg`: Conventional Commits enforcement +- `pre-push`: no-default-features tests and all-features tests + +## Current Project Structure + +```text src/ -├── lib.rs # Public API re-exports +├── lib.rs +├── error.rs +├── jsonrpc.rs +├── store.rs ├── types/ -│ ├── mod.rs -│ ├── agent_card.rs # AgentCard, AgentSkill, AgentCapabilities, AgentProvider, AgentInterface -│ ├── task.rs # Task, TaskState, TaskStatus, TaskStatusUpdateEvent -│ ├── message.rs # Message, Part (unified flat struct), Artifact, Role -│ ├── streaming.rs # StreamResponse, TaskArtifactUpdateEvent -│ ├── security.rs # SecurityScheme (5 variants), SecurityRequirement -│ └── jsonrpc.rs # JSON-RPC 2.0 Request/Response/Error, method constants ├── server/ -│ ├── mod.rs -│ ├── handler.rs # A2AHandler trait — users implement this -│ ├── router.rs # axum Router builder -│ ├── rest.rs # REST endpoint handlers (v1.0 RC paths) -│ ├── jsonrpc.rs # JSON-RPC 2.0 dispatcher -│ └── streaming.rs # SSE streaming implementation -├── client/ -│ ├── mod.rs -│ ├── discovery.rs # AgentCard discovery + TTL cache -│ └── client.rs # A2AClient (send, get, cancel, list, subscribe) -├── store.rs # TaskStore trait + InMemoryTaskStore -└── error.rs # A2AError unified error type +└── client/ ``` -### Key Source Files - -- `src/server/handler.rs` — `A2AHandler` trait: the core trait users implement to build an A2A agent -- `src/server/router.rs` — `router()` function: builds axum Router with all A2A endpoints -- `src/client/client.rs` — `A2AClient`: HTTP client for calling remote A2A agents -- `src/store.rs` — `TaskStore` trait: pluggable task persistence -- `src/error.rs` — `A2AError`: all error types with JSON-RPC code mapping +## Key Source Files + +- `src/error.rs` - `A2AError` and protocol/HTTP error mapping +- `src/jsonrpc.rs` - JSON-RPC 2.0 envelopes, method constants, error-code constants +- `src/store.rs` - `TaskStore` and `InMemoryTaskStore` +- `src/types/agent_id.rs` - validated helper type for agent naming conventions +- `src/types/agent_card.rs` - Agent card and capability model +- `src/types/auth.rs` - `TASK_STATE_AUTH_REQUIRED` metadata helpers +- `src/types/message.rs` - `Message`, `Part`, `Artifact`, `Role` +- `src/types/task.rs` - `Task`, `TaskStatus`, `TaskState` +- `src/types/security.rs` - security schemes and OAuth flow types +- `src/types/requests.rs` - protocol request types +- `src/types/responses.rs` - protocol response and stream-event types +- `src/server/*` - REST, JSON-RPC, SSE, and handler traits +- `src/client/*` - discovery, dual transport client, and SSE parsing +- `docs/proto-first-design.md` - implementation contract ## Feature Flags ```toml [features] default = ["server", "client"] -server = ["axum"] # A2A server framework -client = ["reqwest"] # A2A HTTP client +server = ["dep:async-trait", "dep:axum", "dep:futures-core", "dep:futures-util", "dep:tokio"] +client = ["dep:futures-core", "dep:futures-util", "dep:reqwest"] ``` -Users can depend on types-only by disabling defaults. Server and client are independently toggleable. +`default-features = false` must keep the types-only surface working. -## Code Style +## Protocol and Serialization Rules -### Serde Conventions (CRITICAL) +### JSON field naming -A2A spec uses `camelCase` JSON fields. All struct/enum types must use: +Use `camelCase` JSON field names via `#[serde(rename_all = "camelCase")]`. -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SomeType { - pub some_field: String, // serializes as "someField" - #[serde(default, skip_serializing_if = "Option::is_none")] - pub optional_field: Option, -} -``` +### Enum values -### Enum Value Naming - -v1.0 RC uses `SCREAMING_SNAKE_CASE` for all enum values: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum TaskState { - #[serde(rename = "TASK_STATE_UNSPECIFIED")] - Unspecified, - #[serde(rename = "TASK_STATE_SUBMITTED")] - Submitted, - #[serde(rename = "TASK_STATE_WORKING")] - Working, - // ... -} -``` +Use proto enum strings exactly, for example: -### Part Type +- `TASK_STATE_COMPLETED` +- `ROLE_AGENT` -`Part` is a **flat struct** (NOT a tagged enum). Content type is determined by which field is `Some`: +### JSON-RPC method names -```rust -pub struct Part { - pub text: Option, - pub raw: Option, - pub url: Option, - pub data: Option, - pub metadata: Option, - pub filename: Option, - pub media_type: Option, -} -``` - -### SecurityScheme Serialization +Use PascalCase v1.0 RC method names, for example: -Externally tagged to match proto3 JSON oneof mapping: +- `SendMessage` +- `GetTask` +- `ListTasks` +- `SubscribeToTask` -```rust -#[serde(rename_all = "camelCase")] -pub enum SecurityScheme { - ApiKeySecurityScheme(ApiKeySecurityScheme), - HttpAuthSecurityScheme(HttpAuthSecurityScheme), - // ... -} -// JSON: {"apiKeySecurityScheme": {"in": "header", "name": "X-API-KEY"}} -``` +Do not introduce slash-style method names. -**Note:** Python SDK uses a different format with `type` discriminator. For interop, implement a custom deserializer that accepts both formats. +### Part -### Imports +`Part` is a flat struct, not a tagged enum. -Order: std → external crates → crate-local. One blank line between groups. +- exactly one of `text`, `raw`, `url`, or `data` should be set +- `raw` is modeled as `Vec` in Rust and serialized as base64 in JSON +- use `validate()` when you need an explicit semantic check -```rust -use std::collections::HashMap; -use std::sync::Arc; +### SecurityScheme -use axum::{Router, routing::get}; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; +`SecurityScheme` is externally tagged to match proto-style `oneof` JSON: -use crate::types::*; -use crate::error::A2AError; +```json +{"apiKeySecurityScheme":{"location":"header","name":"X-API-Key"}} ``` -### Error Handling +Important: -- `thiserror` for all public error types (this is a library crate) -- `A2AError` has methods `to_jsonrpc_error()` and `status_code()` for protocol mapping -- Use A2A-defined error codes: `-32001` to `-32005` (do NOT invent new codes) -- Never use `.unwrap()` in non-test code +- the field is `location`, not OpenAPI's `in` +- `OAuthFlows` is modeled as a oneof-style enum +- deserialization also accepts the Python SDK `type`-discriminator shape for interop +- deprecated OAuth flows still exist in the tagged proto and remain part of the wire model -### Derive Order +### AgentId -Always: `Debug, Clone, Serialize, Deserialize` (consistent across codebase). +The crate exposes an `AgentId` helper for repository-level naming rules: -### Naming +- lowercase ASCII letters, digits, and `-` only +- length 3-64 characters -- Modules: `snake_case` -- Structs/Enums: `PascalCase` -- Functions: `snake_case` -- Constants: `SCREAMING_SNAKE_CASE` +This helper is not a proto field. -### Logging +### AUTH_REQUIRED convention -Use `tracing` crate, not `log` or `println!`: +The crate exposes `AuthRequiredMetadata` plus helper methods on `Message`, `TaskStatus`, and `Task` +for the repository's `TASK_STATE_AUTH_REQUIRED` metadata convention: -```rust -tracing::info!(agent = %card.name, "agent card served"); -``` - -### Tests +- `authUrl` +- `authScheme` +- `scopes` +- `description` -- Inline tests in `#[cfg(test)] mod tests { }` at bottom of each file -- Integration tests in `tests/` directory -- Test function names describe behavior: `fn agent_card_round_trip_serialization()` -- **Type serde tests are the most critical** — use A2A spec JSON examples as test data +### Required shape corrections already reflected in code -### Async +- `Task.context_id` is required +- `TaskStatusUpdateEvent.context_id` is required +- `TaskArtifactUpdateEvent.context_id` is required +- `ListTaskPushNotificationConfigResponse.next_page_token` is a string, with empty string meaning no next page -- Tokio runtime -- `async_trait` for async trait methods -- `Arc` for shared state +### Error codes -## Key Patterns +Use: -### A2AHandler Trait +- standard JSON-RPC codes (`-32700` to `-32603`) where appropriate +- A2A-specific codes `-32001` through `-32009` -The core trait users implement. All methods have default implementations returning `UnsupportedOperation` except `get_agent_card` and `handle_send_message`. +Do not invent new A2A error codes. -### Router Builder - -```rust -let app = a2a_rust::server::router(my_handler); -// Mounts all REST + JSON-RPC + well-known endpoints -``` +## Code Style -### TaskStore Trait +- Derive order: `Debug, Clone, Serialize, Deserialize` +- Imports: std, external crates, crate-local +- No `unwrap()` outside tests +- No `unsafe` +- No Clawhive-specific imports or terminology +- Avoid unnecessary dependencies -```rust -pub trait TaskStore: Send + Sync + 'static { - async fn get(&self, task_id: &str) -> Result, A2AError>; - async fn put(&self, task: &Task) -> Result<(), A2AError>; - async fn list(&self, req: &ListTasksRequest) -> Result; - async fn delete(&self, task_id: &str) -> Result; -} -``` +## Testing Guidance -Built-in `InMemoryTaskStore` provides TTL-based expiration and LRU eviction. Downstream projects (clawhive-a2a, clawhive-hub) implement `SqliteTaskStore`. +The repo now has three test layers: -### JSON-RPC Constants +- inline unit and serde tests in `src/*` +- `tests/server_integration.rs` for axum router coverage +- `tests/client_integration.rs` and `tests/client_wiremock.rs` for client behavior -```rust -pub const METHOD_MESSAGE_SEND: &str = "message/send"; -pub const METHOD_TASKS_GET: &str = "tasks/get"; -// etc. -pub const TASK_NOT_FOUND: i32 = -32001; -pub const TASK_NOT_CANCELABLE: i32 = -32002; -// etc. -``` +Prefer: -## Critical Implementation Notes - -1. **JSON field names** — Always `camelCase` via `#[serde(rename_all = "camelCase")]` -2. **SecurityScheme serde** — Externally tagged (proto3 oneof), with optional interop deserializer for Python SDK format -3. **Enum values** — `SCREAMING_SNAKE_CASE` (e.g., `TASK_STATE_COMPLETED`, `ROLE_AGENT`) -4. **Part structure** — Flat struct with optional fields, NOT a tagged enum -5. **SSE format** — Standard `data:` prefix + double newline, payload is `StreamResponse` wrapper -6. **JSON-RPC strictness** — `jsonrpc` must be `"2.0"`, `id` must be echoed back -7. **Error codes** — Use only A2A-defined codes (`-32001` to `-32005`) -8. **Default values** — Missing `Option` fields → `None`, not empty string -9. **No Clawhive** — Zero imports from any `clawhive-*` crate, no Clawhive-specific terminology -10. **Proto is truth** — Proto definition is the canonical reference, locked to tag `v1.0.0-rc` - -## Don'ts - -- **No `unsafe`** without explicit justification -- **No `.unwrap()`** outside of tests -- **No `println!`** — use `tracing::*` -- **No suppressing clippy** with `#[allow(...)]` without a comment explaining why -- **No new dependencies** without checking if existing deps provide equivalent functionality -- **No Clawhive imports** — this is a generic SDK -- **No `as any`** type assertions in doc examples -- **No inventing error codes** — stick to A2A spec -- **No `agent.json`** — well-known path is `agent-card.json` (v1.0 RC) -- **No lowercase enum values** — all enum serialization uses `SCREAMING_SNAKE_CASE` - -## Dependencies - -### Runtime - -- `serde`, `serde_json` — serialization -- `axum` (feature: `server`) — HTTP server -- `reqwest` (feature: `client`) — HTTP client -- `tokio` — async runtime -- `futures`, `tokio-stream` — streaming -- `thiserror`, `anyhow` — error handling -- `async-trait` — async trait support -- `tracing` — structured logging - -### Dev - -- `tower` — testing axum handlers -- `wiremock` — HTTP mocking -- `tempfile` — filesystem tests +- canonical spec/proto JSON examples +- explicit invalid-shape tests for `Part`, `SendMessageResponse`, and `StreamResponse` +- `wiremock` for client-only transport and envelope behavior +- local axum server tests for end-to-end server/client behavior ## References +- [Proto-first design](docs/proto-first-design.md) - [A2A Protocol Spec v1.0 RC](https://a2a-protocol.org/latest/specification/) -- [A2A What's New in V1](https://a2a-protocol.org/latest/whats-new-v1/) -- [A2A Proto (v1.0.0-rc)](https://github.com/a2aproject/A2A/blob/v1.0.0-rc/specification/a2a.proto) -- [A2A Agent Discovery](https://a2a-protocol.org/latest/topics/agent-discovery/) +- [A2A Proto v1.0.0-rc](https://github.com/a2aproject/A2A/blob/v1.0.0-rc/specification/a2a.proto) - [JSON-RPC 2.0 Spec](https://www.jsonrpc.org/specification) -- [OpenAPI 3.2 Security Scheme](https://spec.openapis.org/oas/v3.2.0.html#security-scheme-object) diff --git a/CLAUDE.md b/CLAUDE.md index 20d4d3d..896685c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,37 +1,50 @@ # CLAUDE.md -Read AGENTS.md first — it contains the full project context, code style, and conventions. +Read `AGENTS.md` first. ## Quick Reference -- **Language**: Rust, edition 2021, MSRV 1.75+ -- **Protocol**: A2A v1.0 RC (tag `v1.0.0-rc`, commit `6292104`) -- **This is a library crate** — published to crates.io, no binary -- **Zero Clawhive dependency** — never import `clawhive-*` crates +- Language: Rust, edition 2024 +- Protocol: A2A v1.0 RC, locked to tag `v1.0.0-rc` +- Proto package: `a2a.v1` +- Current implemented surface: `types`, `error`, `jsonrpc`, `server`, `client`, `store` +- Remaining work: docs, examples, release polish +- Zero Clawhive dependency + +## Current Design Contract + +Use the repo-local design doc: + +`docs/proto-first-design.md` + +Do not treat the old external iCloud note as the implementation contract. ## Before Every Change ```bash -cargo fmt -- --check -cargo clippy --all-targets -- -D warnings -cargo test +cargo fmt --all -- --check +cargo clippy --all-targets --no-default-features -- -D warnings +cargo clippy --all-targets --all-features -- -D warnings +cargo test --no-default-features +cargo test --all-features +cargo check --all-features --examples ``` -All three must pass. CI treats warnings as errors. - -## Critical Rules - -1. All JSON field names use `camelCase` (`#[serde(rename_all = "camelCase")]`) -2. All enum values use `SCREAMING_SNAKE_CASE` (`#[serde(rename = "TASK_STATE_COMPLETED")]`) -3. `Part` is a flat struct with optional fields — NOT a tagged enum -4. `SecurityScheme` is externally tagged (proto3 oneof format) -5. Never use `.unwrap()` outside tests -6. Never add Clawhive-specific code -7. The proto file at tag `v1.0.0-rc` is the single source of truth for all types +Install hooks once per clone: -## Design Doc +```bash +just install-hooks +``` -The full design specification is at: -`~/Library/Mobile Documents/iCloud~md~obsidian/Documents/obsidian-vault/Projects/clawhive/research/a2a-rust-design.md` +## Critical Rules -This document contains complete type definitions, API designs, and implementation notes. +1. Follow the tagged proto first, then the spec, then local docs +2. Use `camelCase` JSON field names +3. Use proto enum strings in `SCREAMING_SNAKE_CASE` +4. `Part` is a flat struct, not a tagged enum +5. `Part.raw` is `Vec` and serializes as base64 JSON +6. JSON-RPC method names are PascalCase, not slash-style +7. `SecurityScheme` is externally tagged and API-key uses `location`, not `in` +8. Use A2A-specific error codes `-32001` through `-32009` when applicable +9. Never use `.unwrap()` outside tests +10. Never add Clawhive-specific code diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb9bdcf..a863b34 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,16 +20,25 @@ Be respectful, inclusive, and constructive. We're all here to build a great A2A - Rust 1.75+ (install via [rustup](https://rustup.rs/)) - Git +Install local git hooks after cloning: + +```bash +just install-hooks +``` + ### Build & Test ```bash cargo build cargo test +cargo test --no-default-features cargo clippy --all-targets --all-features -- -D warnings +cargo clippy --all-targets --no-default-features -- -D warnings cargo fmt --all +cargo check --all-features --examples ``` -All four must pass before submitting a PR. CI treats all warnings as errors. +These checks must pass before submitting a PR. CI treats all warnings as errors. ### Feature Flags @@ -98,6 +107,8 @@ test(serde): add round-trip tests for SecurityScheme variants docs(readme): add SSE streaming example ``` +Local hooks enforce this commit message format and run checks on `git commit` and `git push`. + ## Pull Request Process 1. **Update your branch** with the latest upstream changes: @@ -153,11 +164,16 @@ Use `wiremock` to mock A2A servers: ```rust let mock_server = MockServer::start().await; -Mock::given(method("POST")).and(path("/jsonrpc")) +Mock::given(method("POST")).and(path("/rpc")) .respond_with(ResponseTemplate::new(200).set_body_json(/* ... */)) .mount(&mock_server).await; ``` +There are two client test styles in this repo: + +- `tests/client_integration.rs` for end-to-end behavior against the local axum server +- `tests/client_wiremock.rs` for client-only transport and wire-shape tests + ## Questions? - Open a [Discussion](https://github.com/longzhi/a2a-rust/discussions) diff --git a/Cargo.toml b/Cargo.toml index 29322d8..0df2b18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,10 +2,45 @@ name = "a2a-rust" version = "0.1.0" edition = "2024" +rust-version = "1.85" +description = "Rust SDK for the A2A (Agent-to-Agent) protocol" +license = "Apache-2.0 OR MIT" +repository = "https://github.com/longzhi/a2a-rust" +documentation = "https://docs.rs/a2a-rust" +keywords = ["a2a", "agent", "protocol", "sdk"] +categories = ["api-bindings", "web-programming"] [features] default = ["server", "client"] -server = [] -client = [] +server = ["dep:async-trait", "dep:axum", "dep:futures-core", "dep:futures-util", "dep:tokio"] +client = ["dep:futures-core", "dep:futures-util", "dep:reqwest"] [dependencies] +async-trait = { version = "0.1", optional = true } +base64 = "0.22" +http = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" + +axum = { version = "0.8", optional = true } +futures-core = { version = "0.3", optional = true } +futures-util = { version = "0.3", optional = true } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"], optional = true } +tokio = { version = "1", features = ["sync"], optional = true } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } +tower = { version = "0.5", features = ["util"] } +wiremock = "0.6" + +[package.metadata.docs.rs] +all-features = true + +[[example]] +name = "echo_server" +required-features = ["server"] + +[[example]] +name = "ping_client" +required-features = ["client"] diff --git a/README.md b/README.md index 8867a8b..36ffd28 100644 --- a/README.md +++ b/README.md @@ -5,172 +5,272 @@ [![docs.rs](https://docs.rs/a2a-rust/badge.svg)](https://docs.rs/a2a-rust) [![License](https://img.shields.io/crates/l/a2a-rust.svg)](LICENSE-MIT) -A Rust SDK for the [Google A2A (Agent-to-Agent)](https://a2a-protocol.org/) protocol. Provides complete type definitions, a server framework, and a client library for building A2A-compatible agents. +Rust SDK for A2A Protocol v1.0 RC. -**First Rust A2A SDK. First v1.0 implementation in any language.** +`a2a-rust` provides: + +- a proto-aligned type layer +- an axum-based server with REST, JSON-RPC, and SSE +- a reqwest-based client with discovery, dual transport, and SSE parsing +- a pluggable `TaskStore` plus `InMemoryTaskStore` + +This crate has zero Clawhive-specific logic. + +## Status + +- Protocol lock: `v1.0.0-rc` +- Proto package: `a2a.v1` +- Implemented transports: `JSONRPC`, `HTTP+JSON` +- Out of scope: gRPC + +The tagged proto is the source of truth. The repo-local implementation contract is [docs/proto-first-design.md](docs/proto-first-design.md). ## Features -- **Complete type system** — All A2A v1.0 RC types (AgentCard, Task, Message, Part, SecurityScheme, etc.) with serde serialization -- **Server framework** — axum-based router supporting both REST and JSON-RPC 2.0 bindings, with SSE streaming -- **Client library** — AgentCard discovery with caching, and full A2A client (send, get, cancel, list, subscribe) -- **TaskStore trait** — Pluggable task persistence with built-in `InMemoryTaskStore` (TTL + LRU eviction) -- **Protocol compliant** — Strict alignment with A2A v1.0 RC spec (tag `v1.0.0-rc`, commit `6292104`) -- **Feature-gated** — `server` and `client` features can be enabled independently +| Feature | Default | Purpose | +|---|---|---| +| `server` | Yes | Router, handlers, SSE, and `TaskStore` support | +| `client` | Yes | Discovery, dual transport client, and SSE parsing | + +Types-only usage: + +```toml +[dependencies] +a2a-rust = { version = "0.1", default-features = false } +``` ## Quick Start -Add to your `Cargo.toml`: +Add the crate: ```toml [dependencies] a2a-rust = "0.1" ``` -### Build an A2A Server +### Server -Implement the `A2AHandler` trait and mount the router: +Implement `A2AHandler` and mount the router: ```rust use a2a_rust::server::{A2AHandler, router}; -use a2a_rust::types::*; +use a2a_rust::types::{ + AgentCapabilities, AgentCard, AgentInterface, Message, Part, Role, SendMessageRequest, + SendMessageResponse, +}; +use a2a_rust::A2AError; +#[derive(Clone)] struct EchoAgent; #[async_trait::async_trait] impl A2AHandler for EchoAgent { - async fn get_agent_card(&self) -> Result { - // Return your agent's card - todo!() + async fn get_agent_card(&self) -> Result { + Ok(AgentCard { + name: "Echo Agent".to_owned(), + description: "Replies with the same text".to_owned(), + supported_interfaces: vec![ + AgentInterface { + url: "/rpc".to_owned(), + protocol_binding: "JSONRPC".to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + }, + AgentInterface { + url: "/".to_owned(), + protocol_binding: "HTTP+JSON".to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + }, + ], + provider: None, + version: "0.1.0".to_owned(), + documentation_url: None, + capabilities: AgentCapabilities { + streaming: Some(false), + push_notifications: Some(false), + extensions: Vec::new(), + extended_agent_card: Some(false), + }, + security_schemes: Default::default(), + security_requirements: Vec::new(), + default_input_modes: vec!["text/plain".to_owned()], + default_output_modes: vec!["text/plain".to_owned()], + skills: Vec::new(), + signatures: Vec::new(), + icon_url: None, + }) } - async fn handle_send_message( + async fn send_message( &self, - req: SendMessageRequest, - ) -> Result { - // Process the message and return a Task or Message - todo!() + request: SendMessageRequest, + ) -> Result { + Ok(SendMessageResponse::Message(Message { + message_id: "msg-echo-1".to_owned(), + context_id: request.message.context_id, + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some("pong".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + })) } - - // ... implement other required methods } #[tokio::main] -async fn main() { - let app = router(EchoAgent); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - axum::serve(listener, app).await.unwrap(); +async fn main() -> Result<(), Box> { + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?; + axum::serve(listener, router(EchoAgent)).await?; + Ok(()) } ``` -Your agent is now discoverable at `GET /.well-known/agent-card.json` and accepts requests via both REST and JSON-RPC endpoints. +Runnable example: + +```bash +cargo run --example echo_server --features server +``` + +### Client -### Use the A2A Client +Use discovery and send a message: ```rust use a2a_rust::client::A2AClient; -use a2a_rust::types::*; +use a2a_rust::types::{Message, Part, Role, SendMessageRequest, SendMessageResponse}; #[tokio::main] async fn main() -> Result<(), a2a_rust::A2AError> { - let client = A2AClient::new(); - - // Discover an agent - let card = client.discover("https://agent.example.com").await?; - println!("Agent: {} — {}", card.name, card.description); + let client = A2AClient::new("http://127.0.0.1:3000")?; + let card = client.discover_agent_card().await?; + + let response = client + .send_message(SendMessageRequest { + message: Message { + message_id: "msg-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: None, + role: Role::User, + parts: vec![Part { + text: Some("ping".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }, + configuration: None, + metadata: None, + tenant: None, + }) + .await?; + + println!("agent: {}", card.name); + match response { + SendMessageResponse::Message(message) => { + println!("reply: {:?}", message.parts[0].text); + } + SendMessageResponse::Task(task) => { + println!("task: {}", task.id); + } + } - // Send a message - let response = client.send_message("https://agent.example.com", request).await?; Ok(()) } ``` -## Protocol Bindings +Runnable example: -A2A v1.0 RC defines three protocol bindings. This crate implements JSON-RPC and REST: +```bash +cargo run --example ping_client --features client +``` -| Binding | Endpoint | Status | -|---------|----------|--------| -| **JSON-RPC 2.0** | `POST /jsonrpc` | Implemented | -| **REST (HTTP+JSON)** | Multiple endpoints | Implemented | -| **gRPC** | protobuf service | Not yet (structure reserved) | +## Protocol Surface -### REST Endpoints +### Discovery -| Method | Path | Description | -|--------|------|-------------| -| GET | `/.well-known/agent-card.json` | Agent discovery | -| POST | `/message:send` | Send message | -| POST | `/message:stream` | Send message (SSE streaming) | -| GET | `/tasks/{id}` | Get task | -| GET | `/tasks` | List tasks (cursor pagination) | -| POST | `/tasks/{id}:cancel` | Cancel task | -| GET | `/tasks/{id}:subscribe` | Subscribe to task (SSE) | -| GET | `/extendedAgentCard` | Extended agent card (authenticated) | +- `GET /.well-known/agent-card.json` -### JSON-RPC Methods +### JSON-RPC -| Method | Description | -|--------|-------------| -| `message/send` | Send message, returns Task or Message | -| `message/stream` | Send message with SSE streaming | -| `tasks/get` | Get task by ID | -| `tasks/list` | List tasks with cursor pagination | -| `tasks/cancel` | Cancel a task | -| `tasks/subscribe` | Subscribe to existing task (SSE) | -| `agent/getExtendedCard` | Get extended agent card | +- server default endpoint: `POST /rpc` +- compatibility alias: `POST /jsonrpc` +- method names use PascalCase v1.0 RC bindings such as `SendMessage`, `GetTask`, and `ListTasks` -## Project Structure +### REST -``` -src/ -├── lib.rs # Public API re-exports -├── types/ -│ ├── agent_card.rs # AgentCard, AgentSkill, AgentCapabilities -│ ├── task.rs # Task, TaskState, TaskStatus -│ ├── message.rs # Message, Part (unified), Artifact -│ ├── streaming.rs # StreamResponse, SSE event types -│ ├── security.rs # SecurityScheme (5 variants), SecurityRequirement -│ └── jsonrpc.rs # JSON-RPC 2.0 Request/Response/Error -├── server/ -│ ├── handler.rs # A2AHandler trait (implement this) -│ ├── router.rs # axum Router builder -│ ├── rest.rs # REST endpoint handlers -│ ├── jsonrpc.rs # JSON-RPC 2.0 dispatcher -│ └── streaming.rs # SSE streaming -├── client/ -│ ├── discovery.rs # AgentCard discovery + caching -│ └── client.rs # A2AClient -└── error.rs # A2AError type -``` +Canonical REST endpoints include: -## Feature Flags +- `POST /message:send` +- `POST /message:stream` +- `GET /tasks` +- `GET /tasks/{id}` +- `POST /tasks/{id}:cancel` +- `GET /tasks/{id}:subscribe` +- `POST /tasks/{task_id}/pushNotificationConfigs` +- `GET /tasks/{task_id}/pushNotificationConfigs/{id}` +- `GET /tasks/{task_id}/pushNotificationConfigs` +- `DELETE /tasks/{task_id}/pushNotificationConfigs/{id}` +- `GET /extendedAgentCard` -| Feature | Default | Description | -|---------|---------|-------------| -| `server` | Yes | A2A server framework (axum-based) | -| `client` | Yes | A2A HTTP client (reqwest-based) | +Tenant-prefixed variants are also supported. -To use only the types: +## Client Behavior -```toml -[dependencies] -a2a-rust = { version = "0.1", default-features = false } +- Discovery caches agent cards with a configurable TTL +- Transport selection follows the server-declared `supported_interfaces` order +- Supported transports: `JSONRPC`, `HTTP+JSON` +- Streaming uses SSE and parses both `\n\n` and `\r\n\r\n` frame delimiters +- `A2A-Version: 1.0` is always sent + +## Project Layout + +```text +src/ + lib.rs + error.rs + jsonrpc.rs + store.rs + types/ + server/ + client/ +examples/ + echo_server.rs + ping_client.rs +tests/ + server_integration.rs + client_integration.rs + client_wiremock.rs ``` -## Protocol Version +## Development -This crate strictly targets **A2A Protocol v1.0 RC** (git tag `v1.0.0-rc`, commit `6292104`, 2026-01-29). Key differences from v0.3.0: +Core checks: -- `Part` is a unified flat struct (not tagged enum) -- `AgentCard.url` removed — replaced by `supported_interfaces: Vec` -- Enum values use `SCREAMING_SNAKE_CASE` (e.g., `TASK_STATE_COMPLETED`) -- REST endpoints changed (e.g., `POST /message:send` instead of `POST /tasks/send`) -- JSON-RPC methods renamed (e.g., `message/send` instead of `tasks/send`) -- Well-known path: `agent-card.json` (was `agent.json`) -- TaskState has 9 states including `REJECTED` and `AUTH_REQUIRED` +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo clippy --all-targets --no-default-features -- -D warnings +cargo test --all-features +cargo test --no-default-features +``` -See [What's New in V1](https://a2a-protocol.org/latest/whats-new-v1/) for the full changelog. +See [CONTRIBUTING.md](CONTRIBUTING.md) for contributor workflow details. ## References @@ -180,13 +280,9 @@ See [What's New in V1](https://a2a-protocol.org/latest/whats-new-v1/) for the fu ## License -Licensed under either of +Licensed under either of: -- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) -- MIT License ([LICENSE-MIT](LICENSE-MIT) or ) +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) +- MIT License ([LICENSE-MIT](LICENSE-MIT)) at your option. - -### Contribution - -Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/examples/echo_server.rs b/examples/echo_server.rs new file mode 100644 index 0000000..807357f --- /dev/null +++ b/examples/echo_server.rs @@ -0,0 +1,88 @@ +use a2a_rust::A2AError; +use a2a_rust::server::{A2AHandler, router}; +use a2a_rust::types::{ + AgentCapabilities, AgentCard, AgentInterface, Message, Part, Role, SendMessageRequest, + SendMessageResponse, +}; + +#[derive(Clone)] +struct EchoAgent; + +#[async_trait::async_trait] +impl A2AHandler for EchoAgent { + async fn get_agent_card(&self) -> Result { + Ok(AgentCard { + name: "Echo Agent".to_owned(), + description: "Minimal example A2A agent".to_owned(), + supported_interfaces: vec![ + AgentInterface { + url: "/rpc".to_owned(), + protocol_binding: "JSONRPC".to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + }, + AgentInterface { + url: "/".to_owned(), + protocol_binding: "HTTP+JSON".to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + }, + ], + provider: None, + version: "0.1.0".to_owned(), + documentation_url: None, + capabilities: AgentCapabilities { + streaming: Some(false), + push_notifications: Some(false), + extensions: Vec::new(), + extended_agent_card: Some(false), + }, + security_schemes: Default::default(), + security_requirements: Vec::new(), + default_input_modes: vec!["text/plain".to_owned()], + default_output_modes: vec!["text/plain".to_owned()], + skills: Vec::new(), + signatures: Vec::new(), + icon_url: None, + }) + } + + async fn send_message( + &self, + request: SendMessageRequest, + ) -> Result { + let reply = request + .message + .parts + .iter() + .find_map(|part| part.text.as_deref()) + .unwrap_or("hello"); + + Ok(SendMessageResponse::Message(Message { + message_id: "msg-echo-1".to_owned(), + context_id: request.message.context_id, + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some(format!("echo: {reply}")), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + })) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?; + println!("echo server listening on http://127.0.0.1:3000"); + axum::serve(listener, router(EchoAgent)).await?; + Ok(()) +} diff --git a/examples/ping_client.rs b/examples/ping_client.rs new file mode 100644 index 0000000..4db5a7b --- /dev/null +++ b/examples/ping_client.rs @@ -0,0 +1,51 @@ +use a2a_rust::client::A2AClient; +use a2a_rust::types::{Message, Part, Role, SendMessageRequest, SendMessageResponse}; + +#[tokio::main] +async fn main() -> Result<(), a2a_rust::A2AError> { + let client = A2AClient::new("http://127.0.0.1:3000")?; + let card = client.discover_agent_card().await?; + println!("discovered agent: {}", card.name); + + let response = client + .send_message(SendMessageRequest { + message: Message { + message_id: "msg-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: None, + role: Role::User, + parts: vec![Part { + text: Some("ping".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }, + configuration: None, + metadata: None, + tenant: None, + }) + .await?; + + match response { + SendMessageResponse::Message(message) => { + let reply = message + .parts + .iter() + .find_map(|part| part.text.as_deref()) + .unwrap_or(""); + println!("reply: {reply}"); + } + SendMessageResponse::Task(task) => { + println!("task created: {}", task.id); + } + } + + Ok(()) +} diff --git a/scripts/check.sh b/scripts/check.sh index dfcf7ad..413a412 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -4,8 +4,17 @@ set -euo pipefail echo "[check] cargo fmt --all -- --check" cargo fmt --all -- --check +echo "[check] cargo clippy --all-targets --no-default-features -- -D warnings" +cargo clippy --all-targets --no-default-features -- -D warnings + echo "[check] cargo clippy --all-targets --all-features -- -D warnings" cargo clippy --all-targets --all-features -- -D warnings +echo "[check] cargo test --no-default-features" +cargo test --no-default-features + echo "[check] cargo test --all-features" cargo test --all-features + +echo "[check] cargo check --all-features --examples" +cargo check --all-features --examples diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index f39d7c0..7750850 100755 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -14,6 +14,7 @@ if [[ ! -d "$ROOT/.git" ]]; then fi chmod +x "$ROOT/.githooks/pre-commit" +chmod +x "$ROOT/.githooks/commit-msg" chmod +x "$ROOT/.githooks/pre-push" git -C "$ROOT" config core.hooksPath .githooks diff --git a/src/client/api.rs b/src/client/api.rs new file mode 100644 index 0000000..01f4ae6 --- /dev/null +++ b/src/client/api.rs @@ -0,0 +1,859 @@ +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use futures_core::Stream; +use futures_util::stream; +use reqwest::Url; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::A2AError; +use crate::jsonrpc::{ + CONTENT_TYPE_NOT_SUPPORTED, EXTENDED_AGENT_CARD_NOT_CONFIGURED, EXTENSION_SUPPORT_REQUIRED, + INTERNAL_ERROR, INVALID_AGENT_RESPONSE, INVALID_PARAMS, INVALID_REQUEST, JSONRPC_VERSION, + JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse, METHOD_CANCEL_TASK, + METHOD_CREATE_TASK_PUSH_NOTIFICATION_CONFIG, METHOD_DELETE_TASK_PUSH_NOTIFICATION_CONFIG, + METHOD_GET_EXTENDED_AGENT_CARD, METHOD_GET_TASK, METHOD_GET_TASK_PUSH_NOTIFICATION_CONFIG, + METHOD_LIST_TASK_PUSH_NOTIFICATION_CONFIG, METHOD_LIST_TASKS, METHOD_NOT_FOUND, + METHOD_SEND_MESSAGE, PARSE_ERROR, PROTOCOL_VERSION, PUSH_NOTIFICATION_NOT_SUPPORTED, + TASK_NOT_CANCELABLE, TASK_NOT_FOUND, UNSUPPORTED_OPERATION, VERSION_NOT_SUPPORTED, +}; +use crate::types::{ + AgentCard, AgentInterface, CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetExtendedAgentCardRequest, + GetTaskPushNotificationConfigRequest, GetTaskRequest, ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, ListTasksRequest, ListTasksResponse, + SendMessageRequest, SendMessageResponse, StreamResponse, SubscribeToTaskRequest, Task, + TaskPushNotificationConfig, +}; + +use super::discovery::{ + AgentCardDiscovery, AgentCardDiscoveryConfig, ensure_trailing_slash, normalize_base_url, + resolve_interface_url, +}; + +/// Configuration for [`A2AClient`]. +#[derive(Debug, Clone)] +pub struct A2AClientConfig { + /// Discovery cache time-to-live. + pub discovery_ttl: Duration, + /// Extension URIs sent as `A2A-Extensions: uri1,uri2`. + pub extensions: Vec, +} + +impl Default for A2AClientConfig { + fn default() -> Self { + Self { + discovery_ttl: Duration::from_secs(300), + extensions: Vec::new(), + } + } +} + +#[derive(Debug)] +enum TransportEndpoint { + JsonRpc(Url), + HttpJson(Url), +} + +/// Stream of validated SSE items returned by streaming client operations. +pub type A2AClientStream = + Pin> + Send + 'static>>; + +/// HTTP client for discovery, unary calls, and SSE streams against a remote agent. +#[derive(Debug)] +pub struct A2AClient { + base_url: Url, + client: reqwest::Client, + discovery: AgentCardDiscovery, + config: A2AClientConfig, + request_ids: Arc, +} + +impl A2AClient { + /// Create a client with default configuration and a default `reqwest` client. + pub fn new(base_url: &str) -> Result { + Self::with_config(base_url, A2AClientConfig::default()) + } + + /// Create a client with explicit SDK configuration. + pub fn with_config(base_url: &str, config: A2AClientConfig) -> Result { + Self::with_http_client(base_url, reqwest::Client::new(), config) + } + + /// Create a client with an explicit `reqwest` client and SDK configuration. + pub fn with_http_client( + base_url: &str, + client: reqwest::Client, + config: A2AClientConfig, + ) -> Result { + let base_url = normalize_base_url(base_url)?; + let discovery = AgentCardDiscovery::with_http_client( + client.clone(), + AgentCardDiscoveryConfig { + ttl: config.discovery_ttl, + }, + ); + + Ok(Self { + base_url, + client, + discovery, + config, + request_ids: Arc::new(AtomicU64::new(1)), + }) + } + + /// Discover the remote agent card, using the discovery cache when fresh. + pub async fn discover_agent_card(&self) -> Result { + self.discovery.discover(self.base_url.as_ref()).await + } + + /// Refresh the remote agent card and replace any cached copy. + pub async fn refresh_agent_card(&self) -> Result { + self.discovery.refresh(self.base_url.as_ref()).await + } + + /// Invoke `SendMessage` over the server's preferred unary transport. + pub async fn send_message( + &self, + request: SendMessageRequest, + ) -> Result { + request.validate()?; + + let response: SendMessageResponse = match self.transport().await? { + TransportEndpoint::JsonRpc(url) => { + self.jsonrpc_call(&url, METHOD_SEND_MESSAGE, &request) + .await? + } + TransportEndpoint::HttpJson(base_url) => { + let url = rest_url(&base_url, request.tenant.as_deref(), &["message:send"])?; + self.read_json_response( + self.apply_protocol_headers(self.client.post(url)) + .json(&request) + .send() + .await?, + ) + .await? + } + }; + + response.validate()?; + Ok(response) + } + + /// Invoke `SendStreamingMessage` over HTTP+JSON SSE. + pub async fn send_streaming_message( + &self, + request: SendMessageRequest, + ) -> Result { + request.validate()?; + + let base_url = self.http_json_transport().await?; + let url = rest_url(&base_url, request.tenant.as_deref(), &["message:stream"])?; + let response = self + .apply_protocol_headers( + self.client + .post(url) + .header(reqwest::header::ACCEPT, "text/event-stream"), + ) + .json(&request) + .send() + .await?; + + self.read_sse_response(response).await + } + + /// Fetch a task by identifier. + pub async fn get_task(&self, request: GetTaskRequest) -> Result { + match self.transport().await? { + TransportEndpoint::JsonRpc(url) => { + self.jsonrpc_call(&url, METHOD_GET_TASK, &request).await + } + TransportEndpoint::HttpJson(base_url) => { + let url = rest_url( + &base_url, + request.tenant.as_deref(), + &["tasks", &request.id], + )?; + self.read_json_response( + self.apply_protocol_headers(self.client.get(url)) + .query(&GetTaskQuery { + history_length: request.history_length, + }) + .send() + .await?, + ) + .await + } + } + } + + /// List tasks using the server's preferred unary transport. + pub async fn list_tasks( + &self, + request: ListTasksRequest, + ) -> Result { + request.validate()?; + + match self.transport().await? { + TransportEndpoint::JsonRpc(url) => { + self.jsonrpc_call(&url, METHOD_LIST_TASKS, &request).await + } + TransportEndpoint::HttpJson(base_url) => { + let url = rest_url(&base_url, request.tenant.as_deref(), &["tasks"])?; + self.read_json_response( + self.apply_protocol_headers(self.client.get(url)) + .query(&ListTasksQuery { + context_id: request.context_id, + status: request.status, + page_size: request.page_size, + page_token: request.page_token, + history_length: request.history_length, + status_timestamp_after: request.status_timestamp_after, + include_artifacts: request.include_artifacts, + }) + .send() + .await?, + ) + .await + } + } + } + + /// Request cancellation of a task. + pub async fn cancel_task(&self, request: CancelTaskRequest) -> Result { + match self.transport().await? { + TransportEndpoint::JsonRpc(url) => { + self.jsonrpc_call(&url, METHOD_CANCEL_TASK, &request).await + } + TransportEndpoint::HttpJson(base_url) => { + let cancel_segment = format!("{}:cancel", request.id); + let url = rest_url( + &base_url, + request.tenant.as_deref(), + &["tasks", &cancel_segment], + )?; + self.read_json_response( + self.apply_protocol_headers(self.client.post(url)) + .send() + .await?, + ) + .await + } + } + } + + /// Fetch the extended agent card when the remote agent advertises it. + pub async fn get_extended_agent_card( + &self, + request: GetExtendedAgentCardRequest, + ) -> Result { + match self.transport().await? { + TransportEndpoint::JsonRpc(url) => { + self.jsonrpc_call(&url, METHOD_GET_EXTENDED_AGENT_CARD, &request) + .await + } + TransportEndpoint::HttpJson(base_url) => { + let url = rest_url(&base_url, request.tenant.as_deref(), &["extendedAgentCard"])?; + self.read_json_response( + self.apply_protocol_headers(self.client.get(url)) + .send() + .await?, + ) + .await + } + } + } + + /// Create or replace a push-notification configuration for a task. + pub async fn create_task_push_notification_config( + &self, + request: CreateTaskPushNotificationConfigRequest, + ) -> Result { + match self.transport().await? { + TransportEndpoint::JsonRpc(url) => { + self.jsonrpc_call(&url, METHOD_CREATE_TASK_PUSH_NOTIFICATION_CONFIG, &request) + .await + } + TransportEndpoint::HttpJson(base_url) => { + let url = rest_url( + &base_url, + request.tenant.as_deref(), + &["tasks", &request.task_id, "pushNotificationConfigs"], + )?; + self.read_json_response( + self.apply_protocol_headers(self.client.post(url)) + .query(&CreateTaskPushNotificationConfigQuery { + config_id: request.config_id, + }) + .json(&request.config) + .send() + .await?, + ) + .await + } + } + } + + /// Fetch a single push-notification configuration by identifier. + pub async fn get_task_push_notification_config( + &self, + request: GetTaskPushNotificationConfigRequest, + ) -> Result { + match self.transport().await? { + TransportEndpoint::JsonRpc(url) => { + self.jsonrpc_call(&url, METHOD_GET_TASK_PUSH_NOTIFICATION_CONFIG, &request) + .await + } + TransportEndpoint::HttpJson(base_url) => { + let url = rest_url( + &base_url, + request.tenant.as_deref(), + &[ + "tasks", + &request.task_id, + "pushNotificationConfigs", + &request.id, + ], + )?; + self.read_json_response( + self.apply_protocol_headers(self.client.get(url)) + .send() + .await?, + ) + .await + } + } + } + + /// List push-notification configurations for a task. + pub async fn list_task_push_notification_config( + &self, + request: ListTaskPushNotificationConfigRequest, + ) -> Result { + request.validate()?; + + match self.transport().await? { + TransportEndpoint::JsonRpc(url) => { + self.jsonrpc_call(&url, METHOD_LIST_TASK_PUSH_NOTIFICATION_CONFIG, &request) + .await + } + TransportEndpoint::HttpJson(base_url) => { + let url = rest_url( + &base_url, + request.tenant.as_deref(), + &["tasks", &request.task_id, "pushNotificationConfigs"], + )?; + self.read_json_response( + self.apply_protocol_headers(self.client.get(url)) + .query(&ListTaskPushNotificationConfigQuery { + page_size: request.page_size, + page_token: request.page_token, + }) + .send() + .await?, + ) + .await + } + } + } + + /// Delete a push-notification configuration by identifier. + pub async fn delete_task_push_notification_config( + &self, + request: DeleteTaskPushNotificationConfigRequest, + ) -> Result<(), A2AError> { + match self.transport().await? { + TransportEndpoint::JsonRpc(url) => self + .jsonrpc_call::<_, serde_json::Value>( + &url, + METHOD_DELETE_TASK_PUSH_NOTIFICATION_CONFIG, + &request, + ) + .await + .map(|_| ()), + TransportEndpoint::HttpJson(base_url) => { + let url = rest_url( + &base_url, + request.tenant.as_deref(), + &[ + "tasks", + &request.task_id, + "pushNotificationConfigs", + &request.id, + ], + )?; + self.read_json_response::( + self.apply_protocol_headers(self.client.delete(url)) + .send() + .await?, + ) + .await + .map(|_| ()) + } + } + } + + /// Subscribe to task updates over HTTP+JSON SSE. + pub async fn subscribe_to_task( + &self, + request: SubscribeToTaskRequest, + ) -> Result { + let base_url = self.http_json_transport().await?; + let subscribe_segment = format!("{}:subscribe", request.id); + let url = rest_url( + &base_url, + request.tenant.as_deref(), + &["tasks", &subscribe_segment], + )?; + let response = self + .apply_protocol_headers( + self.client + .get(url) + .header(reqwest::header::ACCEPT, "text/event-stream"), + ) + .send() + .await?; + + self.read_sse_response(response).await + } + + async fn transport(&self) -> Result { + let card = self.discover_agent_card().await?; + select_transport(&self.base_url, &card.supported_interfaces) + } + + async fn http_json_transport(&self) -> Result { + let card = self.discover_agent_card().await?; + select_http_json_transport(&self.base_url, &card.supported_interfaces) + } + + async fn jsonrpc_call(&self, url: &Url, method: &str, params: &P) -> Result + where + P: Serialize, + R: DeserializeOwned, + { + let id = JsonRpcId::String(format!( + "req-{}", + self.request_ids.fetch_add(1, Ordering::Relaxed) + )); + let request = JsonRpcRequest { + jsonrpc: JSONRPC_VERSION.to_owned(), + method: method.to_owned(), + params: Some(serde_json::to_value(params)?), + id: id.clone(), + }; + + let response = self + .apply_protocol_headers(self.client.post(url.clone())) + .json(&request) + .send() + .await?; + let bytes = response.bytes().await?; + let envelope: JsonRpcResponse = serde_json::from_slice(&bytes) + .map_err(|error| A2AError::InvalidAgentResponse(error.to_string()))?; + + if envelope.jsonrpc != JSONRPC_VERSION { + return Err(A2AError::InvalidAgentResponse( + "jsonrpc must be \"2.0\"".to_owned(), + )); + } + + if envelope.id != id { + return Err(A2AError::InvalidAgentResponse( + "response id did not match request id".to_owned(), + )); + } + + match (envelope.result, envelope.error) { + (Some(result), None) => serde_json::from_value(result) + .map_err(|error| A2AError::InvalidAgentResponse(error.to_string())), + (None, Some(error)) => Err(map_jsonrpc_error(error)), + _ => Err(A2AError::InvalidAgentResponse( + "response must contain exactly one of result or error".to_owned(), + )), + } + } + + async fn read_json_response(&self, response: reqwest::Response) -> Result + where + T: DeserializeOwned, + { + let status = response.status(); + let bytes = response.bytes().await?; + + if status.is_success() { + return serde_json::from_slice(&bytes) + .map_err(|error| A2AError::InvalidAgentResponse(error.to_string())); + } + + if let Ok(error) = serde_json::from_slice::(&bytes) { + return Err(map_jsonrpc_error(error.error)); + } + + Err(A2AError::InvalidAgentResponse(format!( + "unexpected HTTP status {}", + status + ))) + } + + async fn read_sse_response( + &self, + response: reqwest::Response, + ) -> Result { + let status = response.status(); + if !status.is_success() { + let bytes = response.bytes().await?; + if let Ok(error) = serde_json::from_slice::(&bytes) { + return Err(map_jsonrpc_error(error.error)); + } + + return Err(A2AError::InvalidAgentResponse(format!( + "unexpected HTTP status {}", + status + ))); + } + + Ok(Box::pin(sse_stream(response))) + } + + fn apply_protocol_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + let mut builder = builder.header("A2A-Version", PROTOCOL_VERSION); + if !self.config.extensions.is_empty() { + builder = builder.header("A2A-Extensions", self.config.extensions.join(",")); + } + + builder + } +} + +fn select_transport( + base_url: &Url, + interfaces: &[AgentInterface], +) -> Result { + for interface in interfaces { + if interface.protocol_binding.eq_ignore_ascii_case("JSONRPC") { + return resolve_interface_url(base_url, &interface.url).map(TransportEndpoint::JsonRpc); + } + + if interface.protocol_binding.eq_ignore_ascii_case("HTTP+JSON") { + return resolve_interface_url(base_url, &interface.url) + .map(ensure_trailing_slash) + .map(TransportEndpoint::HttpJson); + } + } + + Err(A2AError::InvalidAgentResponse( + "agent card does not advertise a supported interface".to_owned(), + )) +} + +fn select_http_json_transport( + base_url: &Url, + interfaces: &[AgentInterface], +) -> Result { + interfaces + .iter() + .find(|interface| interface.protocol_binding.eq_ignore_ascii_case("HTTP+JSON")) + .ok_or_else(|| { + A2AError::InvalidAgentResponse( + "agent card does not advertise an HTTP+JSON interface".to_owned(), + ) + }) + .and_then(|interface| resolve_interface_url(base_url, &interface.url)) + .map(ensure_trailing_slash) +} + +fn rest_url(base_url: &Url, tenant: Option<&str>, segments: &[&str]) -> Result { + let mut url = ensure_trailing_slash(base_url.clone()); + { + let mut path_segments = url + .path_segments_mut() + .map_err(|_| A2AError::InvalidRequest("base URL cannot be a base".to_owned()))?; + path_segments.pop_if_empty(); + if let Some(tenant) = tenant { + path_segments.push(tenant); + } + for segment in segments { + path_segments.push(segment); + } + } + + Ok(url) +} + +fn map_jsonrpc_error(error: JsonRpcError) -> A2AError { + let detail = error + .data + .as_ref() + .and_then(serde_json::Value::as_str) + .unwrap_or(&error.message) + .to_owned(); + + match error.code { + TASK_NOT_FOUND => A2AError::TaskNotFound(detail), + TASK_NOT_CANCELABLE => A2AError::TaskNotCancelable(detail), + PUSH_NOTIFICATION_NOT_SUPPORTED => A2AError::PushNotificationNotSupported(detail), + UNSUPPORTED_OPERATION => A2AError::UnsupportedOperation(detail), + CONTENT_TYPE_NOT_SUPPORTED => A2AError::ContentTypeNotSupported(detail), + INVALID_AGENT_RESPONSE => A2AError::InvalidAgentResponse(detail), + EXTENDED_AGENT_CARD_NOT_CONFIGURED => A2AError::ExtendedAgentCardNotConfigured(detail), + EXTENSION_SUPPORT_REQUIRED => A2AError::ExtensionSupportRequired(detail), + VERSION_NOT_SUPPORTED => A2AError::VersionNotSupported(detail), + PARSE_ERROR => A2AError::ParseError(detail), + INVALID_REQUEST => A2AError::InvalidRequest(detail), + METHOD_NOT_FOUND => A2AError::MethodNotFound(detail), + INVALID_PARAMS => A2AError::InvalidParams(detail), + INTERNAL_ERROR => A2AError::Internal(detail), + code => A2AError::Internal(format!("jsonrpc error {}: {}", code, error.message)), + } +} + +fn sse_stream( + response: reqwest::Response, +) -> impl Stream> + Send { + stream::try_unfold( + SseState { + response, + buffer: Vec::new(), + }, + |mut state| async move { + loop { + if let Some(frame) = take_sse_frame(&mut state.buffer, false)? + && let Some(item) = parse_sse_frame(frame)? + { + item.validate()?; + return Ok(Some((item, state))); + } + + match state.response.chunk().await? { + Some(chunk) => state.buffer.extend_from_slice(&chunk), + None => match take_sse_frame(&mut state.buffer, true)? { + Some(frame) => { + if let Some(item) = parse_sse_frame(frame)? { + item.validate()?; + return Ok(Some((item, state))); + } + } + None => return Ok(None), + }, + } + } + }, + ) +} + +#[derive(Debug)] +struct SseState { + response: reqwest::Response, + buffer: Vec, +} + +fn take_sse_frame(buffer: &mut Vec, eof: bool) -> Result>, A2AError> { + if let Some((index, delimiter_len)) = sse_frame_boundary(buffer) { + let frame = buffer[..index].to_vec(); + buffer.drain(..index + delimiter_len); + return Ok(Some(frame)); + } + + if eof && !buffer.is_empty() { + return Ok(Some(std::mem::take(buffer))); + } + + Ok(None) +} + +fn sse_frame_boundary(buffer: &[u8]) -> Option<(usize, usize)> { + for index in 0..buffer.len().saturating_sub(1) { + if buffer[index] == b'\n' && buffer[index + 1] == b'\n' { + return Some((index, 2)); + } + + if index + 3 < buffer.len() && &buffer[index..index + 4] == b"\r\n\r\n" { + return Some((index, 4)); + } + } + + None +} + +fn parse_sse_frame(frame: Vec) -> Result, A2AError> { + let text = String::from_utf8(frame) + .map_err(|error| A2AError::InvalidAgentResponse(error.to_string()))?; + let mut data_lines = Vec::new(); + + for line in text.lines() { + let line = line.strip_suffix('\r').unwrap_or(line); + if line.is_empty() || line.starts_with(':') { + continue; + } + + if let Some(data) = line.strip_prefix("data:") { + let data = data.strip_prefix(' ').unwrap_or(data); + data_lines.push(data.to_owned()); + } + } + + if data_lines.is_empty() { + return Ok(None); + } + + serde_json::from_str::(&data_lines.join("\n")) + .map(Some) + .map_err(|error| A2AError::InvalidAgentResponse(error.to_string())) +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct GetTaskQuery { + #[serde(skip_serializing_if = "Option::is_none")] + history_length: Option, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ListTasksQuery { + #[serde(skip_serializing_if = "Option::is_none")] + context_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + page_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + history_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + status_timestamp_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + include_artifacts: Option, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateTaskPushNotificationConfigQuery { + config_id: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ListTaskPushNotificationConfigQuery { + #[serde(skip_serializing_if = "Option::is_none")] + page_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + page_token: Option, +} + +#[derive(serde::Deserialize)] +struct RestErrorEnvelope { + error: JsonRpcError, +} + +#[cfg(test)] +mod tests { + use super::map_jsonrpc_error; + use crate::A2AError; + use crate::jsonrpc::JsonRpcError; + use crate::jsonrpc::{ + CONTENT_TYPE_NOT_SUPPORTED, EXTENDED_AGENT_CARD_NOT_CONFIGURED, EXTENSION_SUPPORT_REQUIRED, + INTERNAL_ERROR, INVALID_AGENT_RESPONSE, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, + PARSE_ERROR, PUSH_NOTIFICATION_NOT_SUPPORTED, TASK_NOT_CANCELABLE, TASK_NOT_FOUND, + UNSUPPORTED_OPERATION, VERSION_NOT_SUPPORTED, + }; + + #[test] + fn map_jsonrpc_error_covers_all_protocol_codes() { + let cases = [ + (TASK_NOT_FOUND, "task missing"), + (TASK_NOT_CANCELABLE, "task busy"), + (PUSH_NOTIFICATION_NOT_SUPPORTED, "push unsupported"), + (UNSUPPORTED_OPERATION, "operation unsupported"), + (CONTENT_TYPE_NOT_SUPPORTED, "content type unsupported"), + (INVALID_AGENT_RESPONSE, "invalid agent response"), + ( + EXTENDED_AGENT_CARD_NOT_CONFIGURED, + "extended agent card missing", + ), + (EXTENSION_SUPPORT_REQUIRED, "extension required"), + (VERSION_NOT_SUPPORTED, "version unsupported"), + (PARSE_ERROR, "parse error"), + (INVALID_REQUEST, "invalid request"), + (METHOD_NOT_FOUND, "missing method"), + (INVALID_PARAMS, "invalid params"), + (INTERNAL_ERROR, "internal error"), + ]; + + for (code, detail) in cases { + let mapped = map_jsonrpc_error(JsonRpcError { + code, + message: format!("message for {code}"), + data: Some(serde_json::Value::String(detail.to_owned())), + }); + + match code { + TASK_NOT_FOUND => { + assert!(matches!(mapped, A2AError::TaskNotFound(value) if value == detail)); + } + TASK_NOT_CANCELABLE => { + assert!( + matches!(mapped, A2AError::TaskNotCancelable(value) if value == detail) + ); + } + PUSH_NOTIFICATION_NOT_SUPPORTED => { + assert!( + matches!(mapped, A2AError::PushNotificationNotSupported(value) if value == detail) + ); + } + UNSUPPORTED_OPERATION => { + assert!( + matches!(mapped, A2AError::UnsupportedOperation(value) if value == detail) + ); + } + CONTENT_TYPE_NOT_SUPPORTED => { + assert!( + matches!(mapped, A2AError::ContentTypeNotSupported(value) if value == detail) + ); + } + INVALID_AGENT_RESPONSE => { + assert!( + matches!(mapped, A2AError::InvalidAgentResponse(value) if value == detail) + ); + } + EXTENDED_AGENT_CARD_NOT_CONFIGURED => { + assert!( + matches!(mapped, A2AError::ExtendedAgentCardNotConfigured(value) if value == detail) + ); + } + EXTENSION_SUPPORT_REQUIRED => { + assert!( + matches!(mapped, A2AError::ExtensionSupportRequired(value) if value == detail) + ); + } + VERSION_NOT_SUPPORTED => { + assert!( + matches!(mapped, A2AError::VersionNotSupported(value) if value == detail) + ); + } + PARSE_ERROR => { + assert!(matches!(mapped, A2AError::ParseError(value) if value == detail)); + } + INVALID_REQUEST => { + assert!(matches!(mapped, A2AError::InvalidRequest(value) if value == detail)); + } + METHOD_NOT_FOUND => { + assert!(matches!(mapped, A2AError::MethodNotFound(value) if value == detail)); + } + INVALID_PARAMS => { + assert!(matches!(mapped, A2AError::InvalidParams(value) if value == detail)); + } + INTERNAL_ERROR => { + assert!(matches!(mapped, A2AError::Internal(value) if value == detail)); + } + _ => unreachable!("all cases should be covered"), + } + } + } +} diff --git a/src/client/discovery.rs b/src/client/discovery.rs new file mode 100644 index 0000000..7562eda --- /dev/null +++ b/src/client/discovery.rs @@ -0,0 +1,178 @@ +use std::collections::BTreeMap; +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +use reqwest::Url; + +use crate::A2AError; +use crate::jsonrpc::PROTOCOL_VERSION; +use crate::types::AgentCard; + +/// Discovery cache configuration for remote agent cards. +#[derive(Debug, Clone, Copy)] +pub struct AgentCardDiscoveryConfig { + /// Maximum time to reuse a cached discovery response. + pub ttl: Duration, +} + +impl Default for AgentCardDiscoveryConfig { + fn default() -> Self { + Self { + ttl: Duration::from_secs(300), + } + } +} + +#[derive(Debug, Clone)] +struct CachedAgentCard { + card: AgentCard, + fetched_at: Instant, +} + +/// Discovers and caches remote A2A agent cards. +#[derive(Debug)] +pub struct AgentCardDiscovery { + client: reqwest::Client, + config: AgentCardDiscoveryConfig, + cache: RwLock>, +} + +impl Default for AgentCardDiscovery { + fn default() -> Self { + Self::new() + } +} + +impl AgentCardDiscovery { + /// Create a discovery client with default caching behavior. + pub fn new() -> Self { + Self::with_config(AgentCardDiscoveryConfig::default()) + } + + /// Create a discovery client with explicit cache settings. + pub fn with_config(config: AgentCardDiscoveryConfig) -> Self { + Self::with_http_client(reqwest::Client::new(), config) + } + + /// Create a discovery client with a caller-provided HTTP client. + pub fn with_http_client(client: reqwest::Client, config: AgentCardDiscoveryConfig) -> Self { + Self { + client, + config, + cache: RwLock::new(BTreeMap::new()), + } + } + + /// Discover an agent card, using the cache when still fresh. + pub async fn discover(&self, base_url: &str) -> Result { + let base_url = normalize_base_url(base_url)?; + let cache_key = cache_key(&base_url); + + if let Some(card) = self.cached_card(&cache_key)? { + return Ok(card); + } + + self.fetch_and_store(cache_key, base_url).await + } + + /// Force a fresh agent-card fetch and replace any cached entry. + pub async fn refresh(&self, base_url: &str) -> Result { + let base_url = normalize_base_url(base_url)?; + self.fetch_and_store(cache_key(&base_url), base_url).await + } + + fn cached_card(&self, cache_key: &str) -> Result, A2AError> { + let cache = self + .cache + .read() + .map_err(|_| A2AError::Internal("discovery cache lock poisoned".to_owned()))?; + + let Some(cached) = cache.get(cache_key) else { + return Ok(None); + }; + + if cached.fetched_at.elapsed() >= self.config.ttl { + return Ok(None); + } + + Ok(Some(cached.card.clone())) + } + + async fn fetch_and_store( + &self, + cache_key: String, + base_url: Url, + ) -> Result { + let discovery_url = well_known_agent_card_url(&base_url)?; + let response = self + .client + .get(discovery_url) + .header("A2A-Version", PROTOCOL_VERSION) + .send() + .await?; + + let status = response.status(); + let bytes = response.bytes().await?; + if !status.is_success() { + return Err(A2AError::InvalidAgentResponse(format!( + "agent discovery returned HTTP {}", + status + ))); + } + + let card: AgentCard = serde_json::from_slice(&bytes) + .map_err(|error| A2AError::InvalidAgentResponse(error.to_string()))?; + let mut cache = self + .cache + .write() + .map_err(|_| A2AError::Internal("discovery cache lock poisoned".to_owned()))?; + cache.insert( + cache_key, + CachedAgentCard { + card: card.clone(), + fetched_at: Instant::now(), + }, + ); + + Ok(card) + } +} + +pub(crate) fn normalize_base_url(base_url: &str) -> Result { + let mut url = + Url::parse(base_url).map_err(|error| A2AError::InvalidRequest(error.to_string()))?; + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +pub(crate) fn resolve_interface_url(base_url: &Url, interface_url: &str) -> Result { + Url::parse(interface_url) + .or_else(|_| base_url.join(interface_url)) + .map_err(|error| A2AError::InvalidAgentResponse(error.to_string())) +} + +pub(crate) fn ensure_trailing_slash(mut url: Url) -> Url { + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + + url +} + +fn cache_key(base_url: &Url) -> String { + let mut normalized = base_url.clone(); + if normalized.path() != "/" { + let trimmed = normalized.path().trim_end_matches('/').to_owned(); + normalized.set_path(&trimmed); + } + + normalized.to_string() +} + +fn well_known_agent_card_url(base_url: &Url) -> Result { + ensure_trailing_slash(base_url.clone()) + .join(".well-known/agent-card.json") + .map_err(|error| A2AError::InvalidRequest(error.to_string())) +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..e7b3f6d --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,5 @@ +mod api; +mod discovery; + +pub use self::api::{A2AClient, A2AClientConfig, A2AClientStream}; +pub use self::discovery::{AgentCardDiscovery, AgentCardDiscoveryConfig}; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6d05cd0 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,139 @@ +use http::StatusCode; +use serde_json::Value; +use thiserror::Error; + +use crate::jsonrpc; +use crate::jsonrpc::JsonRpcError; + +/// Unified error type for A2A protocol, HTTP, and serialization failures. +#[derive(Debug, Error)] +pub enum A2AError { + /// The requested task identifier does not exist. + #[error("task not found: {0}")] + TaskNotFound(String), + /// The requested task cannot transition to canceled. + #[error("task not cancelable: {0}")] + TaskNotCancelable(String), + /// Push notifications are disabled or unsupported for this agent. + #[error("push notification not supported: {0}")] + PushNotificationNotSupported(String), + /// The requested operation is not implemented. + #[error("unsupported operation: {0}")] + UnsupportedOperation(String), + /// The request content type is not supported by the peer. + #[error("content type not supported: {0}")] + ContentTypeNotSupported(String), + /// The remote agent returned an invalid response payload. + #[error("invalid agent response: {0}")] + InvalidAgentResponse(String), + /// Extended agent card retrieval is not configured for the agent. + #[error("extended agent card not configured: {0}")] + ExtendedAgentCardNotConfigured(String), + /// A required extension is not supported by the peer. + #[error("extension support required: {0}")] + ExtensionSupportRequired(String), + /// The peer rejected the requested A2A protocol version. + #[error("version not supported: {0}")] + VersionNotSupported(String), + /// The request body could not be parsed as valid JSON-RPC or JSON. + #[error("parse error: {0}")] + ParseError(String), + /// The request shape is structurally invalid. + #[error("invalid request: {0}")] + InvalidRequest(String), + /// The requested method or route does not exist. + #[error("method not found: {0}")] + MethodNotFound(String), + /// The supplied parameters could not be deserialized or validated. + #[error("invalid params: {0}")] + InvalidParams(String), + /// An internal SDK or server error occurred. + #[error("internal error: {0}")] + Internal(String), + /// JSON serialization or deserialization failed locally. + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + #[cfg(feature = "client")] + /// The underlying HTTP client returned an error. + #[error("http error: {0}")] + Http(#[from] reqwest::Error), +} + +impl A2AError { + /// Return the JSON-RPC error code associated with this error. + pub fn code(&self) -> i32 { + match self { + Self::TaskNotFound(_) => jsonrpc::TASK_NOT_FOUND, + Self::TaskNotCancelable(_) => jsonrpc::TASK_NOT_CANCELABLE, + Self::PushNotificationNotSupported(_) => jsonrpc::PUSH_NOTIFICATION_NOT_SUPPORTED, + Self::UnsupportedOperation(_) => jsonrpc::UNSUPPORTED_OPERATION, + Self::ContentTypeNotSupported(_) => jsonrpc::CONTENT_TYPE_NOT_SUPPORTED, + Self::InvalidAgentResponse(_) => jsonrpc::INVALID_AGENT_RESPONSE, + Self::ExtendedAgentCardNotConfigured(_) => jsonrpc::EXTENDED_AGENT_CARD_NOT_CONFIGURED, + Self::ExtensionSupportRequired(_) => jsonrpc::EXTENSION_SUPPORT_REQUIRED, + Self::VersionNotSupported(_) => jsonrpc::VERSION_NOT_SUPPORTED, + Self::ParseError(_) => jsonrpc::PARSE_ERROR, + Self::InvalidRequest(_) => jsonrpc::INVALID_REQUEST, + Self::MethodNotFound(_) => jsonrpc::METHOD_NOT_FOUND, + Self::InvalidParams(_) => jsonrpc::INVALID_PARAMS, + Self::Internal(_) => jsonrpc::INTERNAL_ERROR, + Self::Serialization(_) => jsonrpc::INTERNAL_ERROR, + #[cfg(feature = "client")] + Self::Http(_) => jsonrpc::INTERNAL_ERROR, + } + } + + /// Convert this error into a JSON-RPC error object. + pub fn to_jsonrpc_error(&self) -> JsonRpcError { + JsonRpcError { + code: self.code(), + message: self.to_string(), + data: self.data(), + } + } + + /// Return the HTTP status code associated with this error. + pub fn status_code(&self) -> StatusCode { + match self { + Self::TaskNotFound(_) => StatusCode::NOT_FOUND, + Self::TaskNotCancelable(_) => StatusCode::CONFLICT, + Self::PushNotificationNotSupported(_) => StatusCode::BAD_REQUEST, + Self::UnsupportedOperation(_) => StatusCode::BAD_REQUEST, + Self::ContentTypeNotSupported(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::InvalidAgentResponse(_) => StatusCode::BAD_GATEWAY, + Self::ExtendedAgentCardNotConfigured(_) => StatusCode::BAD_REQUEST, + Self::ExtensionSupportRequired(_) => StatusCode::BAD_REQUEST, + Self::VersionNotSupported(_) => StatusCode::BAD_REQUEST, + Self::ParseError(_) => StatusCode::BAD_REQUEST, + Self::InvalidRequest(_) => StatusCode::BAD_REQUEST, + Self::MethodNotFound(_) => StatusCode::NOT_FOUND, + Self::InvalidParams(_) => StatusCode::BAD_REQUEST, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR, + #[cfg(feature = "client")] + Self::Http(_) => StatusCode::BAD_GATEWAY, + } + } + + fn data(&self) -> Option { + match self { + Self::TaskNotFound(task_id) => Some(Value::String(task_id.clone())), + Self::TaskNotCancelable(task_id) => Some(Value::String(task_id.clone())), + Self::PushNotificationNotSupported(detail) + | Self::UnsupportedOperation(detail) + | Self::ContentTypeNotSupported(detail) + | Self::InvalidAgentResponse(detail) + | Self::ExtendedAgentCardNotConfigured(detail) + | Self::ExtensionSupportRequired(detail) + | Self::VersionNotSupported(detail) + | Self::ParseError(detail) + | Self::InvalidRequest(detail) + | Self::MethodNotFound(detail) + | Self::InvalidParams(detail) + | Self::Internal(detail) => Some(Value::String(detail.clone())), + Self::Serialization(_) => None, + #[cfg(feature = "client")] + Self::Http(_) => None, + } + } +} diff --git a/src/jsonrpc.rs b/src/jsonrpc.rs new file mode 100644 index 0000000..b968770 --- /dev/null +++ b/src/jsonrpc.rs @@ -0,0 +1,133 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Required JSON-RPC version marker. +pub const JSONRPC_VERSION: &str = "2.0"; +/// Required A2A protocol version header value. +pub const PROTOCOL_VERSION: &str = "1.0"; + +/// JSON-RPC parse error code. +pub const PARSE_ERROR: i32 = -32700; +/// JSON-RPC invalid request error code. +pub const INVALID_REQUEST: i32 = -32600; +/// JSON-RPC method not found error code. +pub const METHOD_NOT_FOUND: i32 = -32601; +/// JSON-RPC invalid params error code. +pub const INVALID_PARAMS: i32 = -32602; +/// JSON-RPC internal error code. +pub const INTERNAL_ERROR: i32 = -32603; + +/// A2A task-not-found error code. +pub const TASK_NOT_FOUND: i32 = -32001; +/// A2A task-not-cancelable error code. +pub const TASK_NOT_CANCELABLE: i32 = -32002; +/// A2A push-notifications-not-supported error code. +pub const PUSH_NOTIFICATION_NOT_SUPPORTED: i32 = -32003; +/// A2A unsupported-operation error code. +pub const UNSUPPORTED_OPERATION: i32 = -32004; +/// A2A unsupported-content-type error code. +pub const CONTENT_TYPE_NOT_SUPPORTED: i32 = -32005; +/// A2A invalid-agent-response error code. +pub const INVALID_AGENT_RESPONSE: i32 = -32006; +/// A2A extended-agent-card-not-configured error code. +pub const EXTENDED_AGENT_CARD_NOT_CONFIGURED: i32 = -32007; +/// A2A extension-support-required error code. +pub const EXTENSION_SUPPORT_REQUIRED: i32 = -32008; +/// A2A version-not-supported error code. +pub const VERSION_NOT_SUPPORTED: i32 = -32009; + +/// JSON-RPC method name for `SendMessage`. +pub const METHOD_SEND_MESSAGE: &str = "SendMessage"; +/// JSON-RPC method name for `SendStreamingMessage`. +pub const METHOD_SEND_STREAMING_MESSAGE: &str = "SendStreamingMessage"; +/// JSON-RPC method name for `GetTask`. +pub const METHOD_GET_TASK: &str = "GetTask"; +/// JSON-RPC method name for `ListTasks`. +pub const METHOD_LIST_TASKS: &str = "ListTasks"; +/// JSON-RPC method name for `CancelTask`. +pub const METHOD_CANCEL_TASK: &str = "CancelTask"; +/// JSON-RPC method name for `SubscribeToTask`. +pub const METHOD_SUBSCRIBE_TO_TASK: &str = "SubscribeToTask"; +/// JSON-RPC method name for `CreateTaskPushNotificationConfig`. +pub const METHOD_CREATE_TASK_PUSH_NOTIFICATION_CONFIG: &str = "CreateTaskPushNotificationConfig"; +/// JSON-RPC method name for `GetTaskPushNotificationConfig`. +pub const METHOD_GET_TASK_PUSH_NOTIFICATION_CONFIG: &str = "GetTaskPushNotificationConfig"; +/// JSON-RPC method name for `ListTaskPushNotificationConfig`. +pub const METHOD_LIST_TASK_PUSH_NOTIFICATION_CONFIG: &str = "ListTaskPushNotificationConfig"; +/// JSON-RPC method name for `DeleteTaskPushNotificationConfig`. +pub const METHOD_DELETE_TASK_PUSH_NOTIFICATION_CONFIG: &str = "DeleteTaskPushNotificationConfig"; +/// JSON-RPC method name for `GetExtendedAgentCard`. +pub const METHOD_GET_EXTENDED_AGENT_CARD: &str = "GetExtendedAgentCard"; + +/// JSON-RPC 2.0 request envelope. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + #[serde(default = "jsonrpc_version")] + /// JSON-RPC protocol version, always `"2.0"`. + pub jsonrpc: String, + /// Method name to invoke on the remote peer. + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional method parameters encoded as a JSON object. + pub params: Option, + /// Request identifier echoed by the peer in the response. + pub id: JsonRpcId, +} + +/// JSON-RPC 2.0 response envelope. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResponse { + #[serde(default = "jsonrpc_version")] + /// JSON-RPC protocol version, always `"2.0"`. + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Successful result payload. + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Error payload returned when the call fails. + pub error: Option, + /// Response identifier copied from the request. + pub id: JsonRpcId, +} + +/// JSON-RPC 2.0 error object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + /// Numeric error code. + pub code: i32, + /// Human-readable error message. + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional protocol-specific error data. + pub data: Option, +} + +/// Allowed JSON-RPC request/response identifier forms. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcId { + /// String request identifier. + String(String), + /// Numeric request identifier. + Number(i64), + /// Explicit null identifier. + Null, +} + +fn jsonrpc_version() -> String { + JSONRPC_VERSION.to_owned() +} + +#[cfg(test)] +mod tests { + use super::JsonRpcId; + + #[test] + fn jsonrpc_id_null_serializes_as_null() { + let json = serde_json::to_string(&JsonRpcId::Null).expect("id should serialize"); + assert_eq!(json, "null"); + + let round_trip: JsonRpcId = serde_json::from_str("null").expect("id should deserialize"); + assert!(matches!(round_trip, JsonRpcId::Null)); + } +} diff --git a/src/lib.rs b/src/lib.rs index b93cf3f..d9b396d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,29 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +//! Rust SDK for the A2A Protocol v1.0 RC. +//! +//! This crate provides a protocol-accurate type layer plus optional server and +//! client implementations behind feature flags. -#[cfg(test)] -mod tests { - use super::*; +/// HTTP client for discovering and calling remote A2A agents. +#[cfg(feature = "client")] +pub mod client; +/// Transport-neutral error type shared across the crate. +pub mod error; +/// JSON-RPC 2.0 envelope types and A2A method/code constants. +pub mod jsonrpc; +/// Axum-based server framework for exposing an A2A agent. +#[cfg(feature = "server")] +pub mod server; +/// Task persistence traits and the in-memory store implementation. +#[cfg(feature = "server")] +pub mod store; +/// Protocol request, response, and resource types. +pub mod types; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +#[cfg(feature = "client")] +pub use crate::client::{ + A2AClient, A2AClientConfig, A2AClientStream, AgentCardDiscovery, AgentCardDiscoveryConfig, +}; +pub use crate::error::A2AError; +#[cfg(feature = "server")] +pub use crate::store::{InMemoryTaskStore, TaskStore}; +pub use crate::types::*; diff --git a/src/server/handler.rs b/src/server/handler.rs new file mode 100644 index 0000000..b8037da --- /dev/null +++ b/src/server/handler.rs @@ -0,0 +1,174 @@ +use std::pin::Pin; + +use async_trait::async_trait; +use futures_core::Stream; + +use crate::A2AError; +use crate::types::{ + AgentCard, CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetExtendedAgentCardRequest, + GetTaskPushNotificationConfigRequest, GetTaskRequest, ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, ListTasksRequest, ListTasksResponse, + SendMessageRequest, SendMessageResponse, StreamResponse, SubscribeToTaskRequest, Task, + TaskPushNotificationConfig, +}; + +/// Server-side stream of A2A `StreamResponse` values. +pub type A2AStream = Pin + Send + 'static>>; + +/// Core server trait for implementing an A2A agent. +/// +/// The default capability helpers call `get_agent_card()` on each gated request. +/// Implementations that fetch the card from storage should cache it or override +/// the relevant operation methods. +#[async_trait] +pub trait A2AHandler: Send + Sync + 'static { + /// Return the agent card served from discovery and capability endpoints. + async fn get_agent_card(&self) -> Result; + + /// Process a unary `SendMessage` request. + async fn send_message( + &self, + request: SendMessageRequest, + ) -> Result; + + /// Stream responses for a submitted message. + /// + /// Message-only flows should emit exactly one `StreamResponse::Message`. + /// Task-based flows should emit the initial task first, followed by status + /// and artifact updates until the stream closes. + async fn send_streaming_message( + &self, + _request: SendMessageRequest, + ) -> Result { + self.require_streaming_capability("SendStreamingMessage") + .await?; + Err(A2AError::UnsupportedOperation( + "SendStreamingMessage".to_owned(), + )) + } + + /// Fetch a task by identifier. + async fn get_task(&self, _request: GetTaskRequest) -> Result { + Err(A2AError::UnsupportedOperation("GetTask".to_owned())) + } + + /// List tasks visible to the caller. + async fn list_tasks(&self, _request: ListTasksRequest) -> Result { + Err(A2AError::UnsupportedOperation("ListTasks".to_owned())) + } + + /// Attempt to cancel a task. + async fn cancel_task(&self, _request: CancelTaskRequest) -> Result { + Err(A2AError::UnsupportedOperation("CancelTask".to_owned())) + } + + /// Subscribe to updates for an existing task. + /// + /// Implementations must emit the current `StreamResponse::Task` first before + /// any subsequent status or artifact updates. + async fn subscribe_to_task( + &self, + _request: SubscribeToTaskRequest, + ) -> Result { + self.require_streaming_capability("SubscribeToTask").await?; + Err(A2AError::UnsupportedOperation("SubscribeToTask".to_owned())) + } + + /// Create or replace a push-notification configuration. + async fn create_task_push_notification_config( + &self, + _request: CreateTaskPushNotificationConfigRequest, + ) -> Result { + self.require_push_notifications_capability("CreateTaskPushNotificationConfig") + .await?; + Err(A2AError::UnsupportedOperation( + "CreateTaskPushNotificationConfig".to_owned(), + )) + } + + /// Fetch a stored push-notification configuration. + async fn get_task_push_notification_config( + &self, + _request: GetTaskPushNotificationConfigRequest, + ) -> Result { + self.require_push_notifications_capability("GetTaskPushNotificationConfig") + .await?; + Err(A2AError::UnsupportedOperation( + "GetTaskPushNotificationConfig".to_owned(), + )) + } + + /// List stored push-notification configurations. + async fn list_task_push_notification_config( + &self, + _request: ListTaskPushNotificationConfigRequest, + ) -> Result { + self.require_push_notifications_capability("ListTaskPushNotificationConfig") + .await?; + Err(A2AError::UnsupportedOperation( + "ListTaskPushNotificationConfig".to_owned(), + )) + } + + /// Delete a stored push-notification configuration. + async fn delete_task_push_notification_config( + &self, + _request: DeleteTaskPushNotificationConfigRequest, + ) -> Result<(), A2AError> { + self.require_push_notifications_capability("DeleteTaskPushNotificationConfig") + .await?; + Err(A2AError::UnsupportedOperation( + "DeleteTaskPushNotificationConfig".to_owned(), + )) + } + + /// Fetch the extended agent card. + async fn get_extended_agent_card( + &self, + _request: GetExtendedAgentCardRequest, + ) -> Result { + self.require_extended_agent_card_capability().await?; + Err(A2AError::ExtendedAgentCardNotConfigured( + "GetExtendedAgentCard".to_owned(), + )) + } + + /// Enforce the A2A streaming capability gate. + /// + /// Do not override unless you preserve the same protocol behavior. + async fn require_streaming_capability(&self, operation: &str) -> Result<(), A2AError> { + let card = self.get_agent_card().await?; + if card.capabilities.streaming == Some(true) { + return Ok(()); + } + + Err(A2AError::UnsupportedOperation(operation.to_owned())) + } + + /// Enforce the A2A push-notifications capability gate. + /// + /// Do not override unless you preserve the same protocol behavior. + async fn require_push_notifications_capability(&self, operation: &str) -> Result<(), A2AError> { + let card = self.get_agent_card().await?; + if card.capabilities.push_notifications == Some(true) { + return Ok(()); + } + + Err(A2AError::PushNotificationNotSupported(operation.to_owned())) + } + + /// Enforce the A2A extended-agent-card capability gate. + /// + /// Do not override unless you preserve the same protocol behavior. + async fn require_extended_agent_card_capability(&self) -> Result<(), A2AError> { + let card = self.get_agent_card().await?; + if card.capabilities.extended_agent_card == Some(true) { + return Ok(()); + } + + Err(A2AError::ExtendedAgentCardNotConfigured( + "GetExtendedAgentCard".to_owned(), + )) + } +} diff --git a/src/server/jsonrpc.rs b/src/server/jsonrpc.rs new file mode 100644 index 0000000..4c4c292 --- /dev/null +++ b/src/server/jsonrpc.rs @@ -0,0 +1,189 @@ +use std::sync::Arc; + +use axum::Json; +use axum::body::Bytes; +use axum::extract::State; +use axum::http::StatusCode; + +use crate::A2AError; +use crate::jsonrpc::{ + JSONRPC_VERSION, JsonRpcId, JsonRpcRequest, JsonRpcResponse, METHOD_CANCEL_TASK, + METHOD_CREATE_TASK_PUSH_NOTIFICATION_CONFIG, METHOD_DELETE_TASK_PUSH_NOTIFICATION_CONFIG, + METHOD_GET_EXTENDED_AGENT_CARD, METHOD_GET_TASK, METHOD_GET_TASK_PUSH_NOTIFICATION_CONFIG, + METHOD_LIST_TASK_PUSH_NOTIFICATION_CONFIG, METHOD_LIST_TASKS, METHOD_SEND_MESSAGE, + METHOD_SEND_STREAMING_MESSAGE, METHOD_SUBSCRIBE_TO_TASK, +}; +use crate::types::{ + CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetExtendedAgentCardRequest, + GetTaskPushNotificationConfigRequest, GetTaskRequest, ListTaskPushNotificationConfigRequest, + ListTasksRequest, SendMessageRequest, SubscribeToTaskRequest, +}; + +use super::handler::A2AHandler; + +pub(super) async fn handle( + State(handler): State>, + body: Bytes, +) -> (StatusCode, Json) +where + H: A2AHandler, +{ + let request = match serde_json::from_slice::(&body) { + Ok(request) => request, + Err(error) => { + return ( + StatusCode::OK, + Json(error_response( + JsonRpcId::Null, + A2AError::ParseError(error.to_string()), + )), + ); + } + }; + + if request.jsonrpc != JSONRPC_VERSION { + // JSON-RPC envelope errors still return HTTP 200 with the protocol error + // encoded in the body. + return ( + StatusCode::OK, + Json(error_response( + request.id, + A2AError::InvalidRequest("jsonrpc must be \"2.0\"".to_owned()), + )), + ); + } + + let id = request.id.clone(); + let result = match request.method.as_str() { + METHOD_SEND_MESSAGE => parse_params::(request.params) + .and_then(|params| params.validate().map(|_| params)) + .and_then_async(|params| handler.send_message(params)) + .await + .and_then(|response| response.validate().map(|_| response)) + .map(serde_json::to_value) + .and_then(map_serialization_error), + METHOD_SEND_STREAMING_MESSAGE => { + parse_params::(request.params) + .and_then(|params| params.validate().map(|_| params)) + .and_then_async(|_params| async { + Err(A2AError::UnsupportedOperation( + "SendStreamingMessage".to_owned(), + )) + }) + .await + } + METHOD_GET_TASK => parse_params::(request.params) + .and_then_async(|params| handler.get_task(params)) + .await + .map(serde_json::to_value) + .and_then(map_serialization_error), + METHOD_LIST_TASKS => parse_params::(request.params) + .and_then(|params| params.validate().map(|_| params)) + .and_then_async(|params| handler.list_tasks(params)) + .await + .map(serde_json::to_value) + .and_then(map_serialization_error), + METHOD_CANCEL_TASK => parse_params::(request.params) + .and_then_async(|params| handler.cancel_task(params)) + .await + .map(serde_json::to_value) + .and_then(map_serialization_error), + METHOD_SUBSCRIBE_TO_TASK => { + parse_params::(request.params) + .and_then_async(|_params| async { + Err(A2AError::UnsupportedOperation("SubscribeToTask".to_owned())) + }) + .await + } + METHOD_CREATE_TASK_PUSH_NOTIFICATION_CONFIG => { + parse_params::(request.params) + .and_then_async(|params| handler.create_task_push_notification_config(params)) + .await + .map(serde_json::to_value) + .and_then(map_serialization_error) + } + METHOD_GET_TASK_PUSH_NOTIFICATION_CONFIG => { + parse_params::(request.params) + .and_then_async(|params| handler.get_task_push_notification_config(params)) + .await + .map(serde_json::to_value) + .and_then(map_serialization_error) + } + METHOD_LIST_TASK_PUSH_NOTIFICATION_CONFIG => { + parse_params::(request.params) + .and_then(|params| params.validate().map(|_| params)) + .and_then_async(|params| handler.list_task_push_notification_config(params)) + .await + .map(serde_json::to_value) + .and_then(map_serialization_error) + } + METHOD_DELETE_TASK_PUSH_NOTIFICATION_CONFIG => { + parse_params::(request.params) + .and_then_async(|params| handler.delete_task_push_notification_config(params)) + .await + .map(|()| serde_json::json!({})) + } + METHOD_GET_EXTENDED_AGENT_CARD => { + parse_params::(request.params) + .and_then_async(|params| handler.get_extended_agent_card(params)) + .await + .map(serde_json::to_value) + .and_then(map_serialization_error) + } + method => Err(A2AError::MethodNotFound(method.to_owned())), + }; + + let response = match result { + Ok(result) => JsonRpcResponse { + jsonrpc: JSONRPC_VERSION.to_owned(), + result: Some(result), + error: None, + id, + }, + Err(error) => error_response(id, error), + }; + + (StatusCode::OK, Json(response)) +} + +fn parse_params(params: Option) -> Result +where + T: serde::de::DeserializeOwned, +{ + let params = params.unwrap_or_else(|| serde_json::Value::Object(Default::default())); + serde_json::from_value(params).map_err(|error| A2AError::InvalidParams(error.to_string())) +} + +fn map_serialization_error( + value: Result, +) -> Result { + value.map_err(A2AError::from) +} + +fn error_response(id: JsonRpcId, error: A2AError) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: JSONRPC_VERSION.to_owned(), + result: None, + error: Some(error.to_jsonrpc_error()), + id, + } +} + +trait AsyncResultExt { + async fn and_then_async(self, func: impl FnOnce(T) -> Fut) -> Result + where + Fut: std::future::Future>; +} + +impl AsyncResultExt for Result { + async fn and_then_async(self, func: impl FnOnce(T) -> Fut) -> Result + where + Fut: std::future::Future>, + { + match self { + Ok(value) => func(value).await, + Err(error) => Err(error), + } + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..05c3145 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,8 @@ +mod handler; +mod jsonrpc; +mod rest; +mod router; +mod streaming; + +pub use self::handler::{A2AHandler, A2AStream}; +pub use self::router::router; diff --git a/src/server/rest.rs b/src/server/rest.rs new file mode 100644 index 0000000..4bd98d3 --- /dev/null +++ b/src/server/rest.rs @@ -0,0 +1,523 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; + +use crate::A2AError; +use crate::types::{ + AgentCard, CancelTaskRequest, CreateTaskPushNotificationConfigRequest, + DeleteTaskPushNotificationConfigRequest, GetExtendedAgentCardRequest, + GetTaskPushNotificationConfigRequest, GetTaskRequest, ListTaskPushNotificationConfigRequest, + ListTaskPushNotificationConfigResponse, ListTasksRequest, ListTasksResponse, + PushNotificationConfig, SendMessageRequest, SendMessageResponse, SubscribeToTaskRequest, Task, + TaskPushNotificationConfig, +}; + +use super::handler::A2AHandler; +use super::streaming; + +pub(super) async fn get_agent_card( + State(handler): State>, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + handler.get_agent_card().await.map(Json).map_err(rest_error) +} + +pub(super) async fn send_message( + State(handler): State>, + Json(request): Json, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + request.validate()?; + + handler + .send_message(request) + .await + .and_then(|response| { + response.validate()?; + Ok(response) + }) + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn tenant_send_message( + State(handler): State>, + Path(tenant): Path, + Json(mut request): Json, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + request.tenant = Some(tenant); + send_message(State(handler), Json(request)).await +} + +pub(super) async fn get_task_or_subscribe( + State(handler): State>, + Path(id): Path, + Query(query): Query, +) -> Response +where + H: A2AHandler, +{ + if let Err(error) = reject_query_tenant(&query.tenant) { + return error.into_response(); + } + + if let Some(id) = id.strip_suffix(":subscribe") { + return match streaming::subscribe_to_task_response( + handler, + SubscribeToTaskRequest { + id: id.to_owned(), + tenant: query.tenant, + }, + ) + .await + { + Ok(response) => response.into_response(), + Err(error) => error.into_response(), + }; + } + + get_task(State(handler), Path(id), Query(query)) + .await + .into_response() +} + +pub(super) async fn tenant_get_task_or_subscribe( + State(handler): State>, + Path((tenant, id)): Path<(String, String)>, + Query(mut query): Query, +) -> Response +where + H: A2AHandler, +{ + query.tenant = Some(tenant); + + if let Some(id) = id.strip_suffix(":subscribe") { + return match streaming::subscribe_to_task_response( + handler, + SubscribeToTaskRequest { + id: id.to_owned(), + tenant: query.tenant, + }, + ) + .await + { + Ok(response) => response.into_response(), + Err(error) => error.into_response(), + }; + } + + match handler + .get_task(GetTaskRequest { + id, + history_length: query.history_length, + tenant: query.tenant, + }) + .await + { + Ok(task) => Json(task).into_response(), + Err(error) => rest_error(error).into_response(), + } +} + +pub(super) async fn get_task( + State(handler): State>, + Path(id): Path, + Query(query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + reject_query_tenant(&query.tenant)?; + + if id.ends_with(":cancel") || id.ends_with(":subscribe") { + return Err(rest_error(A2AError::MethodNotFound("not found".to_owned()))); + } + + handler + .get_task(GetTaskRequest { + id, + history_length: query.history_length, + tenant: query.tenant, + }) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn list_tasks( + State(handler): State>, + Query(request): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + reject_query_tenant(&request.tenant)?; + request.validate()?; + + handler + .list_tasks(request) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn tenant_list_tasks( + State(handler): State>, + Path(tenant): Path, + Query(mut request): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + request.tenant = Some(tenant); + + request.validate()?; + + handler + .list_tasks(request) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn cancel_task( + State(handler): State>, + Path(id): Path, + Query(query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + reject_query_tenant(&query.tenant)?; + + let Some(id) = id.strip_suffix(":cancel") else { + return Err(rest_error(A2AError::MethodNotFound("not found".to_owned()))); + }; + + handler + .cancel_task(CancelTaskRequest { + id: id.to_owned(), + tenant: query.tenant, + }) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn tenant_cancel_task( + State(handler): State>, + Path((tenant, id)): Path<(String, String)>, + Query(mut query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + query.tenant = Some(tenant); + + let Some(id) = id.strip_suffix(":cancel") else { + return Err(rest_error(A2AError::MethodNotFound("not found".to_owned()))); + }; + + handler + .cancel_task(CancelTaskRequest { + id: id.to_owned(), + tenant: query.tenant, + }) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn get_extended_agent_card( + State(handler): State>, + Query(query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + reject_query_tenant(&query.tenant)?; + + handler + .get_extended_agent_card(GetExtendedAgentCardRequest { + tenant: query.tenant, + }) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn tenant_get_extended_agent_card( + State(handler): State>, + Path(tenant): Path, + Query(mut query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + query.tenant = Some(tenant); + + handler + .get_extended_agent_card(GetExtendedAgentCardRequest { + tenant: query.tenant, + }) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn create_task_push_notification_config( + State(handler): State>, + Path(task_id): Path, + Query(query): Query, + Json(config): Json, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + reject_query_tenant(&query.tenant)?; + + handler + .create_task_push_notification_config(CreateTaskPushNotificationConfigRequest { + task_id, + config_id: query.config_id, + config, + tenant: query.tenant, + }) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn tenant_create_task_push_notification_config( + State(handler): State>, + Path((tenant, task_id)): Path<(String, String)>, + Query(mut query): Query, + Json(config): Json, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + query.tenant = Some(tenant); + + handler + .create_task_push_notification_config(CreateTaskPushNotificationConfigRequest { + task_id, + config_id: query.config_id, + config, + tenant: query.tenant, + }) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn get_task_push_notification_config( + State(handler): State>, + Path((task_id, id)): Path<(String, String)>, + Query(query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + reject_query_tenant(&query.tenant)?; + + handler + .get_task_push_notification_config(GetTaskPushNotificationConfigRequest { + id, + task_id, + tenant: query.tenant, + }) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn tenant_get_task_push_notification_config( + State(handler): State>, + Path((tenant, task_id, id)): Path<(String, String, String)>, + Query(mut query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + query.tenant = Some(tenant); + + handler + .get_task_push_notification_config(GetTaskPushNotificationConfigRequest { + id, + task_id, + tenant: query.tenant, + }) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn list_task_push_notification_config( + State(handler): State>, + Path(task_id): Path, + Query(query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + reject_query_tenant(&query.tenant)?; + + let request = ListTaskPushNotificationConfigRequest { + task_id, + page_size: query.page_size, + page_token: query.page_token, + tenant: query.tenant, + }; + request.validate()?; + + handler + .list_task_push_notification_config(request) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn tenant_list_task_push_notification_config( + State(handler): State>, + Path((tenant, task_id)): Path<(String, String)>, + Query(mut query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + query.tenant = Some(tenant); + + let request = ListTaskPushNotificationConfigRequest { + task_id, + page_size: query.page_size, + page_token: query.page_token, + tenant: query.tenant, + }; + request.validate()?; + + handler + .list_task_push_notification_config(request) + .await + .map(Json) + .map_err(rest_error) +} + +pub(super) async fn delete_task_push_notification_config( + State(handler): State>, + Path((task_id, id)): Path<(String, String)>, + Query(query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + reject_query_tenant(&query.tenant)?; + + handler + .delete_task_push_notification_config(DeleteTaskPushNotificationConfigRequest { + id, + task_id, + tenant: query.tenant, + }) + .await + .map(|()| Json(serde_json::json!({}))) + .map_err(rest_error) +} + +pub(super) async fn tenant_delete_task_push_notification_config( + State(handler): State>, + Path((tenant, task_id, id)): Path<(String, String, String)>, + Query(mut query): Query, +) -> Result, (StatusCode, Json)> +where + H: A2AHandler, +{ + query.tenant = Some(tenant); + + handler + .delete_task_push_notification_config(DeleteTaskPushNotificationConfigRequest { + id, + task_id, + tenant: query.tenant, + }) + .await + .map(|()| Json(serde_json::json!({}))) + .map_err(rest_error) +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct GetTaskQuery { + #[serde(default)] + pub tenant: Option, + #[serde(default)] + pub history_length: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CreateTaskPushNotificationConfigQuery { + pub config_id: String, + #[serde(default)] + pub tenant: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ListTaskPushNotificationConfigQuery { + #[serde(default)] + pub tenant: Option, + #[serde(default)] + pub page_size: Option, + #[serde(default)] + pub page_token: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct TenantQuery { + #[serde(default)] + pub tenant: Option, +} + +pub(super) fn rest_error(error: A2AError) -> (StatusCode, Json) { + let status = error.status_code(); + let body = serde_json::json!({ + "error": { + "code": error.code(), + "message": error.to_string(), + "data": error.to_jsonrpc_error().data, + } + }); + + (status, Json(body)) +} + +impl From for (StatusCode, Json) { + fn from(value: A2AError) -> Self { + rest_error(value) + } +} + +fn reject_query_tenant( + tenant: &Option, +) -> Result<(), (StatusCode, Json)> { + if tenant.is_some() { + return Err(rest_error(A2AError::InvalidRequest( + "tenant must be supplied via tenant-prefixed routes".to_owned(), + ))); + } + + Ok(()) +} diff --git a/src/server/router.rs b/src/server/router.rs new file mode 100644 index 0000000..298f555 --- /dev/null +++ b/src/server/router.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use axum::Router; +use axum::routing::{get, post}; + +use super::handler::A2AHandler; +use super::{jsonrpc, rest, streaming}; + +/// Build an axum router exposing the A2A REST, JSON-RPC, and discovery routes. +pub fn router(handler: H) -> Router +where + H: A2AHandler, +{ + let handler = Arc::new(handler); + + Router::new() + .route( + "/.well-known/agent-card.json", + get(rest::get_agent_card::), + ) + .route("/message:send", post(rest::send_message::)) + .route( + "/{tenant}/message:send", + post(rest::tenant_send_message::), + ) + .route("/message:stream", post(streaming::send_message::)) + .route( + "/{tenant}/message:stream", + post(streaming::tenant_send_message::), + ) + .route("/tasks", get(rest::list_tasks::)) + .route("/{tenant}/tasks", get(rest::tenant_list_tasks::)) + // axum/matchit does not support literal suffixes like `:cancel` or `:subscribe` + // on the same segment as a capture, so those canonical A2A paths are dispatched + // inside the task handlers after extracting the full `{id}` segment. + .route( + "/tasks/{id}", + get(rest::get_task_or_subscribe::).post(rest::cancel_task::), + ) + .route( + "/{tenant}/tasks/{id}", + get(rest::tenant_get_task_or_subscribe::).post(rest::tenant_cancel_task::), + ) + .route( + "/tasks/{task_id}/pushNotificationConfigs", + post(rest::create_task_push_notification_config::) + .get(rest::list_task_push_notification_config::), + ) + .route( + "/{tenant}/tasks/{task_id}/pushNotificationConfigs", + post(rest::tenant_create_task_push_notification_config::) + .get(rest::tenant_list_task_push_notification_config::), + ) + .route( + "/tasks/{task_id}/pushNotificationConfigs/{id}", + get(rest::get_task_push_notification_config::) + .delete(rest::delete_task_push_notification_config::), + ) + .route( + "/{tenant}/tasks/{task_id}/pushNotificationConfigs/{id}", + get(rest::tenant_get_task_push_notification_config::) + .delete(rest::tenant_delete_task_push_notification_config::), + ) + .route( + "/extendedAgentCard", + get(rest::get_extended_agent_card::), + ) + .route( + "/{tenant}/extendedAgentCard", + get(rest::tenant_get_extended_agent_card::), + ) + .route("/rpc", post(jsonrpc::handle::)) + .route("/jsonrpc", post(jsonrpc::handle::)) + .with_state(handler) +} diff --git a/src/server/streaming.rs b/src/server/streaming.rs new file mode 100644 index 0000000..9e856e1 --- /dev/null +++ b/src/server/streaming.rs @@ -0,0 +1,91 @@ +use std::convert::Infallible; +use std::sync::Arc; +use std::time::Duration; + +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::sse::{Event, KeepAlive, Sse}; +use futures_util::stream::StreamExt; + +use crate::types::{SendMessageRequest, StreamResponse, SubscribeToTaskRequest}; + +use super::handler::A2AHandler; + +const SSE_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15); + +pub(super) async fn send_message( + State(handler): State>, + Json(request): Json, +) -> Result< + Sse>>, + (StatusCode, Json), +> +where + H: A2AHandler, +{ + request.validate()?; + let stream = handler.send_streaming_message(request).await?; + Ok(sse_response(stream)) +} + +pub(super) async fn tenant_send_message( + State(handler): State>, + Path(tenant): Path, + Json(mut request): Json, +) -> Result< + Sse>>, + (StatusCode, Json), +> +where + H: A2AHandler, +{ + request.tenant = Some(tenant); + send_message(State(handler), Json(request)).await +} + +pub(super) async fn subscribe_to_task_response( + handler: Arc, + request: SubscribeToTaskRequest, +) -> Result< + Sse>>, + (StatusCode, Json), +> +where + H: A2AHandler, +{ + let stream = handler.subscribe_to_task(request).await?; + Ok(sse_response(stream)) +} + +fn sse_response( + stream: super::A2AStream, +) -> Sse>> { + Sse::new(stream_to_sse(stream)).keep_alive( + KeepAlive::new() + .interval(SSE_KEEP_ALIVE_INTERVAL) + .text("keep-alive"), + ) +} + +fn stream_to_sse( + stream: super::A2AStream, +) -> impl futures_core::Stream> { + stream.map(|item| Ok(Event::default().data(serialize_stream_response(&item)))) +} + +fn serialize_stream_response(item: &StreamResponse) -> String { + match serde_json::to_string(item) { + Ok(json) => json, + // These protocol types should serialize deterministically. If a future + // change violates that assumption, emit a diagnostic payload rather than + // panic inside the response stream. + Err(error) => serde_json::json!({ + "error": { + "code": crate::jsonrpc::INTERNAL_ERROR, + "message": error.to_string(), + } + }) + .to_string(), + } +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..cd1efb7 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,538 @@ +use std::cmp::Reverse; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use tokio::sync::RwLock; + +use crate::A2AError; +use crate::types::{ListTasksRequest, ListTasksResponse, Task}; + +/// Persistence abstraction for task state exposed by the A2A server. +#[async_trait] +pub trait TaskStore: Send + Sync + 'static { + /// Fetch a task by its identifier. + async fn get(&self, task_id: &str) -> Result, A2AError>; + + /// Insert or replace a task snapshot. + async fn put(&self, task: &Task) -> Result<(), A2AError>; + + /// Implementations should reject invalid pagination inputs or delegate to + /// `ListTasksRequest::validate()` before applying query semantics. + async fn list(&self, req: &ListTasksRequest) -> Result; + + /// Delete a task by identifier. + async fn delete(&self, task_id: &str) -> Result; +} + +/// Configuration for the in-memory task store. +#[derive(Debug, Clone, Copy, Default)] +pub struct InMemoryTaskStoreConfig { + /// Maximum age for stored entries before they are purged on access. + pub entry_ttl: Option, + /// Maximum number of tasks retained before least-recently-used eviction. + pub max_entries: Option, +} + +#[derive(Debug, Clone)] +struct StoredTask { + task: Task, + updated_at: Instant, + last_accessed_at: Instant, +} + +/// In-process task store with TTL expiry and LRU capacity eviction. +#[derive(Debug)] +pub struct InMemoryTaskStore { + config: InMemoryTaskStoreConfig, + tasks: RwLock>, +} + +impl Default for InMemoryTaskStore { + fn default() -> Self { + Self::with_config(InMemoryTaskStoreConfig::default()) + } +} + +impl InMemoryTaskStore { + /// Create a store with default configuration. + pub fn new() -> Self { + Self::default() + } + + /// Create a store with explicit TTL and capacity settings. + pub fn with_config(config: InMemoryTaskStoreConfig) -> Self { + Self { + config, + tasks: RwLock::new(BTreeMap::new()), + } + } +} + +#[async_trait] +impl TaskStore for InMemoryTaskStore { + async fn get(&self, task_id: &str) -> Result, A2AError> { + let mut tasks = self.tasks.write().await; + purge_expired(&mut tasks, self.config); + + Ok(tasks.get_mut(task_id).map(|stored| { + stored.last_accessed_at = Instant::now(); + stored.task.clone() + })) + } + + async fn put(&self, task: &Task) -> Result<(), A2AError> { + let mut tasks = self.tasks.write().await; + purge_expired(&mut tasks, self.config); + + let now = Instant::now(); + tasks.insert( + task.id.clone(), + StoredTask { + task: task.clone(), + updated_at: now, + last_accessed_at: now, + }, + ); + enforce_capacity(&mut tasks, self.config.max_entries); + Ok(()) + } + + async fn list(&self, req: &ListTasksRequest) -> Result { + req.validate()?; + + let mut tasks = self.tasks.write().await; + purge_expired(&mut tasks, self.config); + + let mut matching_tasks: Vec = + tasks.values().map(|stored| stored.task.clone()).collect(); + matching_tasks.retain(|task| task_matches(task, req)); + matching_tasks.sort_by_key(|task| Reverse(task_sort_key(task))); + + // The in-memory store currently uses offset-style tokens for simplicity. + // Downstream stores should prefer stable cursors that do not shift under writes. + let start = req + .page_token + .as_deref() + .unwrap_or("0") + .parse::() + .map_err(|_| A2AError::InvalidRequest("invalid pageToken".to_owned()))?; + let requested_page_size = req.page_size.unwrap_or(50); + let page_size = requested_page_size.clamp(1, 100) as usize; + let total_size = matching_tasks.len() as i32; + let page = matching_tasks + .into_iter() + .skip(start) + .take(page_size) + .map(|mut task| { + apply_history_length(&mut task, req.history_length); + if req.include_artifacts != Some(true) { + task.artifacts.clear(); + } + task + }) + .collect::>(); + let accessed_at = Instant::now(); + for task in &page { + if let Some(stored) = tasks.get_mut(&task.id) { + stored.last_accessed_at = accessed_at; + } + } + + let next_start = start + page.len(); + let next_page_token = if next_start >= total_size as usize { + String::new() + } else { + next_start.to_string() + }; + + Ok(ListTasksResponse { + tasks: page, + next_page_token, + page_size: requested_page_size, + total_size, + }) + } + + async fn delete(&self, task_id: &str) -> Result { + let mut tasks = self.tasks.write().await; + purge_expired(&mut tasks, self.config); + + Ok(tasks.remove(task_id).is_some()) + } +} + +fn purge_expired(tasks: &mut BTreeMap, config: InMemoryTaskStoreConfig) { + let Some(entry_ttl) = config.entry_ttl else { + return; + }; + + let now = Instant::now(); + tasks.retain(|_, stored| now.duration_since(stored.updated_at) < entry_ttl); +} + +fn enforce_capacity(tasks: &mut BTreeMap, max_entries: Option) { + let Some(max_entries) = max_entries else { + return; + }; + + while tasks.len() > max_entries { + let Some(oldest_key) = tasks + .iter() + .min_by(|(left_id, left), (right_id, right)| { + left.last_accessed_at + .cmp(&right.last_accessed_at) + .then_with(|| left_id.cmp(right_id)) + }) + .map(|(task_id, _)| task_id.clone()) + else { + break; + }; + + tasks.remove(&oldest_key); + } +} + +fn task_matches(task: &Task, req: &ListTasksRequest) -> bool { + if let Some(context_id) = &req.context_id + && &task.context_id != context_id + { + return false; + } + + if let Some(status) = req.status + && task.status.state != status + { + return false; + } + + if let Some(after) = &req.status_timestamp_after { + let Some(timestamp) = task.status.timestamp.as_ref() else { + return false; + }; + + if timestamp < after { + return false; + } + } + + true +} + +fn task_sort_key(task: &Task) -> (String, String) { + ( + task.status.timestamp.clone().unwrap_or_default(), + task.id.clone(), + ) +} + +fn apply_history_length(task: &mut Task, history_length: Option) { + let Some(history_length) = history_length else { + return; + }; + + if history_length <= 0 { + task.history.clear(); + return; + } + + let keep = history_length as usize; + if task.history.len() > keep { + let start = task.history.len() - keep; + task.history = task.history.split_off(start); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use tokio::time::sleep; + + use super::{InMemoryTaskStore, InMemoryTaskStoreConfig, TaskStore}; + use crate::types::{ListTasksRequest, Task, TaskState, TaskStatus}; + + #[tokio::test] + async fn in_memory_task_store_lists_tasks_in_timestamp_order() { + let store = InMemoryTaskStore::new(); + + store + .put(&Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Submitted, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }) + .await + .expect("task should store"); + + store + .put(&Task { + id: "task-2".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some("2026-03-12T13:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }) + .await + .expect("task should store"); + + let response = store + .list(&ListTasksRequest { + tenant: None, + context_id: Some("ctx-1".to_owned()), + status: None, + page_size: Some(10), + page_token: None, + history_length: None, + status_timestamp_after: None, + include_artifacts: None, + }) + .await + .expect("tasks should list"); + + assert_eq!(response.tasks.len(), 2); + assert_eq!(response.tasks[0].id, "task-2"); + assert_eq!(response.tasks[1].id, "task-1"); + assert_eq!(response.next_page_token, ""); + } + + #[tokio::test] + async fn in_memory_task_store_excludes_artifacts_by_default() { + let store = InMemoryTaskStore::new(); + + store + .put(&Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Completed, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: vec![crate::types::Artifact { + artifact_id: "artifact-1".to_owned(), + name: None, + description: None, + parts: vec![crate::types::Part { + text: Some("done".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + }], + history: Vec::new(), + metadata: None, + }) + .await + .expect("task should store"); + + let response = store + .list(&ListTasksRequest { + tenant: None, + context_id: None, + status: None, + page_size: None, + page_token: None, + history_length: None, + status_timestamp_after: None, + include_artifacts: None, + }) + .await + .expect("tasks should list"); + + assert_eq!(response.tasks.len(), 1); + assert!(response.tasks[0].artifacts.is_empty()); + assert_eq!(response.page_size, 50); + } + + #[tokio::test] + async fn in_memory_task_store_expires_entries_by_ttl() { + let store = InMemoryTaskStore::with_config(InMemoryTaskStoreConfig { + entry_ttl: Some(Duration::from_millis(5)), + max_entries: None, + }); + + store + .put(&Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Submitted, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }) + .await + .expect("task should store"); + + sleep(Duration::from_millis(10)).await; + + let task = store.get("task-1").await.expect("lookup should succeed"); + assert!(task.is_none()); + } + + #[tokio::test] + async fn in_memory_task_store_evicts_least_recently_used_when_capacity_is_exceeded() { + let store = InMemoryTaskStore::with_config(InMemoryTaskStoreConfig { + entry_ttl: None, + max_entries: Some(2), + }); + + store + .put(&Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Submitted, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }) + .await + .expect("task should store"); + sleep(Duration::from_millis(2)).await; + + store + .put(&Task { + id: "task-2".to_owned(), + context_id: "ctx-2".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some("2026-03-12T12:01:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }) + .await + .expect("task should store"); + sleep(Duration::from_millis(2)).await; + + assert!( + store + .get("task-1") + .await + .expect("lookup should succeed") + .is_some() + ); + sleep(Duration::from_millis(2)).await; + + store + .put(&Task { + id: "task-3".to_owned(), + context_id: "ctx-3".to_owned(), + status: TaskStatus { + state: TaskState::Completed, + message: None, + timestamp: Some("2026-03-12T12:02:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }) + .await + .expect("task should store"); + + assert!( + store + .get("task-1") + .await + .expect("lookup should succeed") + .is_some() + ); + assert!( + store + .get("task-2") + .await + .expect("lookup should succeed") + .is_none() + ); + assert!( + store + .get("task-3") + .await + .expect("lookup should succeed") + .is_some() + ); + } + + #[tokio::test] + async fn in_memory_task_store_supports_concurrent_reads_and_writes() { + let store = InMemoryTaskStore::with_config(InMemoryTaskStoreConfig { + entry_ttl: None, + max_entries: None, + }); + let store = Arc::new(store); + let mut tasks = Vec::new(); + + for index in 0..16 { + let store = Arc::clone(&store); + tasks.push(tokio::spawn(async move { + let task_id = format!("task-{index}"); + store + .put(&Task { + id: task_id.clone(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some(format!("2026-03-12T12:{index:02}:00Z")), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }) + .await + .expect("task should store"); + + let fetched = store.get(&task_id).await.expect("lookup should succeed"); + assert!(fetched.is_some()); + })); + } + + for task in tasks { + task.await.expect("task should join"); + } + + let response = store + .list(&ListTasksRequest { + tenant: None, + context_id: Some("ctx-1".to_owned()), + status: None, + page_size: Some(100), + page_token: None, + history_length: None, + status_timestamp_after: None, + include_artifacts: Some(true), + }) + .await + .expect("tasks should list"); + + assert_eq!(response.tasks.len(), 16); + } +} diff --git a/src/types/agent_card.rs b/src/types/agent_card.rs new file mode 100644 index 0000000..2db3848 --- /dev/null +++ b/src/types/agent_card.rs @@ -0,0 +1,216 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::types::JsonObject; + +use super::security::{SecurityRequirement, SecurityScheme}; + +/// Agent discovery document served from `/.well-known/agent-card.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCard { + /// Human-readable agent name. + pub name: String, + /// Human-readable agent description. + pub description: String, + /// Ordered list of supported transport bindings. + pub supported_interfaces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional agent provider metadata. + pub provider: Option, + /// Agent implementation version string. + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional human-readable documentation URL. + pub documentation_url: Option, + /// Capability flags and advertised extensions. + pub capabilities: AgentCapabilities, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + /// Named security schemes referenced by requirements. + pub security_schemes: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Security requirements that apply by default. + pub security_requirements: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Default accepted input modes. + pub default_input_modes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Default produced output modes. + pub default_output_modes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Skills exposed by the agent. + pub skills: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Optional signatures over the agent card. + pub signatures: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional icon URL for UI presentation. + pub icon_url: Option, +} + +/// Transport binding advertised by an agent card. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentInterface { + /// Absolute or relative interface URL. + pub url: String, + /// Binding name such as `JSONRPC` or `HTTP+JSON`. + pub protocol_binding: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant associated with the interface. + pub tenant: Option, + /// Protocol version served from the interface. + pub protocol_version: String, +} + +/// Organization metadata for the agent provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentProvider { + /// Provider homepage URL. + pub url: String, + /// Provider or organization name. + pub organization: String, +} + +/// Capability flags and extension declarations for an agent. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCapabilities { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Whether streaming operations are supported. + pub streaming: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Whether push-notification configuration APIs are supported. + pub push_notifications: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Protocol extensions advertised by the agent. + pub extensions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Whether `GetExtendedAgentCard` is supported. + pub extended_agent_card: Option, +} + +/// Extension declaration inside `AgentCapabilities`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentExtension { + /// Stable extension URI. + pub uri: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + /// Human-readable extension description. + pub description: String, + #[serde(default, skip_serializing_if = "crate::types::is_false")] + /// Whether the extension is required to interoperate. + pub required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional extension-specific parameters. + pub params: Option, +} + +/// Skill advertised by an agent card. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSkill { + /// Stable skill identifier. + pub id: String, + /// Human-readable skill name. + pub name: String, + /// Human-readable skill description. + pub description: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Searchable skill tags. + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Example prompts or invocations for the skill. + pub examples: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Input modes supported by the skill. + pub input_modes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Output modes produced by the skill. + pub output_modes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Security requirements specific to the skill. + pub security_requirements: Vec, +} + +/// Signature over an agent card payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCardSignature { + /// Protected JOSE header segment. + pub protected: String, + /// Signature bytes encoded as a string. + pub signature: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional unprotected JOSE header values. + pub header: Option, +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{AgentCapabilities, AgentCard, AgentExtension, AgentInterface, AgentSkill}; + + #[test] + fn agent_card_round_trip_serialization() { + let card = AgentCard { + name: "Echo Agent".to_owned(), + description: "Replies with the same text".to_owned(), + supported_interfaces: vec![AgentInterface { + url: "https://example.com/rpc".to_owned(), + protocol_binding: "JSONRPC".to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + }], + provider: None, + version: "0.1.0".to_owned(), + documentation_url: None, + capabilities: AgentCapabilities { + streaming: Some(true), + push_notifications: Some(false), + extensions: vec![AgentExtension { + uri: "https://example.com/ext/streaming".to_owned(), + description: "Streaming support".to_owned(), + required: false, + params: None, + }], + extended_agent_card: Some(false), + }, + security_schemes: BTreeMap::new(), + security_requirements: Vec::new(), + default_input_modes: vec!["text/plain".to_owned()], + default_output_modes: vec!["text/plain".to_owned()], + skills: vec![AgentSkill { + id: "echo".to_owned(), + name: "Echo".to_owned(), + description: "Echo back user input".to_owned(), + tags: vec!["utility".to_owned()], + examples: vec!["echo hello".to_owned()], + input_modes: vec!["text/plain".to_owned()], + output_modes: vec!["text/plain".to_owned()], + security_requirements: Vec::new(), + }], + signatures: Vec::new(), + icon_url: None, + }; + + let json = serde_json::to_string(&card).expect("card should serialize"); + let round_trip: AgentCard = serde_json::from_str(&json).expect("card should deserialize"); + + assert_eq!(round_trip.name, "Echo Agent"); + assert_eq!( + round_trip.supported_interfaces[0].protocol_binding, + "JSONRPC" + ); + assert_eq!( + round_trip.capabilities.extensions[0].description, + "Streaming support" + ); + assert!(!round_trip.capabilities.extensions[0].required); + assert_eq!(round_trip.skills[0].id, "echo"); + } +} diff --git a/src/types/agent_id.rs b/src/types/agent_id.rs new file mode 100644 index 0000000..827098d --- /dev/null +++ b/src/types/agent_id.rs @@ -0,0 +1,129 @@ +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use crate::A2AError; + +/// Validated helper type for agent identifiers used in application-level naming. +/// +/// This is not a tagged proto field. It exists to codify the repository's +/// naming convention for agent IDs: +/// +/// - only lowercase ASCII letters, digits, and `-` +/// - length between 3 and 64 characters +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct AgentId(String); + +impl AgentId { + /// Validate and construct an `AgentId`. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_agent_id(&value)?; + Ok(Self(value)) + } + + /// Return the validated identifier as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for AgentId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for AgentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl From for String { + fn from(value: AgentId) -> Self { + value.0 + } +} + +impl TryFrom for AgentId { + type Error = A2AError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl TryFrom<&str> for AgentId { + type Error = A2AError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl FromStr for AgentId { + type Err = A2AError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +impl<'de> Deserialize<'de> for AgentId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +fn validate_agent_id(value: &str) -> Result<(), A2AError> { + if !(3..=64).contains(&value.len()) { + return Err(A2AError::InvalidRequest( + "agent_id must be between 3 and 64 characters".to_owned(), + )); + } + + if !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(A2AError::InvalidRequest( + "agent_id may only contain lowercase ASCII letters, digits, and '-'".to_owned(), + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::AgentId; + + #[test] + fn agent_id_accepts_valid_values() { + let agent_id = AgentId::new("echo-agent-01").expect("agent id should validate"); + assert_eq!(agent_id.as_str(), "echo-agent-01"); + } + + #[test] + fn agent_id_rejects_invalid_characters() { + let error = AgentId::new("Echo_Agent").expect_err("agent id should be invalid"); + assert!( + error + .to_string() + .contains("lowercase ASCII letters, digits, and '-'") + ); + } + + #[test] + fn agent_id_rejects_invalid_length() { + let error = AgentId::new("ab").expect_err("agent id should be invalid"); + assert!(error.to_string().contains("between 3 and 64 characters")); + } +} diff --git a/src/types/auth.rs b/src/types/auth.rs new file mode 100644 index 0000000..30b8c66 --- /dev/null +++ b/src/types/auth.rs @@ -0,0 +1,267 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::A2AError; + +use super::{JsonObject, Message, Task, TaskState, TaskStatus}; + +/// Conventional metadata payload used when a task enters `TASK_STATE_AUTH_REQUIRED`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthRequiredMetadata { + /// Authorization URL the user should visit. + pub auth_url: String, + /// Authentication scheme, such as `oauth2` or `apiKey`. + pub auth_scheme: String, + /// Scopes requested by the agent. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scopes: Vec, + /// Human-readable explanation for the authorization request. + pub description: String, +} + +impl AuthRequiredMetadata { + /// Parse the convention from a metadata object. + pub fn from_metadata(metadata: &JsonObject) -> Result { + serde_json::from_value(Value::Object(metadata.clone())).map_err(A2AError::from) + } + + /// Convert the convention into a message metadata object. + pub fn into_metadata(self) -> Result { + match serde_json::to_value(self)? { + Value::Object(object) => Ok(object), + _ => Err(A2AError::Internal( + "auth-required metadata did not serialize to an object".to_owned(), + )), + } + } +} + +impl Message { + /// Parse `TASK_STATE_AUTH_REQUIRED` metadata from this message, if present. + /// + /// This helper is intended for messages already known to participate in the + /// auth-required flow. If `metadata` exists but does not match the + /// `AuthRequiredMetadata` schema, this returns `Err` rather than `Ok(None)`. + pub fn auth_required_metadata(&self) -> Result, A2AError> { + self.metadata + .as_ref() + .map(AuthRequiredMetadata::from_metadata) + .transpose() + } + + /// Replace this message's metadata with the auth-required convention payload. + pub fn set_auth_required_metadata( + &mut self, + metadata: AuthRequiredMetadata, + ) -> Result<(), A2AError> { + self.metadata = Some(metadata.into_metadata()?); + Ok(()) + } +} + +impl TaskStatus { + /// Parse auth-required metadata from the current status message when present. + /// + /// If the nested status message carries unrelated metadata, this returns + /// the underlying parse error instead of `Ok(None)`. + pub fn auth_required_metadata(&self) -> Result, A2AError> { + self.message + .as_ref() + .map(Message::auth_required_metadata) + .transpose() + .map(|metadata| metadata.flatten()) + } + + /// Validate that `TASK_STATE_AUTH_REQUIRED` carries the expected metadata convention. + pub fn validate_auth_required_metadata(&self) -> Result<(), A2AError> { + if self.state != TaskState::AuthRequired { + return Ok(()); + } + + let Some(message) = &self.message else { + return Err(A2AError::InvalidRequest( + "TASK_STATE_AUTH_REQUIRED requires a status message carrying auth metadata" + .to_owned(), + )); + }; + + if message.auth_required_metadata()?.is_none() { + return Err(A2AError::InvalidRequest( + "TASK_STATE_AUTH_REQUIRED status message metadata must include authUrl, authScheme, scopes, and description" + .to_owned(), + )); + } + + Ok(()) + } +} + +impl Task { + /// Return auth-required metadata from the current status message or last history item. + /// + /// This returns `Ok(None)` when the task is not in `TASK_STATE_AUTH_REQUIRED`. + /// When the task is in that state, unrelated metadata on the candidate + /// message is treated as an error so callers can distinguish malformed + /// auth-required payloads from the absence of auth metadata. + pub fn auth_required_metadata(&self) -> Result, A2AError> { + if self.status.state != TaskState::AuthRequired { + return Ok(None); + } + + if let Some(metadata) = self.status.auth_required_metadata()? { + return Ok(Some(metadata)); + } + + self.history + .last() + .map(Message::auth_required_metadata) + .transpose() + .map(|metadata| metadata.flatten()) + } + + /// Validate the repository's `TASK_STATE_AUTH_REQUIRED` metadata convention. + pub fn validate_auth_required_convention(&self) -> Result<(), A2AError> { + if self.status.state != TaskState::AuthRequired { + return Ok(()); + } + + if self.auth_required_metadata()?.is_none() { + return Err(A2AError::InvalidRequest( + "TASK_STATE_AUTH_REQUIRED requires auth metadata on the status message or last task message" + .to_owned(), + )); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::AuthRequiredMetadata; + use crate::types::{Message, Part, Role, Task, TaskState, TaskStatus}; + + #[test] + fn auth_required_metadata_round_trips_through_message_metadata() { + let mut message = Message { + message_id: "msg-auth-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: Some("task-1".to_owned()), + role: Role::Agent, + parts: vec![Part { + text: Some("Please authorize access.".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }; + + message + .set_auth_required_metadata(AuthRequiredMetadata { + auth_url: "https://example.com/oauth/authorize".to_owned(), + auth_scheme: "oauth2".to_owned(), + scopes: vec!["calendar.read".to_owned()], + description: "Grant calendar access".to_owned(), + }) + .expect("metadata should set"); + + let metadata = message + .auth_required_metadata() + .expect("metadata should parse") + .expect("metadata should exist"); + + assert_eq!(metadata.auth_scheme, "oauth2"); + assert_eq!(metadata.scopes, vec!["calendar.read"]); + } + + #[test] + fn task_validates_auth_required_convention() { + let mut message = Message { + message_id: "msg-auth-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: Some("task-1".to_owned()), + role: Role::Agent, + parts: vec![Part { + text: Some("Authorize to continue.".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }; + message + .set_auth_required_metadata(AuthRequiredMetadata { + auth_url: "https://example.com/oauth/authorize".to_owned(), + auth_scheme: "oauth2".to_owned(), + scopes: vec!["drive.readonly".to_owned()], + description: "Grant drive access".to_owned(), + }) + .expect("metadata should set"); + + let task = Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::AuthRequired, + message: Some(message), + timestamp: Some("2026-03-13T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }; + + task.validate_auth_required_convention() + .expect("convention should validate"); + } + + #[test] + fn task_rejects_auth_required_without_metadata() { + let task = Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::AuthRequired, + message: Some(Message { + message_id: "msg-auth-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: Some("task-1".to_owned()), + role: Role::Agent, + parts: vec![Part { + text: Some("Authorize to continue.".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }), + timestamp: Some("2026-03-13T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }; + + let error = task + .validate_auth_required_convention() + .expect_err("convention should fail"); + assert!(error.to_string().contains("TASK_STATE_AUTH_REQUIRED")); + } +} diff --git a/src/types/message.rs b/src/types/message.rs new file mode 100644 index 0000000..fe65ec5 --- /dev/null +++ b/src/types/message.rs @@ -0,0 +1,310 @@ +use serde::{Deserialize, Serialize}; + +use crate::A2AError; +use crate::types::JsonObject; + +/// Message author role. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub enum Role { + #[default] + #[serde(rename = "ROLE_UNSPECIFIED")] + /// Unspecified role value. + Unspecified, + #[serde(rename = "ROLE_USER")] + /// End-user authored message. + User, + #[serde(rename = "ROLE_AGENT")] + /// Agent-authored message. + Agent, +} + +/// Flat content part used in messages and artifacts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Part { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Plain-text content. + pub text: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "crate::types::base64_bytes::option" + )] + /// Raw binary content encoded as base64 in JSON. + pub raw: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// URL content reference. + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Structured JSON content. + pub data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional metadata for the part. + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional filename for file-like parts. + pub filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional media type for the content. + pub media_type: Option, +} + +impl Part { + /// Count how many mutually-exclusive content fields are populated. + pub fn content_count(&self) -> usize { + usize::from(self.text.is_some()) + + usize::from(self.raw.is_some()) + + usize::from(self.url.is_some()) + + usize::from(self.data.is_some()) + } + + /// Return `true` when exactly one content field is populated. + pub fn has_single_content(&self) -> bool { + self.content_count() == 1 + } + + /// Validate the proto oneof-style content constraint. + pub fn validate(&self) -> Result<(), A2AError> { + match self.content_count() { + 1 => Ok(()), + 0 => Err(A2AError::InvalidRequest( + "part must contain exactly one of text, raw, url, or data".to_owned(), + )), + _ => Err(A2AError::InvalidRequest( + "part cannot contain more than one of text, raw, url, or data".to_owned(), + )), + } + } +} + +/// Protocol message exchanged between user and agent. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Message { + /// Unique message identifier. + pub message_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional conversation context identifier. + pub context_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional task identifier associated with the message. + pub task_id: Option, + /// Message author role. + pub role: Role, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Ordered message parts. + pub parts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional message metadata. + pub metadata: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Extension URIs attached to the message. + pub extensions: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Related task identifiers referenced by the message. + pub reference_task_ids: Vec, +} + +impl Message { + /// Validate that the message contains at least one valid part. + pub fn validate(&self) -> Result<(), A2AError> { + if self.parts.is_empty() { + return Err(A2AError::InvalidRequest( + "message must contain at least one part".to_owned(), + )); + } + + for part in &self.parts { + part.validate()?; + } + + Ok(()) + } +} + +/// Output artifact produced by a task. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Artifact { + /// Unique artifact identifier. + pub artifact_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional artifact name. + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional artifact description. + pub description: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Ordered artifact parts. + pub parts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional artifact metadata. + pub metadata: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Extension URIs attached to the artifact. + pub extensions: Vec, +} + +impl Artifact { + /// Validate that the artifact contains at least one valid part. + pub fn validate(&self) -> Result<(), A2AError> { + if self.parts.is_empty() { + return Err(A2AError::InvalidRequest( + "artifact must contain at least one part".to_owned(), + )); + } + + for part in &self.parts { + part.validate()?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{Artifact, Message, Part, Role}; + + #[test] + fn part_reports_single_content_field() { + let part = Part { + text: Some("hello".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }; + + assert_eq!(part.content_count(), 1); + assert!(part.has_single_content()); + } + + #[test] + fn part_raw_serializes_as_base64() { + let part = Part { + text: None, + raw: Some(vec![104, 105]), + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }; + + let json = serde_json::to_string(&part).expect("part should serialize"); + assert_eq!(json, r#"{"raw":"aGk="}"#); + } + + #[test] + fn part_validate_rejects_multiple_content_fields() { + let part = Part { + text: Some("hello".to_owned()), + raw: Some(vec![104, 105]), + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }; + + let error = part.validate().expect_err("part should be invalid"); + assert!( + error + .to_string() + .contains("part cannot contain more than one") + ); + } + + #[test] + fn message_and_artifact_round_trip_serialization() { + let message = Message { + message_id: "msg-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: Some("task-1".to_owned()), + role: Role::User, + parts: vec![Part { + text: Some("hello".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: vec!["trace".to_owned()], + reference_task_ids: vec!["task-0".to_owned()], + }; + let artifact = Artifact { + artifact_id: "artifact-1".to_owned(), + name: Some("transcript".to_owned()), + description: Some("conversation log".to_owned()), + parts: vec![Part { + text: Some("hello".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: vec!["indexed".to_owned()], + }; + + let message_json = serde_json::to_string(&message).expect("message should serialize"); + let artifact_json = serde_json::to_string(&artifact).expect("artifact should serialize"); + + let message_round_trip: Message = + serde_json::from_str(&message_json).expect("message should deserialize"); + let artifact_round_trip: Artifact = + serde_json::from_str(&artifact_json).expect("artifact should deserialize"); + + assert_eq!(message_round_trip.message_id, "msg-1"); + assert_eq!(artifact_round_trip.artifact_id, "artifact-1"); + assert_eq!(artifact_round_trip.parts.len(), 1); + } + + #[test] + fn message_validate_rejects_empty_parts() { + let message = Message { + message_id: "msg-1".to_owned(), + context_id: None, + task_id: None, + role: Role::User, + parts: Vec::new(), + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }; + + let error = message.validate().expect_err("message should be invalid"); + assert!( + error + .to_string() + .contains("message must contain at least one part") + ); + } + + #[test] + fn artifact_validate_rejects_empty_parts() { + let artifact = Artifact { + artifact_id: "artifact-1".to_owned(), + name: None, + description: None, + parts: Vec::new(), + metadata: None, + extensions: Vec::new(), + }; + + let error = artifact.validate().expect_err("artifact should be invalid"); + assert!( + error + .to_string() + .contains("artifact must contain at least one part") + ); + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 0000000..3c24c7f --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,70 @@ +//! Core A2A protocol types. + +use base64::Engine as _; +use serde::{Deserialize, Deserializer, Serializer}; + +/// Agent discovery and capability types. +pub mod agent_card; +/// Validated helper type for application-level agent identifiers. +pub mod agent_id; +/// Auth-required metadata helpers and conventions. +pub mod auth; +/// Messages, parts, and artifacts exchanged by agents. +pub mod message; +/// Push-notification configuration types. +pub mod push; +/// Operation request payloads. +pub mod requests; +/// Operation response payloads and stream events. +pub mod responses; +/// Security scheme and requirement types. +pub mod security; +/// Task state and status models. +pub mod task; + +pub use self::agent_card::*; +pub use self::agent_id::*; +pub use self::auth::*; +pub use self::message::*; +pub use self::push::*; +pub use self::requests::*; +pub use self::responses::*; +pub use self::security::*; +pub use self::task::*; + +/// Shared JSON object alias used across metadata-bearing protocol types. +pub type JsonObject = serde_json::Map; + +pub(crate) fn is_false(value: &bool) -> bool { + !*value +} + +pub(crate) mod base64_bytes { + use super::*; + + const ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + + pub(crate) mod option { + use super::*; + + pub fn serialize(value: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(bytes) => serializer.serialize_some(&ENGINE.encode(bytes)), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let encoded = Option::::deserialize(deserializer)?; + encoded + .map(|encoded| ENGINE.decode(encoded).map_err(serde::de::Error::custom)) + .transpose() + } + } +} diff --git a/src/types/push.rs b/src/types/push.rs new file mode 100644 index 0000000..925c7eb --- /dev/null +++ b/src/types/push.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +/// Push-notification delivery target. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PushNotificationConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional provider-specific configuration identifier. + pub id: Option, + /// Destination URL for push delivery. + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional opaque bearer token or shared secret. + pub token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional authentication description. + pub authentication: Option, +} + +/// Authentication details for a push target. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticationInfo { + /// Authentication scheme name. + pub scheme: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional scheme-specific credentials. + pub credentials: Option, +} + +/// Stored push-notification configuration associated with a task. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskPushNotificationConfig { + /// Unique configuration identifier. + pub id: String, + /// Task identifier that owns the configuration. + pub task_id: String, + /// Push delivery settings. + pub push_notification_config: PushNotificationConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant associated with the configuration. + pub tenant: Option, +} diff --git a/src/types/requests.rs b/src/types/requests.rs new file mode 100644 index 0000000..ef1d70d --- /dev/null +++ b/src/types/requests.rs @@ -0,0 +1,311 @@ +use serde::{Deserialize, Serialize}; + +use crate::A2AError; +use crate::types::JsonObject; + +use super::message::Message; +use super::push::PushNotificationConfig; +use super::task::TaskState; + +/// Optional configuration for `SendMessage`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendMessageConfiguration { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Output modes the caller can accept. + pub accepted_output_modes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional push configuration to attach to the request. + pub push_notification_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Maximum history items requested in task responses. + pub history_length: Option, + #[serde(default, skip_serializing_if = "crate::types::is_false")] + /// Whether the server should block for a final response when possible. + pub blocking: bool, +} + +/// Request payload for `SendMessage`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendMessageRequest { + /// Input message from the caller. + pub message: Message, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional message handling configuration. + pub configuration: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional request metadata. + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, +} + +impl SendMessageRequest { + /// Validate nested message content. + pub fn validate(&self) -> Result<(), A2AError> { + self.message.validate() + } +} + +/// Request payload for `GetTask`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetTaskRequest { + /// Task identifier. + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Maximum history items requested in the response. + pub history_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, +} + +/// Request payload for `ListTasks`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListTasksRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional context filter. + pub context_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional task-state filter. + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Requested page size. + pub page_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Opaque pagination token from a previous response. + pub page_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Maximum history items requested per returned task. + pub history_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Lower bound for task status timestamps. + pub status_timestamp_after: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Whether artifacts should be included in results. + pub include_artifacts: Option, +} + +impl ListTasksRequest { + /// Validate pagination bounds. + pub fn validate(&self) -> Result<(), A2AError> { + if let Some(page_size) = self.page_size + && !(1..=100).contains(&page_size) + { + return Err(A2AError::InvalidRequest( + "pageSize must be between 1 and 100".to_owned(), + )); + } + + Ok(()) + } +} + +/// Request payload for `CancelTask`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelTaskRequest { + /// Task identifier. + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, +} + +/// Request payload for `GetTaskPushNotificationConfig`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetTaskPushNotificationConfigRequest { + /// Push configuration identifier. + pub id: String, + /// Owning task identifier. + pub task_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, +} + +/// Request payload for `DeleteTaskPushNotificationConfig`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteTaskPushNotificationConfigRequest { + /// Push configuration identifier. + pub id: String, + /// Owning task identifier. + pub task_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, +} + +/// Request payload for `CreateTaskPushNotificationConfig`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTaskPushNotificationConfigRequest { + /// Owning task identifier. + pub task_id: String, + /// Desired push configuration identifier. + pub config_id: String, + /// Push delivery configuration. + pub config: PushNotificationConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, +} + +/// Request payload for `SubscribeToTask`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscribeToTaskRequest { + /// Task identifier. + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, +} + +/// Request payload for `ListTaskPushNotificationConfig`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListTaskPushNotificationConfigRequest { + /// Owning task identifier. + pub task_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Requested page size. + pub page_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Opaque pagination token from a previous response. + pub page_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, +} + +impl ListTaskPushNotificationConfigRequest { + /// Validate required identifiers. + pub fn validate(&self) -> Result<(), A2AError> { + if self.task_id.is_empty() { + return Err(A2AError::InvalidRequest( + "task_id must not be empty".to_owned(), + )); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{ListTaskPushNotificationConfigRequest, ListTasksRequest, SendMessageRequest}; + use crate::types::{Message, Part, Role}; + + #[test] + fn list_task_push_notification_config_request_rejects_empty_task_id() { + let request = ListTaskPushNotificationConfigRequest { + task_id: String::new(), + page_size: None, + page_token: None, + tenant: None, + }; + + let error = request.validate().expect_err("request should be invalid"); + assert!(error.to_string().contains("task_id must not be empty")); + } + + #[test] + fn list_tasks_request_rejects_out_of_range_page_size() { + let request = ListTasksRequest { + tenant: None, + context_id: None, + status: None, + page_size: Some(101), + page_token: None, + history_length: None, + status_timestamp_after: None, + include_artifacts: None, + }; + + let error = request.validate().expect_err("request should be invalid"); + assert!( + error + .to_string() + .contains("pageSize must be between 1 and 100") + ); + } + + #[test] + fn send_message_request_rejects_empty_message_parts() { + let request = SendMessageRequest { + message: Message { + message_id: "msg-1".to_owned(), + context_id: None, + task_id: None, + role: Role::User, + parts: Vec::new(), + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }, + configuration: None, + metadata: None, + tenant: None, + }; + + let error = request.validate().expect_err("request should be invalid"); + assert!( + error + .to_string() + .contains("message must contain at least one part") + ); + } + + #[test] + fn send_message_request_validates_part_content() { + let request = SendMessageRequest { + message: Message { + message_id: "msg-1".to_owned(), + context_id: None, + task_id: None, + role: Role::User, + parts: vec![Part { + text: Some("hello".to_owned()), + raw: Some(vec![104, 105]), + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }, + configuration: None, + metadata: None, + tenant: None, + }; + + let error = request.validate().expect_err("request should be invalid"); + assert!( + error + .to_string() + .contains("part cannot contain more than one") + ); + } +} + +/// Request payload for `GetExtendedAgentCard`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetExtendedAgentCardRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional tenant identifier. + pub tenant: Option, +} diff --git a/src/types/responses.rs b/src/types/responses.rs new file mode 100644 index 0000000..a74ad59 --- /dev/null +++ b/src/types/responses.rs @@ -0,0 +1,314 @@ +use serde::{Deserialize, Serialize}; + +use crate::A2AError; +use crate::types::JsonObject; + +use super::message::{Artifact, Message}; +use super::push::TaskPushNotificationConfig; +use super::task::{Task, TaskStatus}; + +/// Streaming event emitted when a task status changes. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskStatusUpdateEvent { + /// Task identifier. + pub task_id: String, + /// Context identifier shared with the task. + pub context_id: String, + /// Updated task status snapshot. + pub status: TaskStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional event metadata. + pub metadata: Option, +} + +/// Streaming event emitted when an artifact changes. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskArtifactUpdateEvent { + /// Task identifier. + pub task_id: String, + /// Context identifier shared with the task. + pub context_id: String, + /// Artifact snapshot or chunk. + pub artifact: Artifact, + #[serde(default, skip_serializing_if = "crate::types::is_false")] + /// Whether the artifact payload should append to prior chunks. + pub append: bool, + #[serde(default, skip_serializing_if = "crate::types::is_false")] + /// Whether this is the last chunk for the artifact. + pub last_chunk: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional event metadata. + pub metadata: Option, +} + +fn validate_task(task: &Task) -> Result<(), A2AError> { + for artifact in &task.artifacts { + artifact.validate()?; + } + + for message in &task.history { + message.validate()?; + } + + if let Some(message) = &task.status.message { + message.validate()?; + } + + Ok(()) +} + +/// Oneof-style result for `SendMessage`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SendMessageResponse { + /// Response returned as a task. + Task(Task), + /// Response returned directly as a message. + Message(Message), +} + +impl SendMessageResponse { + /// Validate nested task or message content. + pub fn validate(&self) -> Result<(), A2AError> { + match self { + Self::Task(task) => validate_task(task), + Self::Message(message) => message.validate(), + } + } +} + +/// Oneof-style item emitted on streaming operations. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StreamResponse { + /// Task snapshot event. + Task(Task), + /// Message event. + Message(Message), + /// Task status update event. + StatusUpdate(TaskStatusUpdateEvent), + /// Task artifact update event. + ArtifactUpdate(TaskArtifactUpdateEvent), +} + +impl StreamResponse { + /// Validate nested event content. + pub fn validate(&self) -> Result<(), A2AError> { + match self { + Self::Task(task) => validate_task(task), + Self::Message(message) => message.validate(), + Self::StatusUpdate(update) => { + if let Some(message) = &update.status.message { + message.validate()?; + } + + Ok(()) + } + Self::ArtifactUpdate(update) => update.artifact.validate(), + } + } +} + +/// Paginated response for `ListTasks`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListTasksResponse { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Returned task page. + pub tasks: Vec, + /// Opaque token for the next page, or an empty string when exhausted. + pub next_page_token: String, + /// Requested page size echoed in the response. + pub page_size: i32, + /// Total number of matching tasks. + pub total_size: i32, +} + +/// Paginated response for `ListTaskPushNotificationConfig`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListTaskPushNotificationConfigResponse { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Returned push-notification configuration page. + pub configs: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + /// Opaque token for the next page, or an empty string when exhausted. + pub next_page_token: String, +} + +#[cfg(test)] +mod tests { + use super::{ + ListTaskPushNotificationConfigResponse, SendMessageResponse, StreamResponse, + TaskArtifactUpdateEvent, TaskStatusUpdateEvent, + }; + use crate::types::{Artifact, Message, Part, Role, Task, TaskState, TaskStatus}; + + #[test] + fn send_message_response_uses_proto_oneof_shape() { + let response = SendMessageResponse::Message(Message { + message_id: "msg-1".to_owned(), + context_id: None, + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some("done".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }); + + let json = serde_json::to_string(&response).expect("response should serialize"); + assert_eq!( + json, + r#"{"message":{"messageId":"msg-1","role":"ROLE_AGENT","parts":[{"text":"done"}]}}"# + ); + } + + #[test] + fn send_message_response_validate_rejects_invalid_part() { + let response = SendMessageResponse::Message(Message { + message_id: "msg-1".to_owned(), + context_id: None, + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some("done".to_owned()), + raw: Some(vec![104, 105]), + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }); + + let error = response.validate().expect_err("response should be invalid"); + assert!( + error + .to_string() + .contains("part cannot contain more than one") + ); + } + + #[test] + fn list_push_notification_response_uses_empty_string_for_no_next_page() { + let response = ListTaskPushNotificationConfigResponse { + configs: Vec::new(), + next_page_token: String::new(), + }; + + let json = serde_json::to_string(&response).expect("response should serialize"); + assert_eq!(json, "{}"); + } + + #[test] + fn task_status_update_event_round_trip_serialization() { + let event = TaskStatusUpdateEvent { + task_id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: Some(Message { + message_id: "msg-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: Some("task-1".to_owned()), + role: Role::Agent, + parts: vec![Part { + text: Some("still working".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }), + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + metadata: None, + }; + + let json = serde_json::to_string(&event).expect("event should serialize"); + let round_trip: TaskStatusUpdateEvent = + serde_json::from_str(&json).expect("event should deserialize"); + + assert_eq!(round_trip.task_id, "task-1"); + assert_eq!(round_trip.status.state, TaskState::Working); + } + + #[test] + fn task_artifact_update_event_round_trip_serialization() { + let event = TaskArtifactUpdateEvent { + task_id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + artifact: Artifact { + artifact_id: "artifact-1".to_owned(), + name: Some("result".to_owned()), + description: None, + parts: vec![Part { + text: Some("partial".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + }, + append: true, + last_chunk: false, + metadata: None, + }; + + let json = serde_json::to_string(&event).expect("event should serialize"); + let round_trip: TaskArtifactUpdateEvent = + serde_json::from_str(&json).expect("event should deserialize"); + + assert!(round_trip.append); + assert!(!round_trip.last_chunk); + assert_eq!(round_trip.artifact.artifact_id, "artifact-1"); + } + + #[test] + fn stream_response_round_trip_serialization() { + let response = StreamResponse::Task(Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Submitted, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }); + + let json = serde_json::to_string(&response).expect("response should serialize"); + let round_trip: StreamResponse = + serde_json::from_str(&json).expect("response should deserialize"); + + match round_trip { + StreamResponse::Task(task) => assert_eq!(task.id, "task-1"), + _ => panic!("expected task stream response"), + } + } +} diff --git a/src/types/security.rs b/src/types/security.rs new file mode 100644 index 0000000..0094762 --- /dev/null +++ b/src/types/security.rs @@ -0,0 +1,512 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +/// Wrapper used by proto JSON for repeated string values in maps. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StringList { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Ordered string values. + pub list: Vec, +} + +/// Security requirement mapping from scheme name to scopes. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SecurityRequirement { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + /// Required schemes and scope lists. + pub schemes: BTreeMap, +} + +/// Supported security scheme variants. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SecurityScheme { + #[serde(rename = "apiKeySecurityScheme")] + /// API key security scheme. + ApiKeySecurityScheme(ApiKeySecurityScheme), + #[serde(rename = "httpAuthSecurityScheme")] + /// HTTP auth security scheme. + HttpAuthSecurityScheme(HttpAuthSecurityScheme), + #[serde(rename = "oauth2SecurityScheme")] + /// OAuth 2.0 security scheme. + OAuth2SecurityScheme(OAuth2SecurityScheme), + #[serde(rename = "openIdConnectSecurityScheme")] + /// OpenID Connect discovery scheme. + OpenIdConnectSecurityScheme(OpenIdConnectSecurityScheme), + #[serde(rename = "mtlsSecurityScheme")] + /// Mutual TLS security scheme. + MutualTlsSecurityScheme(MutualTlsSecurityScheme), +} + +impl<'de> Deserialize<'de> for SecurityScheme { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + deserialize_security_scheme(value).map_err(serde::de::Error::custom) + } +} + +/// API key security scheme definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiKeySecurityScheme { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional description for human readers. + pub description: Option, + /// Location of the API key, such as `header` or `query`. + pub location: String, + /// Header or parameter name carrying the key. + pub name: String, +} + +/// HTTP auth security scheme definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpAuthSecurityScheme { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional description for human readers. + pub description: Option, + /// Authentication scheme, such as `basic` or `bearer`. + pub scheme: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional bearer token format hint. + pub bearer_format: Option, +} + +/// OAuth 2.0 security scheme definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OAuth2SecurityScheme { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional description for human readers. + pub description: Option, + /// Supported OAuth flow. + pub flows: OAuthFlows, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional metadata discovery URL. + pub oauth2_metadata_url: Option, +} + +/// OpenID Connect security scheme definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenIdConnectSecurityScheme { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional description for human readers. + pub description: Option, + /// OpenID Connect discovery URL. + pub open_id_connect_url: String, +} + +/// Mutual TLS security scheme definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MutualTlsSecurityScheme { + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional description for human readers. + pub description: Option, +} + +/// Supported OAuth 2.0 flow variants. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OAuthFlows { + /// Authorization code flow. + AuthorizationCode(AuthorizationCodeOAuthFlow), + /// Client credentials flow. + ClientCredentials(ClientCredentialsOAuthFlow), + /// Implicit flow. + Implicit(ImplicitOAuthFlow), + /// Resource owner password flow. + Password(PasswordOAuthFlow), + /// Device code flow. + DeviceCode(DeviceCodeOAuthFlow), +} + +impl<'de> Deserialize<'de> for OAuthFlows { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + deserialize_oauth_flows(value).map_err(serde::de::Error::custom) + } +} + +/// Authorization code flow settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorizationCodeOAuthFlow { + /// Authorization endpoint URL. + pub authorization_url: String, + /// Token endpoint URL. + pub token_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional refresh endpoint URL. + pub refresh_url: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + /// OAuth scopes and their descriptions. + pub scopes: BTreeMap, + #[serde(default, skip_serializing_if = "crate::types::is_false")] + /// Whether PKCE is required for this flow. + pub pkce_required: bool, +} + +/// Client credentials flow settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientCredentialsOAuthFlow { + /// Token endpoint URL. + pub token_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional refresh endpoint URL. + pub refresh_url: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + /// OAuth scopes and their descriptions. + pub scopes: BTreeMap, +} + +/// Implicit flow settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImplicitOAuthFlow { + /// Authorization endpoint URL. + pub authorization_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional refresh endpoint URL. + pub refresh_url: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + /// OAuth scopes and their descriptions. + pub scopes: BTreeMap, +} + +/// Password flow settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasswordOAuthFlow { + /// Token endpoint URL. + pub token_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional refresh endpoint URL. + pub refresh_url: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + /// OAuth scopes and their descriptions. + pub scopes: BTreeMap, +} + +/// Device code flow settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceCodeOAuthFlow { + /// Device authorization endpoint URL. + pub device_authorization_url: String, + /// Token endpoint URL. + pub token_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional refresh endpoint URL. + pub refresh_url: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + /// OAuth scopes and their descriptions. + pub scopes: BTreeMap, +} + +fn deserialize_security_scheme(value: Value) -> Result { + let Value::Object(mut object) = value else { + return Err("security scheme must be a JSON object".to_owned()); + }; + + if object.len() == 1 { + let (key, value) = object + .into_iter() + .next() + .ok_or_else(|| "security scheme object cannot be empty".to_owned())?; + return match key.as_str() { + "apiKeySecurityScheme" => { + deserialize_variant(value, SecurityScheme::ApiKeySecurityScheme) + } + "httpAuthSecurityScheme" => { + deserialize_variant(value, SecurityScheme::HttpAuthSecurityScheme) + } + "oauth2SecurityScheme" => { + deserialize_variant(value, SecurityScheme::OAuth2SecurityScheme) + } + "openIdConnectSecurityScheme" => { + deserialize_variant(value, SecurityScheme::OpenIdConnectSecurityScheme) + } + "mtlsSecurityScheme" => { + deserialize_variant(value, SecurityScheme::MutualTlsSecurityScheme) + } + _ => Err(format!("unknown security scheme variant: {key}")), + }; + } + + let type_name = object + .remove("type") + .and_then(|value| match value { + Value::String(value) => Some(value), + _ => None, + }) + .ok_or_else(|| "security scheme must contain either a proto oneof tag or a Python SDK 'type' discriminator".to_owned())?; + + match type_name.as_str() { + "apiKey" => { + if let Some(location) = object.remove("in") { + object.insert("location".to_owned(), location); + } + deserialize_variant(Value::Object(object), SecurityScheme::ApiKeySecurityScheme) + } + "http" => deserialize_variant( + Value::Object(object), + SecurityScheme::HttpAuthSecurityScheme, + ), + "oauth2" => { + deserialize_variant(Value::Object(object), SecurityScheme::OAuth2SecurityScheme) + } + "openIdConnect" => deserialize_variant( + Value::Object(object), + SecurityScheme::OpenIdConnectSecurityScheme, + ), + "mutualTLS" | "mutualTls" | "mtls" => deserialize_variant( + Value::Object(object), + SecurityScheme::MutualTlsSecurityScheme, + ), + other => Err(format!( + "unsupported security scheme type discriminator: {other}" + )), + } +} + +fn deserialize_oauth_flows(value: Value) -> Result { + let Value::Object(mut object) = value else { + return Err("oauth flows must be a JSON object".to_owned()); + }; + + let mut chosen: Option<(&'static str, Value)> = None; + for key in [ + "authorizationCode", + "clientCredentials", + "implicit", + "password", + "deviceCode", + ] { + match object.remove(key) { + Some(Value::Null) | None => {} + Some(value) => { + if chosen.is_some() { + return Err("oauth flows must contain exactly one flow variant".to_owned()); + } + chosen = Some((key, value)); + } + } + } + + if !object.is_empty() { + let mut keys = object.keys().cloned().collect::>(); + keys.sort(); + return Err(format!( + "oauth flows contained unexpected keys: {}", + keys.join(", ") + )); + } + + let Some((key, value)) = chosen else { + return Err("oauth flows must contain exactly one flow variant".to_owned()); + }; + + match key { + "authorizationCode" => deserialize_variant(value, OAuthFlows::AuthorizationCode), + "clientCredentials" => deserialize_variant(value, OAuthFlows::ClientCredentials), + "implicit" => deserialize_variant(value, OAuthFlows::Implicit), + "password" => deserialize_variant(value, OAuthFlows::Password), + "deviceCode" => deserialize_variant(value, OAuthFlows::DeviceCode), + _ => Err(format!("unsupported oauth flow variant: {key}")), + } +} + +fn deserialize_variant(value: Value, constructor: impl FnOnce(T) -> U) -> Result +where + T: serde::de::DeserializeOwned, +{ + serde_json::from_value(value) + .map(constructor) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{ + ApiKeySecurityScheme, AuthorizationCodeOAuthFlow, HttpAuthSecurityScheme, + OAuth2SecurityScheme, OAuthFlows, OpenIdConnectSecurityScheme, SecurityScheme, + }; + + #[test] + fn security_scheme_serializes_as_externally_tagged_enum() { + let scheme = SecurityScheme::ApiKeySecurityScheme(ApiKeySecurityScheme { + description: None, + location: "header".to_owned(), + name: "X-API-Key".to_owned(), + }); + + let json = serde_json::to_string(&scheme).expect("scheme should serialize"); + assert_eq!( + json, + r#"{"apiKeySecurityScheme":{"location":"header","name":"X-API-Key"}}"# + ); + } + + #[test] + fn oauth_flows_serializes_with_variant_name() { + let mut scopes = BTreeMap::new(); + scopes.insert("read".to_owned(), "Read access".to_owned()); + + let scheme = OAuth2SecurityScheme { + description: None, + flows: OAuthFlows::AuthorizationCode(AuthorizationCodeOAuthFlow { + authorization_url: "https://example.com/authorize".to_owned(), + token_url: "https://example.com/token".to_owned(), + refresh_url: None, + scopes, + pkce_required: true, + }), + oauth2_metadata_url: None, + }; + + let json = serde_json::to_string(&scheme).expect("oauth2 scheme should serialize"); + assert!(json.contains( + r#""authorizationCode":{"authorizationUrl":"https://example.com/authorize""# + )); + assert!(json.contains(r#""pkceRequired":true"#)); + } + + #[test] + fn security_scheme_deserializes_python_sdk_api_key_shape() { + let json = serde_json::json!({ + "type": "apiKey", + "description": "Header auth", + "in": "header", + "name": "X-API-Key" + }); + + let scheme: SecurityScheme = + serde_json::from_value(json).expect("scheme should deserialize"); + + match &scheme { + SecurityScheme::ApiKeySecurityScheme(scheme) => { + assert_eq!(scheme.location, "header"); + assert_eq!(scheme.name, "X-API-Key"); + } + _ => panic!("expected api key scheme"), + } + + let reserialized = serde_json::to_string(&scheme).expect("scheme should serialize"); + assert_eq!( + reserialized, + r#"{"apiKeySecurityScheme":{"description":"Header auth","location":"header","name":"X-API-Key"}}"# + ); + } + + #[test] + fn security_scheme_deserializes_python_sdk_http_shape() { + let json = serde_json::json!({ + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }); + + let scheme: SecurityScheme = + serde_json::from_value(json).expect("scheme should deserialize"); + + assert!(matches!( + scheme, + SecurityScheme::HttpAuthSecurityScheme(HttpAuthSecurityScheme { scheme, .. }) if scheme == "bearer" + )); + } + + #[test] + fn security_scheme_deserializes_python_sdk_openid_shape() { + let json = serde_json::json!({ + "type": "openIdConnect", + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration" + }); + + let scheme: SecurityScheme = + serde_json::from_value(json).expect("scheme should deserialize"); + + assert!(matches!( + scheme, + SecurityScheme::OpenIdConnectSecurityScheme(OpenIdConnectSecurityScheme { open_id_connect_url, .. }) + if open_id_connect_url == "https://example.com/.well-known/openid-configuration" + )); + } + + #[test] + fn oauth_flows_deserialize_python_sdk_object_shape() { + let json = serde_json::json!({ + "authorizationCode": { + "authorizationUrl": "https://example.com/authorize", + "tokenUrl": "https://example.com/token", + "scopes": { + "read": "Read access" + }, + "pkceRequired": true + } + }); + + let flows: OAuthFlows = serde_json::from_value(json).expect("flows should deserialize"); + assert!(matches!( + flows, + OAuthFlows::AuthorizationCode(AuthorizationCodeOAuthFlow { + pkce_required: true, + .. + }) + )); + } + + #[test] + fn security_scheme_deserializes_python_sdk_oauth2_shape() { + let json = serde_json::json!({ + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://example.com/authorize", + "tokenUrl": "https://example.com/token", + "scopes": { + "read": "Read access" + } + } + } + }); + + let scheme: SecurityScheme = + serde_json::from_value(json).expect("scheme should deserialize"); + + assert!(matches!( + scheme, + SecurityScheme::OAuth2SecurityScheme(OAuth2SecurityScheme { + flows: OAuthFlows::AuthorizationCode(_), + .. + }) + )); + } + + #[test] + fn security_scheme_deserializes_python_sdk_mutual_tls_shape() { + let json = serde_json::json!({ + "type": "mutualTLS", + "description": "mTLS client cert" + }); + + let scheme: SecurityScheme = + serde_json::from_value(json).expect("scheme should deserialize"); + + assert!(matches!(scheme, SecurityScheme::MutualTlsSecurityScheme(_))); + } +} diff --git a/src/types/task.rs b/src/types/task.rs new file mode 100644 index 0000000..ad1367c --- /dev/null +++ b/src/types/task.rs @@ -0,0 +1,126 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::JsonObject; + +use super::message::{Artifact, Message}; + +/// Server-side task resource. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Task { + /// Unique task identifier. + pub id: String, + /// Context identifier shared with related messages and updates. + pub context_id: String, + /// Current task status. + pub status: TaskStatus, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Artifacts produced by the task. + pub artifacts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Task message history. + pub history: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional task metadata. + pub metadata: Option, +} + +/// Snapshot of a task's current state. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskStatus { + /// Current lifecycle state. + pub state: TaskState, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional status message payload. + pub message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional RFC 3339 timestamp for the status update. + pub timestamp: Option, +} + +/// Task lifecycle state. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum TaskState { + #[default] + #[serde(rename = "TASK_STATE_UNSPECIFIED")] + /// Unspecified state. + Unspecified, + #[serde(rename = "TASK_STATE_SUBMITTED")] + /// Task accepted but not yet running. + Submitted, + #[serde(rename = "TASK_STATE_WORKING")] + /// Task is currently running. + Working, + #[serde(rename = "TASK_STATE_COMPLETED")] + /// Task completed successfully. + Completed, + #[serde(rename = "TASK_STATE_FAILED")] + /// Task failed permanently. + Failed, + #[serde(rename = "TASK_STATE_CANCELED")] + /// Task was canceled. + Canceled, + #[serde(rename = "TASK_STATE_INPUT_REQUIRED")] + /// Task requires further user input. + InputRequired, + #[serde(rename = "TASK_STATE_REJECTED")] + /// Task was rejected before execution. + Rejected, + #[serde(rename = "TASK_STATE_AUTH_REQUIRED")] + /// Task requires authentication before continuing. + AuthRequired, +} + +#[cfg(test)] +mod tests { + use super::{Task, TaskState, TaskStatus}; + use crate::types::{Message, Part, Role}; + + #[test] + fn task_state_serializes_as_proto_enum_name() { + let json = + serde_json::to_string(&TaskState::Completed).expect("task state should serialize"); + assert_eq!(json, r#""TASK_STATE_COMPLETED""#); + } + + #[test] + fn task_round_trip_serialization() { + let task = Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Completed, + message: Some(Message { + message_id: "msg-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: Some("task-1".to_owned()), + role: Role::Agent, + parts: vec![Part { + text: Some("done".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }), + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }; + + let json = serde_json::to_string(&task).expect("task should serialize"); + let round_trip: Task = serde_json::from_str(&json).expect("task should deserialize"); + + assert_eq!(round_trip.id, "task-1"); + assert_eq!(round_trip.context_id, "ctx-1"); + assert_eq!(round_trip.status.state, TaskState::Completed); + } +} diff --git a/tests/client_integration.rs b/tests/client_integration.rs new file mode 100644 index 0000000..3cb7d0c --- /dev/null +++ b/tests/client_integration.rs @@ -0,0 +1,644 @@ +#![cfg(all(feature = "client", feature = "server"))] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use futures_util::{StreamExt, stream}; +use tokio::task::JoinHandle; + +use a2a_rust::A2AError; +use a2a_rust::client::{A2AClient, A2AClientConfig, AgentCardDiscovery, AgentCardDiscoveryConfig}; +use a2a_rust::server::{A2AHandler, A2AStream, router}; +use a2a_rust::types::{ + AgentCapabilities, AgentCard, AgentInterface, CancelTaskRequest, + CreateTaskPushNotificationConfigRequest, DeleteTaskPushNotificationConfigRequest, + GetExtendedAgentCardRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest, + ListTaskPushNotificationConfigRequest, ListTaskPushNotificationConfigResponse, + ListTasksRequest, ListTasksResponse, Message, Part, PushNotificationConfig, Role, + SendMessageRequest, SendMessageResponse, StreamResponse, SubscribeToTaskRequest, Task, + TaskPushNotificationConfig, TaskState, TaskStatus, TaskStatusUpdateEvent, +}; + +#[derive(Clone)] +struct ClientTestHandler { + card_hits: Arc, + interfaces: Vec, + capabilities: AgentCapabilities, +} + +#[async_trait] +impl A2AHandler for ClientTestHandler { + async fn get_agent_card(&self) -> Result { + self.card_hits.fetch_add(1, Ordering::SeqCst); + + Ok(AgentCard { + name: "Client Test Agent".to_owned(), + description: "Client integration test agent".to_owned(), + supported_interfaces: self.interfaces.clone(), + provider: None, + version: "0.1.0".to_owned(), + documentation_url: None, + capabilities: self.capabilities.clone(), + security_schemes: Default::default(), + security_requirements: Vec::new(), + default_input_modes: vec!["text/plain".to_owned()], + default_output_modes: vec!["text/plain".to_owned()], + skills: Vec::new(), + signatures: Vec::new(), + icon_url: None, + }) + } + + async fn send_message( + &self, + request: SendMessageRequest, + ) -> Result { + Ok(SendMessageResponse::Message(Message { + message_id: "msg-client-1".to_owned(), + context_id: request.message.context_id, + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some("pong".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: tenant_metadata(request.tenant), + extensions: Vec::new(), + reference_task_ids: Vec::new(), + })) + } + + async fn send_streaming_message( + &self, + request: SendMessageRequest, + ) -> Result { + self.require_streaming_capability("SendStreamingMessage") + .await?; + + Ok(Box::pin(stream::iter(vec![StreamResponse::Message( + Message { + message_id: "msg-stream-1".to_owned(), + context_id: request.message.context_id, + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some("stream-pong".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: tenant_metadata(request.tenant), + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }, + )]))) + } + + async fn get_task(&self, request: GetTaskRequest) -> Result { + if request.id == "missing" { + return Err(A2AError::TaskNotFound(request.id)); + } + + Ok(Task { + id: request.id, + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: tenant_metadata(request.tenant), + }) + } + + async fn list_tasks(&self, request: ListTasksRequest) -> Result { + Ok(ListTasksResponse { + tasks: vec![Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Submitted, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: tenant_metadata(request.tenant), + }], + next_page_token: String::new(), + page_size: 1, + total_size: 1, + }) + } + + async fn cancel_task(&self, request: CancelTaskRequest) -> Result { + Ok(Task { + id: request.id, + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Canceled, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: tenant_metadata(request.tenant), + }) + } + + async fn subscribe_to_task( + &self, + request: SubscribeToTaskRequest, + ) -> Result { + self.require_streaming_capability("SubscribeToTask").await?; + + Ok(Box::pin(stream::iter(vec![ + StreamResponse::Task(Task { + id: request.id.clone(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: tenant_metadata(request.tenant.clone()), + }), + StreamResponse::StatusUpdate(TaskStatusUpdateEvent { + task_id: request.id, + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Completed, + message: None, + timestamp: Some("2026-03-12T12:01:00Z".to_owned()), + }, + metadata: tenant_metadata(request.tenant), + }), + ]))) + } + + async fn create_task_push_notification_config( + &self, + request: CreateTaskPushNotificationConfigRequest, + ) -> Result { + Ok(TaskPushNotificationConfig { + id: request.config_id.clone(), + task_id: request.task_id, + push_notification_config: request.config, + tenant: request.tenant, + }) + } + + async fn get_task_push_notification_config( + &self, + request: GetTaskPushNotificationConfigRequest, + ) -> Result { + Ok(TaskPushNotificationConfig { + id: request.id.clone(), + task_id: request.task_id, + push_notification_config: PushNotificationConfig { + id: Some(request.id), + url: "https://example.com/push".to_owned(), + token: Some("secret".to_owned()), + authentication: None, + }, + tenant: request.tenant, + }) + } + + async fn list_task_push_notification_config( + &self, + request: ListTaskPushNotificationConfigRequest, + ) -> Result { + Ok(ListTaskPushNotificationConfigResponse { + configs: vec![TaskPushNotificationConfig { + id: "cfg-1".to_owned(), + task_id: request.task_id, + push_notification_config: PushNotificationConfig { + id: Some("cfg-1".to_owned()), + url: "https://example.com/push".to_owned(), + token: None, + authentication: None, + }, + tenant: request.tenant, + }], + next_page_token: String::new(), + }) + } + + async fn delete_task_push_notification_config( + &self, + _request: DeleteTaskPushNotificationConfigRequest, + ) -> Result<(), A2AError> { + Ok(()) + } + + async fn get_extended_agent_card( + &self, + _request: GetExtendedAgentCardRequest, + ) -> Result { + self.get_agent_card().await + } +} + +struct TestServer { + base_url: String, + handle: JoinHandle<()>, +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.handle.abort(); + } +} + +#[tokio::test] +async fn discovery_caches_agent_card_until_refresh() { + let card_hits = Arc::new(AtomicUsize::new(0)); + let server = spawn_server(ClientTestHandler { + card_hits: Arc::clone(&card_hits), + interfaces: vec![interface("/rpc", "JSONRPC")], + capabilities: capabilities(false, true), + }) + .await; + let discovery = AgentCardDiscovery::with_config(AgentCardDiscoveryConfig { + ttl: Duration::from_secs(60), + }); + + let first = discovery + .discover(&server.base_url) + .await + .expect("discovery should succeed"); + let second = discovery + .discover(&server.base_url) + .await + .expect("cached discovery should succeed"); + let refreshed = discovery + .refresh(&server.base_url) + .await + .expect("refresh should succeed"); + + assert_eq!(first.name, "Client Test Agent"); + assert_eq!(second.name, "Client Test Agent"); + assert_eq!(refreshed.name, "Client Test Agent"); + assert_eq!(card_hits.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn client_respects_server_interface_order() { + let server = spawn_server(ClientTestHandler { + card_hits: Arc::new(AtomicUsize::new(0)), + interfaces: vec![ + interface("/", "HTTP+JSON"), + interface("/bad-rpc", "JSONRPC"), + ], + capabilities: capabilities(false, true), + }) + .await; + let client = A2AClient::new(&server.base_url).expect("client should build"); + + let response = client + .send_message(user_message_request(None)) + .await + .expect("first supported interface should succeed"); + + match response { + SendMessageResponse::Message(message) => { + assert_eq!(message.message_id, "msg-client-1"); + assert_eq!(message.parts[0].text.as_deref(), Some("pong")); + } + SendMessageResponse::Task(_) => panic!("expected message response"), + } +} + +#[tokio::test] +async fn client_falls_back_to_http_json_and_uses_tenant_paths() { + let server = spawn_server(ClientTestHandler { + card_hits: Arc::new(AtomicUsize::new(0)), + interfaces: vec![interface("/", "HTTP+JSON")], + capabilities: capabilities(false, true), + }) + .await; + let client = A2AClient::new(&server.base_url).expect("client should build"); + + let response = client + .send_message(user_message_request(Some("tenant-a".to_owned()))) + .await + .expect("rest fallback should succeed"); + let task = client + .get_task(GetTaskRequest { + id: "task-1".to_owned(), + history_length: None, + tenant: Some("tenant-a".to_owned()), + }) + .await + .expect("tenant task fetch should succeed"); + + match response { + SendMessageResponse::Message(message) => { + assert_eq!( + message + .metadata + .as_ref() + .and_then(|m| m.get("tenant")) + .and_then(|v| v.as_str()), + Some("tenant-a") + ); + } + SendMessageResponse::Task(_) => panic!("expected message response"), + } + assert_eq!( + task.metadata + .as_ref() + .and_then(|m| m.get("tenant")) + .and_then(|v| v.as_str()), + Some("tenant-a") + ); +} + +#[tokio::test] +async fn client_maps_jsonrpc_a2a_errors() { + let server = spawn_server(ClientTestHandler { + card_hits: Arc::new(AtomicUsize::new(0)), + interfaces: vec![interface("/rpc", "JSONRPC")], + capabilities: capabilities(false, true), + }) + .await; + let client = A2AClient::new(&server.base_url).expect("client should build"); + + let error = client + .get_task(GetTaskRequest { + id: "missing".to_owned(), + history_length: None, + tenant: None, + }) + .await + .expect_err("missing task should fail"); + + match error { + A2AError::TaskNotFound(task_id) => assert_eq!(task_id, "missing"), + other => panic!("expected task not found error, got {other}"), + } +} + +#[tokio::test] +async fn client_supports_unary_rest_and_jsonrpc_operations() { + let server = spawn_server(ClientTestHandler { + card_hits: Arc::new(AtomicUsize::new(0)), + interfaces: vec![interface("/rpc", "JSONRPC")], + capabilities: capabilities(false, true), + }) + .await; + let client = A2AClient::with_config( + &server.base_url, + A2AClientConfig { + discovery_ttl: Duration::from_secs(60), + extensions: vec!["streaming".to_owned()], + }, + ) + .expect("client should build"); + + let list = client + .list_tasks(ListTasksRequest::default()) + .await + .expect("list should succeed"); + let canceled = client + .cancel_task(CancelTaskRequest { + id: "task-1".to_owned(), + tenant: None, + }) + .await + .expect("cancel should succeed"); + let card = client + .get_extended_agent_card(GetExtendedAgentCardRequest { tenant: None }) + .await + .expect("extended card should succeed"); + + assert_eq!(list.tasks.len(), 1); + assert_eq!(canceled.status.state, TaskState::Canceled); + assert_eq!(card.name, "Client Test Agent"); +} + +#[tokio::test] +async fn client_streams_send_message_over_http_json_sse() { + let server = spawn_server(ClientTestHandler { + card_hits: Arc::new(AtomicUsize::new(0)), + interfaces: vec![interface("/", "HTTP+JSON")], + capabilities: capabilities(true, true), + }) + .await; + let client = A2AClient::new(&server.base_url).expect("client should build"); + + let mut stream = client + .send_streaming_message(user_message_request(Some("tenant-a".to_owned()))) + .await + .expect("streaming request should succeed"); + let first = stream + .next() + .await + .expect("stream should yield an event") + .expect("event should deserialize"); + + match first { + StreamResponse::Message(message) => { + assert_eq!(message.parts[0].text.as_deref(), Some("stream-pong")); + assert_eq!( + message + .metadata + .as_ref() + .and_then(|m| m.get("tenant")) + .and_then(|v| v.as_str()), + Some("tenant-a") + ); + } + other => panic!("expected message stream response, got {other:?}"), + } +} + +#[tokio::test] +async fn client_streams_subscribe_to_task_over_http_json_sse() { + let server = spawn_server(ClientTestHandler { + card_hits: Arc::new(AtomicUsize::new(0)), + interfaces: vec![interface("/", "HTTP+JSON")], + capabilities: capabilities(true, true), + }) + .await; + let client = A2AClient::new(&server.base_url).expect("client should build"); + + let stream = client + .subscribe_to_task(SubscribeToTaskRequest { + id: "task-1".to_owned(), + tenant: Some("tenant-a".to_owned()), + }) + .await + .expect("subscribe request should succeed"); + let events = stream + .collect::>>() + .await; + + assert_eq!(events.len(), 2); + match &events[0] { + Ok(StreamResponse::Task(task)) => { + assert_eq!(task.id, "task-1"); + assert_eq!( + task.metadata + .as_ref() + .and_then(|m| m.get("tenant")) + .and_then(|v| v.as_str()), + Some("tenant-a") + ); + } + other => panic!("expected task as first event, got {other:?}"), + } + match &events[1] { + Ok(StreamResponse::StatusUpdate(update)) => { + assert_eq!(update.task_id, "task-1"); + assert_eq!(update.status.state, TaskState::Completed); + } + other => panic!("expected status update as second event, got {other:?}"), + } +} + +#[tokio::test] +async fn client_supports_push_notification_config_operations() { + let server = spawn_server(ClientTestHandler { + card_hits: Arc::new(AtomicUsize::new(0)), + interfaces: vec![interface("/rpc", "JSONRPC")], + capabilities: capabilities(false, true), + }) + .await; + let client = A2AClient::new(&server.base_url).expect("client should build"); + + let created = client + .create_task_push_notification_config(CreateTaskPushNotificationConfigRequest { + task_id: "task-1".to_owned(), + config_id: "cfg-1".to_owned(), + config: PushNotificationConfig { + id: Some("cfg-1".to_owned()), + url: "https://example.com/push".to_owned(), + token: Some("secret".to_owned()), + authentication: None, + }, + tenant: Some("tenant-a".to_owned()), + }) + .await + .expect("create should succeed"); + let fetched = client + .get_task_push_notification_config(GetTaskPushNotificationConfigRequest { + id: "cfg-1".to_owned(), + task_id: "task-1".to_owned(), + tenant: Some("tenant-a".to_owned()), + }) + .await + .expect("get should succeed"); + let listed = client + .list_task_push_notification_config(ListTaskPushNotificationConfigRequest { + task_id: "task-1".to_owned(), + page_size: Some(10), + page_token: None, + tenant: Some("tenant-a".to_owned()), + }) + .await + .expect("list should succeed"); + client + .delete_task_push_notification_config(DeleteTaskPushNotificationConfigRequest { + id: "cfg-1".to_owned(), + task_id: "task-1".to_owned(), + tenant: Some("tenant-a".to_owned()), + }) + .await + .expect("delete should succeed"); + + assert_eq!(created.id, "cfg-1"); + assert_eq!(created.tenant.as_deref(), Some("tenant-a")); + assert_eq!( + fetched.push_notification_config.url, + "https://example.com/push" + ); + assert_eq!(listed.configs.len(), 1); +} + +fn interface(url: &str, protocol_binding: &str) -> AgentInterface { + AgentInterface { + url: url.to_owned(), + protocol_binding: protocol_binding.to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + } +} + +fn capabilities(streaming: bool, push_notifications: bool) -> AgentCapabilities { + AgentCapabilities { + streaming: Some(streaming), + push_notifications: Some(push_notifications), + extensions: Vec::new(), + extended_agent_card: Some(true), + } +} + +fn tenant_metadata(tenant: Option) -> Option> { + tenant.map(|tenant| { + let mut metadata = serde_json::Map::new(); + metadata.insert("tenant".to_owned(), serde_json::Value::String(tenant)); + metadata + }) +} + +fn user_message_request(tenant: Option) -> SendMessageRequest { + SendMessageRequest { + message: Message { + message_id: "msg-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: None, + role: Role::User, + parts: vec![Part { + text: Some("ping".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }, + configuration: None, + metadata: None, + tenant, + } +} + +async fn spawn_server(handler: H) -> TestServer +where + H: A2AHandler, +{ + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let handle = tokio::spawn(async move { + axum::serve(listener, router(handler)) + .await + .expect("server should run"); + }); + + TestServer { + base_url: format!("http://{}", address), + handle, + } +} diff --git a/tests/client_wiremock.rs b/tests/client_wiremock.rs new file mode 100644 index 0000000..c6b4273 --- /dev/null +++ b/tests/client_wiremock.rs @@ -0,0 +1,421 @@ +#![cfg(feature = "client")] + +use std::collections::BTreeMap; +use std::time::Duration; + +use futures_util::StreamExt; +use serde_json::json; +use tokio::time::sleep; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +use a2a_rust::A2AError; +use a2a_rust::client::{A2AClient, AgentCardDiscovery, AgentCardDiscoveryConfig}; +use a2a_rust::types::{ + AgentCapabilities, AgentCard, AgentInterface, Message, Part, Role, SendMessageRequest, + SendMessageResponse, StreamResponse, SubscribeToTaskRequest, Task, TaskState, TaskStatus, + TaskStatusUpdateEvent, +}; + +#[tokio::test] +async fn discovery_refetches_after_ttl_expiry() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/.well-known/agent-card.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(agent_card( + vec![interface("/rpc", "JSONRPC")], + capabilities(false, false), + ))) + .mount(&server) + .await; + + let discovery = AgentCardDiscovery::with_config(AgentCardDiscoveryConfig { + ttl: Duration::from_millis(20), + }); + + discovery + .discover(&server.uri()) + .await + .expect("first discovery should succeed"); + discovery + .discover(&server.uri()) + .await + .expect("cached discovery should succeed"); + sleep(Duration::from_millis(30)).await; + discovery + .discover(&server.uri()) + .await + .expect("discovery after ttl should refetch"); + + let requests = server + .received_requests() + .await + .expect("received requests should be available"); + let discovery_hits = requests + .iter() + .filter(|request| request.url.path() == "/.well-known/agent-card.json") + .count(); + + assert_eq!(discovery_hits, 2); +} + +#[tokio::test] +async fn client_uses_first_supported_interface_from_agent_card() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/.well-known/agent-card.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(agent_card( + vec![interface("/", "HTTP+JSON"), interface("/rpc", "JSONRPC")], + capabilities(false, false), + ))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/message:send")) + .respond_with( + ResponseTemplate::new(200).set_body_json(send_message_response("rest-first", None)), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let client = A2AClient::new(&server.uri()).expect("client should build"); + let response = client + .send_message(user_message_request(None)) + .await + .expect("rest-first transport should succeed"); + + match response { + SendMessageResponse::Message(message) => { + assert_eq!(message.parts[0].text.as_deref(), Some("rest-first")); + } + SendMessageResponse::Task(_) => panic!("expected message response"), + } + + let requests = server + .received_requests() + .await + .expect("received requests should be available"); + assert!( + requests + .iter() + .any(|request| request.url.path() == "/message:send"), + "rest endpoint should have been called first" + ); + assert!( + !requests.iter().any(|request| request.url.path() == "/rpc"), + "jsonrpc endpoint should not be used when HTTP+JSON is listed first" + ); +} + +#[tokio::test] +async fn client_rejects_jsonrpc_response_with_mismatched_id() { + let server = MockServer::start().await; + mount_jsonrpc_discovery(&server).await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(JsonRpcEnvelopeResponder { + jsonrpc: "2.0", + mismatched_id: true, + }) + .mount(&server) + .await; + + let client = A2AClient::new(&server.uri()).expect("client should build"); + let error = client + .send_message(user_message_request(None)) + .await + .expect_err("mismatched response ids should fail"); + + match error { + A2AError::InvalidAgentResponse(detail) => { + assert!(detail.contains("response id did not match request id")); + } + other => panic!("expected invalid agent response, got {other}"), + } +} + +#[tokio::test] +async fn client_rejects_jsonrpc_response_with_invalid_version() { + let server = MockServer::start().await; + mount_jsonrpc_discovery(&server).await; + Mock::given(method("POST")) + .and(path("/rpc")) + .respond_with(JsonRpcEnvelopeResponder { + jsonrpc: "1.0", + mismatched_id: false, + }) + .mount(&server) + .await; + + let client = A2AClient::new(&server.uri()).expect("client should build"); + let error = client + .send_message(user_message_request(None)) + .await + .expect_err("invalid jsonrpc versions should fail"); + + match error { + A2AError::InvalidAgentResponse(detail) => { + assert!(detail.contains("jsonrpc must be \"2.0\"")); + } + other => panic!("expected invalid agent response, got {other}"), + } +} + +#[tokio::test] +async fn client_parses_sse_streams_with_lf_frame_delimiters() { + let server = MockServer::start().await; + mount_http_json_discovery(&server, true).await; + Mock::given(method("POST")) + .and(path("/message:stream")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + sse_body( + "\n\n", + &[StreamResponse::Message(agent_message_response( + "lf-stream", + None, + ))], + ), + "text/event-stream", + )) + .mount(&server) + .await; + + let client = A2AClient::new(&server.uri()).expect("client should build"); + let items = client + .send_streaming_message(user_message_request(None)) + .await + .expect("stream should succeed") + .collect::>() + .await; + + assert_eq!(items.len(), 1); + match &items[0] { + Ok(StreamResponse::Message(message)) => { + assert_eq!(message.parts[0].text.as_deref(), Some("lf-stream")); + } + other => panic!("expected message stream response, got {other:?}"), + } +} + +#[tokio::test] +async fn client_parses_sse_streams_with_crlf_frame_delimiters() { + let server = MockServer::start().await; + mount_http_json_discovery(&server, true).await; + Mock::given(method("GET")) + .and(path("/tasks/task-1:subscribe")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + sse_body( + "\r\n\r\n", + &[ + StreamResponse::Task(task("task-1")), + StreamResponse::StatusUpdate(TaskStatusUpdateEvent { + task_id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Completed, + message: None, + timestamp: Some("2026-03-13T08:31:00Z".to_owned()), + }, + metadata: None, + }), + ], + ), + "text/event-stream", + )) + .mount(&server) + .await; + + let client = A2AClient::new(&server.uri()).expect("client should build"); + let items = client + .subscribe_to_task(SubscribeToTaskRequest { + id: "task-1".to_owned(), + tenant: None, + }) + .await + .expect("subscribe stream should succeed") + .collect::>() + .await; + + assert_eq!(items.len(), 2); + match &items[0] { + Ok(StreamResponse::Task(task)) => assert_eq!(task.id, "task-1"), + other => panic!("expected task event first, got {other:?}"), + } + match &items[1] { + Ok(StreamResponse::StatusUpdate(update)) => { + assert_eq!(update.status.state, TaskState::Completed); + } + other => panic!("expected status update second, got {other:?}"), + } +} + +async fn mount_jsonrpc_discovery(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/.well-known/agent-card.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(agent_card( + vec![interface("/rpc", "JSONRPC")], + capabilities(false, false), + ))) + .mount(server) + .await; +} + +async fn mount_http_json_discovery(server: &MockServer, streaming: bool) { + Mock::given(method("GET")) + .and(path("/.well-known/agent-card.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(agent_card( + vec![interface("/", "HTTP+JSON")], + capabilities(streaming, false), + ))) + .mount(server) + .await; +} + +fn agent_card(interfaces: Vec, capabilities: AgentCapabilities) -> AgentCard { + AgentCard { + name: "Wiremock Agent".to_owned(), + description: "Client wiremock test agent".to_owned(), + supported_interfaces: interfaces, + provider: None, + version: "0.1.0".to_owned(), + documentation_url: None, + capabilities, + security_schemes: BTreeMap::new(), + security_requirements: Vec::new(), + default_input_modes: vec!["text/plain".to_owned()], + default_output_modes: vec!["text/plain".to_owned()], + skills: Vec::new(), + signatures: Vec::new(), + icon_url: None, + } +} + +fn interface(url: &str, protocol_binding: &str) -> AgentInterface { + AgentInterface { + url: url.to_owned(), + protocol_binding: protocol_binding.to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + } +} + +fn capabilities(streaming: bool, push_notifications: bool) -> AgentCapabilities { + AgentCapabilities { + streaming: Some(streaming), + push_notifications: Some(push_notifications), + extensions: Vec::new(), + extended_agent_card: Some(true), + } +} + +fn user_message_request(tenant: Option) -> SendMessageRequest { + SendMessageRequest { + message: Message { + message_id: "msg-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: None, + role: Role::User, + parts: vec![Part { + text: Some("ping".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }, + configuration: None, + metadata: None, + tenant, + } +} + +fn agent_message_response(text: &str, tenant: Option) -> Message { + Message { + message_id: "msg-agent-1".to_owned(), + context_id: Some("ctx-1".to_owned()), + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some(text.to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: tenant.map(|tenant| { + let mut metadata = serde_json::Map::new(); + metadata.insert("tenant".to_owned(), serde_json::Value::String(tenant)); + metadata + }), + extensions: Vec::new(), + reference_task_ids: Vec::new(), + } +} + +fn send_message_response(text: &str, tenant: Option) -> SendMessageResponse { + SendMessageResponse::Message(agent_message_response(text, tenant)) +} + +fn task(id: &str) -> Task { + Task { + id: id.to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some("2026-03-13T08:30:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + } +} + +fn sse_body(delimiter: &str, items: &[StreamResponse]) -> String { + items + .iter() + .map(|item| { + let payload = serde_json::to_string(item).expect("stream item should serialize"); + format!("data: {payload}{delimiter}") + }) + .collect::() +} + +struct JsonRpcEnvelopeResponder { + jsonrpc: &'static str, + mismatched_id: bool, +} + +impl Respond for JsonRpcEnvelopeResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let request_body: serde_json::Value = + serde_json::from_slice(&request.body).expect("request body should be valid json"); + let id = if self.mismatched_id { + json!("wrong-id") + } else { + request_body + .get("id") + .cloned() + .unwrap_or(serde_json::Value::Null) + }; + + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": self.jsonrpc, + "result": serde_json::to_value(send_message_response("rpc", None)) + .expect("response should serialize"), + "id": id, + })) + } +} diff --git a/tests/server_integration.rs b/tests/server_integration.rs new file mode 100644 index 0000000..dd03bab --- /dev/null +++ b/tests/server_integration.rs @@ -0,0 +1,1047 @@ +#![cfg(feature = "server")] + +use async_trait::async_trait; +use futures_util::stream; + +use a2a_rust::A2AError; +use a2a_rust::server::{A2AHandler, A2AStream, router}; +use a2a_rust::types::{ + AgentCapabilities, AgentCard, AgentInterface, GetTaskRequest, ListTasksRequest, + ListTasksResponse, Message, Part, Role, SendMessageRequest, SendMessageResponse, + StreamResponse, Task, TaskState, TaskStatus, TaskStatusUpdateEvent, +}; +use axum::body::{Body, to_bytes}; +use http::{Request, StatusCode}; +use tower::util::ServiceExt; + +#[derive(Clone)] +struct TestHandler; + +#[derive(Clone)] +struct StreamingHandler; + +#[derive(Clone)] +struct TenantEchoHandler; + +fn tenant_metadata(tenant: Option) -> Option> { + tenant.map(|tenant| { + let mut metadata = serde_json::Map::new(); + metadata.insert("tenant".to_owned(), serde_json::Value::String(tenant)); + metadata + }) +} + +#[async_trait] +impl A2AHandler for TestHandler { + async fn get_agent_card(&self) -> Result { + Ok(AgentCard { + name: "Test Agent".to_owned(), + description: "Integration test agent".to_owned(), + supported_interfaces: vec![AgentInterface { + url: "https://example.com/rpc".to_owned(), + protocol_binding: "JSONRPC".to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + }], + provider: None, + version: "0.1.0".to_owned(), + documentation_url: None, + capabilities: AgentCapabilities::default(), + security_schemes: Default::default(), + security_requirements: Vec::new(), + default_input_modes: vec!["text/plain".to_owned()], + default_output_modes: vec!["text/plain".to_owned()], + skills: Vec::new(), + signatures: Vec::new(), + icon_url: None, + }) + } + + async fn send_message( + &self, + request: SendMessageRequest, + ) -> Result { + Ok(SendMessageResponse::Message(Message { + message_id: "msg-2".to_owned(), + context_id: request.message.context_id.clone(), + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some("pong".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + })) + } + + async fn get_task(&self, request: GetTaskRequest) -> Result { + Ok(Task { + id: request.id, + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }) + } + + async fn list_tasks(&self, _request: ListTasksRequest) -> Result { + Ok(ListTasksResponse { + tasks: vec![Task { + id: "task-1".to_owned(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Submitted, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }], + next_page_token: String::new(), + page_size: 1, + total_size: 1, + }) + } +} + +#[async_trait] +impl A2AHandler for StreamingHandler { + async fn get_agent_card(&self) -> Result { + Ok(AgentCard { + name: "Streaming Agent".to_owned(), + description: "Streaming integration test agent".to_owned(), + supported_interfaces: vec![AgentInterface { + url: "https://example.com/rpc".to_owned(), + protocol_binding: "JSONRPC".to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + }], + provider: None, + version: "0.1.0".to_owned(), + documentation_url: None, + capabilities: AgentCapabilities { + streaming: Some(true), + push_notifications: Some(false), + extensions: Vec::new(), + extended_agent_card: Some(false), + }, + security_schemes: Default::default(), + security_requirements: Vec::new(), + default_input_modes: vec!["text/plain".to_owned()], + default_output_modes: vec!["text/plain".to_owned()], + skills: Vec::new(), + signatures: Vec::new(), + icon_url: None, + }) + } + + async fn send_message( + &self, + request: SendMessageRequest, + ) -> Result { + TestHandler.send_message(request).await + } + + async fn send_streaming_message( + &self, + request: SendMessageRequest, + ) -> Result { + Ok(Box::pin(stream::iter(vec![StreamResponse::Message( + Message { + message_id: "msg-stream-1".to_owned(), + context_id: request.message.context_id, + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some("stream-pong".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: None, + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }, + )]))) + } + + async fn subscribe_to_task( + &self, + request: a2a_rust::types::SubscribeToTaskRequest, + ) -> Result { + Ok(Box::pin(stream::iter(vec![ + StreamResponse::Task(Task { + id: request.id.clone(), + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: None, + }), + StreamResponse::StatusUpdate(TaskStatusUpdateEvent { + task_id: request.id, + context_id: "ctx-1".to_owned(), + status: TaskStatus { + state: TaskState::Completed, + message: None, + timestamp: Some("2026-03-12T12:01:00Z".to_owned()), + }, + metadata: None, + }), + ]))) + } +} + +#[async_trait] +impl A2AHandler for TenantEchoHandler { + async fn get_agent_card(&self) -> Result { + Ok(AgentCard { + name: "Tenant Agent".to_owned(), + description: "Tenant integration test agent".to_owned(), + supported_interfaces: vec![AgentInterface { + url: "https://example.com/rpc".to_owned(), + protocol_binding: "JSONRPC".to_owned(), + tenant: None, + protocol_version: "1.0".to_owned(), + }], + provider: None, + version: "0.1.0".to_owned(), + documentation_url: None, + capabilities: AgentCapabilities { + streaming: Some(true), + push_notifications: Some(true), + extensions: Vec::new(), + extended_agent_card: Some(true), + }, + security_schemes: Default::default(), + security_requirements: Vec::new(), + default_input_modes: vec!["text/plain".to_owned()], + default_output_modes: vec!["text/plain".to_owned()], + skills: Vec::new(), + signatures: Vec::new(), + icon_url: None, + }) + } + + async fn send_message( + &self, + request: SendMessageRequest, + ) -> Result { + Ok(SendMessageResponse::Message(Message { + message_id: "tenant-msg-1".to_owned(), + context_id: request.message.context_id, + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some("tenant-pong".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: tenant_metadata(request.tenant), + extensions: Vec::new(), + reference_task_ids: Vec::new(), + })) + } + + async fn send_streaming_message( + &self, + request: SendMessageRequest, + ) -> Result { + Ok(Box::pin(stream::iter(vec![StreamResponse::Message( + Message { + message_id: "tenant-stream-1".to_owned(), + context_id: request.message.context_id, + task_id: None, + role: Role::Agent, + parts: vec![Part { + text: Some("tenant-stream".to_owned()), + raw: None, + url: None, + data: None, + metadata: None, + filename: None, + media_type: None, + }], + metadata: tenant_metadata(request.tenant), + extensions: Vec::new(), + reference_task_ids: Vec::new(), + }, + )]))) + } + + async fn get_task(&self, request: GetTaskRequest) -> Result { + Ok(Task { + id: request.id, + context_id: "ctx-tenant".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: tenant_metadata(request.tenant), + }) + } + + async fn list_tasks(&self, request: ListTasksRequest) -> Result { + Ok(ListTasksResponse { + tasks: vec![Task { + id: "tenant-task-1".to_owned(), + context_id: "ctx-tenant".to_owned(), + status: TaskStatus { + state: TaskState::Submitted, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: tenant_metadata(request.tenant), + }], + next_page_token: String::new(), + page_size: 1, + total_size: 1, + }) + } + + async fn subscribe_to_task( + &self, + request: a2a_rust::types::SubscribeToTaskRequest, + ) -> Result { + Ok(Box::pin(stream::iter(vec![StreamResponse::Task(Task { + id: request.id, + context_id: "ctx-tenant".to_owned(), + status: TaskStatus { + state: TaskState::Working, + message: None, + timestamp: Some("2026-03-12T12:00:00Z".to_owned()), + }, + artifacts: Vec::new(), + history: Vec::new(), + metadata: tenant_metadata(request.tenant), + })]))) + } + + async fn list_task_push_notification_config( + &self, + request: a2a_rust::types::ListTaskPushNotificationConfigRequest, + ) -> Result { + Ok(a2a_rust::types::ListTaskPushNotificationConfigResponse { + configs: vec![a2a_rust::types::TaskPushNotificationConfig { + id: "cfg-1".to_owned(), + task_id: request.task_id, + push_notification_config: a2a_rust::types::PushNotificationConfig { + id: Some("cfg-1".to_owned()), + url: "https://example.com/push".to_owned(), + token: None, + authentication: None, + }, + tenant: request.tenant, + }], + next_page_token: String::new(), + }) + } + + async fn get_task_push_notification_config( + &self, + request: a2a_rust::types::GetTaskPushNotificationConfigRequest, + ) -> Result { + Ok(a2a_rust::types::TaskPushNotificationConfig { + id: request.id, + task_id: request.task_id, + push_notification_config: a2a_rust::types::PushNotificationConfig { + id: Some("cfg-1".to_owned()), + url: "https://example.com/push".to_owned(), + token: None, + authentication: None, + }, + tenant: request.tenant, + }) + } + + async fn delete_task_push_notification_config( + &self, + _request: a2a_rust::types::DeleteTaskPushNotificationConfigRequest, + ) -> Result<(), A2AError> { + Ok(()) + } +} + +#[tokio::test] +async fn well_known_endpoint_serves_agent_card() { + let response = router(TestHandler) + .oneshot( + Request::builder() + .uri("/.well-known/agent-card.json") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn rest_send_message_returns_protocol_shape() { + let body = serde_json::json!({ + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "ping"}] + } + }); + + let response = router(TestHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/message:send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["message"]["role"], "ROLE_AGENT"); + assert_eq!(json["message"]["parts"][0]["text"], "pong"); +} + +#[tokio::test] +async fn tenant_message_route_uses_path_tenant() { + let body = serde_json::json!({ + "tenant": "wrong-tenant", + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "ping"}] + } + }); + + let response = router(TenantEchoHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/tenant-a/message:send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["message"]["metadata"]["tenant"], "tenant-a"); +} + +#[tokio::test] +async fn jsonrpc_send_message_dispatches_pascal_case_method() { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": "req-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "ping"}] + } + } + }); + + let response = router(TestHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/rpc") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["id"], "req-1"); + assert_eq!(json["result"]["message"]["parts"][0]["text"], "pong"); +} + +#[tokio::test] +async fn jsonrpc_unknown_method_returns_jsonrpc_error() { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "UnknownMethod", + "params": {} + }); + + let response = router(TestHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/rpc") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32601); + assert_eq!(json["id"], 7); +} + +#[tokio::test] +async fn jsonrpc_parse_error_returns_http_200() { + let response = router(TestHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/rpc") + .header("content-type", "application/json") + .body(Body::from("{")) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32700); + assert_eq!(json["id"], serde_json::Value::Null); +} + +#[tokio::test] +async fn jsonrpc_invalid_version_returns_http_200() { + let body = serde_json::json!({ + "jsonrpc": "1.0", + "id": "req-invalid", + "method": "ListTasks", + "params": {} + }); + + let response = router(TestHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/rpc") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32600); + assert_eq!(json["id"], "req-invalid"); +} + +#[tokio::test] +async fn jsonrpc_list_tasks_allows_missing_params_object() { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": "req-2", + "method": "ListTasks" + }); + + let response = router(TestHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/rpc") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["id"], "req-2"); + assert_eq!(json["result"]["tasks"][0]["id"], "task-1"); +} + +#[tokio::test] +async fn tenant_list_tasks_route_uses_path_tenant() { + let response = router(TenantEchoHandler) + .oneshot( + Request::builder() + .uri("/tenant-b/tasks?tenant=wrong-tenant") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["tasks"][0]["metadata"]["tenant"], "tenant-b"); +} + +#[tokio::test] +async fn tenant_get_task_route_uses_path_tenant() { + let response = router(TenantEchoHandler) + .oneshot( + Request::builder() + .uri("/tenant-b/tasks/task-1?tenant=wrong-tenant") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["metadata"]["tenant"], "tenant-b"); +} + +#[tokio::test] +async fn non_tenant_list_tasks_rejects_query_tenant() { + let response = router(TestHandler) + .oneshot( + Request::builder() + .uri("/tasks?tenant=tenant-a") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32600); +} + +#[tokio::test] +async fn jsonrpc_get_extended_agent_card_allows_missing_params_object() { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": "req-3", + "method": "GetExtendedAgentCard" + }); + + let response = router(TestHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/rpc") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["id"], "req-3"); + assert_eq!(json["error"]["code"], -32007); +} + +#[tokio::test] +async fn rest_get_extended_agent_card_returns_default_error() { + let response = router(TestHandler) + .oneshot( + Request::builder() + .uri("/extendedAgentCard") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32007); +} + +#[tokio::test] +async fn get_cancel_path_returns_not_found() { + let response = router(TestHandler) + .oneshot( + Request::builder() + .uri("/tasks/task-1:cancel") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32601); +} + +#[tokio::test] +async fn streaming_route_returns_unsupported_when_capability_is_disabled() { + let body = serde_json::json!({ + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "ping"}] + } + }); + + let response = router(TestHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/message:stream") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32004); +} + +#[tokio::test] +async fn subscribe_route_returns_unsupported_when_capability_is_disabled() { + let response = router(TestHandler) + .oneshot( + Request::builder() + .uri("/tasks/task-1:subscribe") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32004); +} + +#[tokio::test] +async fn push_config_route_returns_not_supported_when_capability_is_disabled() { + let response = router(TestHandler) + .oneshot( + Request::builder() + .uri("/tasks/task-1/pushNotificationConfigs") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32003); +} + +#[tokio::test] +async fn jsonrpc_push_config_returns_not_supported_when_capability_is_disabled() { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": "req-4", + "method": "ListTaskPushNotificationConfig", + "params": { + "taskId": "task-1" + } + }); + + let response = router(TestHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/rpc") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32003); +} + +#[tokio::test] +async fn jsonrpc_delete_push_config_returns_empty_object_result() { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": "req-5", + "method": "DeleteTaskPushNotificationConfig", + "params": { + "taskId": "task-1", + "id": "cfg-1" + } + }); + + let response = router(TenantEchoHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/rpc") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["id"], "req-5"); + assert_eq!(json["result"], serde_json::json!({})); +} + +#[tokio::test] +async fn streaming_route_uses_sse_framing() { + let body = serde_json::json!({ + "message": { + "messageId": "msg-1", + "contextId": "ctx-1", + "role": "ROLE_USER", + "parts": [{"text": "ping"}] + } + }); + + let response = router(StreamingHandler) + .oneshot( + Request::builder() + .method("POST") + .uri("/message:stream") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let body = String::from_utf8(bytes.to_vec()).expect("body should be utf8"); + assert!(body.starts_with("data: ")); + assert!(body.contains("\"message\":{\"messageId\":\"msg-stream-1\"")); + assert!(body.ends_with("\n\n")); +} + +#[tokio::test] +async fn subscribe_route_streams_current_task_first() { + let response = router(StreamingHandler) + .oneshot( + Request::builder() + .uri("/tasks/task-1:subscribe") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let body = String::from_utf8(bytes.to_vec()).expect("body should be utf8"); + let frames = body + .trim_end() + .split("\n\n") + .map(str::to_owned) + .collect::>(); + + assert_eq!(frames.len(), 2); + assert!(frames[0].contains("\"task\":{\"id\":\"task-1\"")); + assert!(frames[1].contains("\"statusUpdate\":{\"taskId\":\"task-1\"")); +} + +#[tokio::test] +async fn tenant_subscribe_route_uses_path_tenant() { + let response = router(TenantEchoHandler) + .oneshot( + Request::builder() + .uri("/tenant-c/tasks/task-1:subscribe?tenant=wrong-tenant") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let body = String::from_utf8(bytes.to_vec()).expect("body should be utf8"); + assert!(body.contains("\"metadata\":{\"tenant\":\"tenant-c\"}")); +} + +#[tokio::test] +async fn non_tenant_subscribe_rejects_query_tenant() { + let response = router(StreamingHandler) + .oneshot( + Request::builder() + .uri("/tasks/task-1:subscribe?tenant=tenant-a") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["error"]["code"], -32600); +} + +#[tokio::test] +async fn tenant_push_config_route_uses_path_tenant() { + let response = router(TenantEchoHandler) + .oneshot( + Request::builder() + .uri("/tenant-d/tasks/task-1/pushNotificationConfigs?tenant=wrong-tenant") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["configs"][0]["tenant"], "tenant-d"); +} + +#[tokio::test] +async fn tenant_get_push_config_route_uses_path_tenant() { + let response = router(TenantEchoHandler) + .oneshot( + Request::builder() + .uri("/tenant-d/tasks/task-1/pushNotificationConfigs/cfg-1?tenant=wrong-tenant") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("body should deserialize"); + assert_eq!(json["tenant"], "tenant-d"); +} diff --git a/tests/spec_examples.rs b/tests/spec_examples.rs new file mode 100644 index 0000000..8fa1760 --- /dev/null +++ b/tests/spec_examples.rs @@ -0,0 +1,205 @@ +use a2a_rust::types::{ + AgentCard, Role, SendMessageRequest, SendMessageResponse, StreamResponse, TaskState, +}; + +#[test] +fn agent_card_deserializes_spec_discovery_example() { + let json = serde_json::json!({ + "name": "Research Agent", + "description": "AI assistant specialized in academic and technical research with comprehensive source citation", + "supportedInterfaces": [ + { + "url": "https://research-agent.example.com/a2a/v1", + "protocolBinding": "HTTP+JSON", + "protocolVersion": "1.0" + } + ], + "version": "1.2.0", + "capabilities": { + "streaming": false, + "pushNotifications": false, + "extensions": [ + { + "uri": "https://example.com/extensions/citations/v1", + "description": "Returns citation metadata alongside answers", + "required": false + } + ] + }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { + "id": "academic-research", + "name": "Academic Research Assistant", + "description": "Provides research assistance with citations and source verification", + "tags": ["research", "citations", "academic"] + } + ] + }); + + let card: AgentCard = serde_json::from_value(json).expect("agent card should deserialize"); + let serialized = serde_json::to_value(&card).expect("agent card should serialize"); + + assert_eq!(card.name, "Research Agent"); + assert_eq!(card.supported_interfaces[0].protocol_binding, "HTTP+JSON"); + assert_eq!(card.skills[0].id, "academic-research"); + assert_eq!( + card.capabilities.extensions[0].uri, + "https://example.com/extensions/citations/v1" + ); + assert_eq!(serialized["name"], "Research Agent"); + assert_eq!( + serialized["supportedInterfaces"][0]["protocolBinding"], + "HTTP+JSON" + ); +} + +#[test] +fn send_message_request_deserializes_spec_booking_example() { + let json = serde_json::json!({ + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [ + { + "text": "Book me a flight from San Francisco to London next Friday." + } + ] + } + }); + + let request: SendMessageRequest = + serde_json::from_value(json).expect("request should deserialize"); + request.validate().expect("request should validate"); + let serialized = serde_json::to_value(&request).expect("request should serialize"); + + assert_eq!(request.message.message_id, "msg-1"); + assert!(matches!(request.message.role, Role::User)); + assert_eq!( + request.message.parts[0].text.as_deref(), + Some("Book me a flight from San Francisco to London next Friday.") + ); + assert_eq!(serialized["message"]["role"], "ROLE_USER"); + assert_eq!( + serialized["message"]["parts"][0]["text"], + "Book me a flight from San Francisco to London next Friday." + ); +} + +#[test] +fn send_message_response_deserializes_proto_first_input_required_example() { + let json = serde_json::json!({ + "task": { + "id": "task-123", + "contextId": "ctx-123", + "status": { + "state": "TASK_STATE_INPUT_REQUIRED", + "message": { + "messageId": "msg-2", + "contextId": "ctx-123", + "taskId": "task-123", + "role": "ROLE_AGENT", + "parts": [ + { + "text": "I need more details. Where would you like to fly from and to?" + } + ] + } + } + } + }); + + let response: SendMessageResponse = + serde_json::from_value(json).expect("response should deserialize"); + response.validate().expect("response should validate"); + let serialized = serde_json::to_value(&response).expect("response should serialize"); + + match response { + SendMessageResponse::Task(task) => { + assert_eq!(task.id, "task-123"); + assert_eq!(task.context_id, "ctx-123"); + assert_eq!(task.status.state, TaskState::InputRequired); + } + SendMessageResponse::Message(_) => panic!("expected task response"), + } + assert_eq!( + serialized["task"]["status"]["state"], + "TASK_STATE_INPUT_REQUIRED" + ); +} + +#[test] +fn stream_response_deserializes_status_update_example() { + let json = serde_json::json!({ + "statusUpdate": { + "taskId": "task-123", + "contextId": "ctx-123", + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "messageId": "msg-3", + "contextId": "ctx-123", + "taskId": "task-123", + "role": "ROLE_AGENT", + "parts": [ + { + "text": "Still searching for flights..." + } + ] + } + } + } + }); + + let response: StreamResponse = serde_json::from_value(json).expect("stream should deserialize"); + response.validate().expect("stream should validate"); + let serialized = serde_json::to_value(&response).expect("stream should serialize"); + + match response { + StreamResponse::StatusUpdate(update) => { + assert_eq!(update.task_id, "task-123"); + assert_eq!(update.context_id, "ctx-123"); + assert_eq!(update.status.state, TaskState::Working); + } + _ => panic!("expected status update"), + } + assert_eq!( + serialized["statusUpdate"]["status"]["state"], + "TASK_STATE_WORKING" + ); +} + +#[test] +fn stream_response_deserializes_artifact_update_example() { + let json = serde_json::json!({ + "artifactUpdate": { + "taskId": "task-123", + "contextId": "ctx-123", + "artifact": { + "artifactId": "artifact-1", + "parts": [ + { + "text": "Partial itinerary" + } + ] + }, + "append": true, + "lastChunk": false + } + }); + + let response: StreamResponse = serde_json::from_value(json).expect("stream should deserialize"); + response.validate().expect("stream should validate"); + let serialized = serde_json::to_value(&response).expect("stream should serialize"); + + match response { + StreamResponse::ArtifactUpdate(update) => { + assert_eq!(update.task_id, "task-123"); + assert!(update.append); + assert!(!update.last_chunk); + } + _ => panic!("expected artifact update"), + } + assert_eq!(serialized["artifactUpdate"]["append"], true); +}