Testing Google's Agent-to-Agent (A2A) Protocol

Testing Google's Agent-to-Agent (A2A) Protocol

Google's Agent-to-Agent (A2A) protocol is an open standard that lets AI agents discover each other, delegate tasks, and exchange results — regardless of the underlying AI framework or vendor. It defines how agents expose capabilities through an Agent Card, communicate via task requests, and return streaming or synchronous results.

If you're building A2A-compatible agents or orchestrating multi-agent workflows using A2A, your test suite needs to cover the protocol layer in addition to your agent logic. This guide explains how.

What A2A Defines

A2A has three core testable surfaces:

  1. Agent Card (/.well-known/agent.json) — a JSON description of the agent's capabilities, supported input/output modes, and endpoint URLs
  2. Task API — POST /tasks/send to create a task, GET /tasks/{id} to poll, POST /tasks/send-subscribe for server-sent events streaming
  3. Artifacts — structured outputs the agent produces (files, JSON, text)

Your tests should verify that your agent's Agent Card is valid, that it handles task requests correctly, and that it returns properly formatted artifacts.

Setting Up a Test Agent

Here's a minimal A2A-compatible agent server in Node.js:

// src/agent/server.ts
import express from "express";
import { z } from "zod";

const app = express();
app.use(express.json());

// Agent Card
app.get("/.well-known/agent.json", (req, res) => {
  res.json({
    name: "Text Summarizer Agent",
    description: "Summarizes text documents",
    version: "1.0.0",
    capabilities: {
      streaming: true,
      pushNotifications: false,
    },
    defaultInputModes: ["text/plain"],
    defaultOutputModes: ["text/plain"],
    skills: [
      {
        id: "summarize",
        name: "Summarize Text",
        description: "Summarizes a document in 2-3 sentences",
        inputModes: ["text/plain"],
        outputModes: ["text/plain"],
      },
    ],
  });
});

// Task endpoint
const tasks = new Map<string, Task>();

app.post("/tasks/send", async (req, res) => {
  const { id, message } = req.body;
  
  // Validate input
  const text = message?.parts?.[0]?.text;
  if (!text) {
    return res.status(400).json({ error: "Missing text input" });
  }

  // Process (in real agent, this calls the LLM)
  const summary = await summarizeText(text);
  
  const task: Task = {
    id,
    status: { state: "completed" },
    artifacts: [
      {
        parts: [{ type: "text", text: summary }],
      },
    ],
  };
  
  tasks.set(id, task);
  res.json(task);
});

app.get("/tasks/:id", (req, res) => {
  const task = tasks.get(req.params.id);
  if (!task) return res.status(404).json({ error: "Task not found" });
  res.json(task);
});

export { app };

Testing the Agent Card

The Agent Card is the entry point for agent discovery. Test that it's valid and complete:

// src/agent/__tests__/agentCard.test.ts
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "../server";

const AgentCardSchema = {
  required: ["name", "description", "version", "capabilities", "skills"],
};

describe("Agent Card (/.well-known/agent.json)", () => {
  it("returns a valid agent card", async () => {
    const res = await request(app).get("/.well-known/agent.json");

    expect(res.status).toBe(200);
    expect(res.headers["content-type"]).toMatch(/json/);

    const card = res.body;
    expect(card.name).toBeTruthy();
    expect(card.description).toBeTruthy();
    expect(card.version).toMatch(/^\d+\.\d+\.\d+/);
  });

  it("declares at least one skill", async () => {
    const res = await request(app).get("/.well-known/agent.json");
    expect(res.body.skills).toBeInstanceOf(Array);
    expect(res.body.skills.length).toBeGreaterThan(0);
  });

  it("each skill has required fields", async () => {
    const res = await request(app).get("/.well-known/agent.json");
    for (const skill of res.body.skills) {
      expect(skill.id).toBeTruthy();
      expect(skill.name).toBeTruthy();
      expect(skill.description).toBeTruthy();
    }
  });

  it("declares input and output modes", async () => {
    const res = await request(app).get("/.well-known/agent.json");
    const card = res.body;
    expect(card.defaultInputModes).toBeInstanceOf(Array);
    expect(card.defaultOutputModes).toBeInstanceOf(Array);
  });

  it("streaming capability matches actual implementation", async () => {
    const card = (await request(app).get("/.well-known/agent.json")).body;

    if (card.capabilities.streaming) {
      // Verify the streaming endpoint exists
      // We'll test it in the streaming test suite
      expect(card.capabilities.streaming).toBe(true);
    }
  });
});

Testing Task Creation

The core of A2A is the task lifecycle. Test the happy path first:

// src/agent/__tests__/tasks.test.ts
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
import { app } from "../server";
import * as ai from "../ai"; // the actual LLM call

vi.mock("../ai", () => ({
  summarizeText: vi.fn().mockResolvedValue(
    "This text describes the fundamentals of testing. It covers unit tests and integration tests."
  ),
}));

describe("POST /tasks/send", () => {
  const validTaskRequest = {
    id: "task-001",
    message: {
      role: "user",
      parts: [
        {
          type: "text",
          text: "Unit testing involves testing individual components in isolation. Integration testing checks how components work together.",
        },
      ],
    },
  };

  it("returns a completed task with artifact", async () => {
    const res = await request(app)
      .post("/tasks/send")
      .send(validTaskRequest);

    expect(res.status).toBe(200);
    expect(res.body.id).toBe("task-001");
    expect(res.body.status.state).toBe("completed");
    expect(res.body.artifacts).toBeInstanceOf(Array);
    expect(res.body.artifacts.length).toBeGreaterThan(0);
  });

  it("artifact contains text output", async () => {
    const res = await request(app)
      .post("/tasks/send")
      .send(validTaskRequest);

    const artifact = res.body.artifacts[0];
    expect(artifact.parts).toBeInstanceOf(Array);

    const textPart = artifact.parts.find((p: any) => p.type === "text");
    expect(textPart).toBeDefined();
    expect(textPart.text).toBeTruthy();
    expect(typeof textPart.text).toBe("string");
  });

  it("calls the AI function with the input text", async () => {
    await request(app).post("/tasks/send").send(validTaskRequest);

    expect(ai.summarizeText).toHaveBeenCalledWith(
      "Unit testing involves testing individual components in isolation. Integration testing checks how components work together."
    );
  });

  it("returns 400 for missing text input", async () => {
    const res = await request(app)
      .post("/tasks/send")
      .send({
        id: "task-bad",
        message: { role: "user", parts: [] },
      });

    expect(res.status).toBe(400);
  });

  it("handles unique task IDs independently", async () => {
    await request(app)
      .post("/tasks/send")
      .send({ ...validTaskRequest, id: "task-A" });

    await request(app)
      .post("/tasks/send")
      .send({ ...validTaskRequest, id: "task-B" });

    const taskA = await request(app).get("/tasks/task-A");
    const taskB = await request(app).get("/tasks/task-B");

    expect(taskA.body.id).toBe("task-A");
    expect(taskB.body.id).toBe("task-B");
  });
});

Testing Task Polling

Long-running tasks use the polling pattern. Test the state transitions:

describe("GET /tasks/:id", () => {
  it("returns task status for existing task", async () => {
    // Create the task first
    await request(app)
      .post("/tasks/send")
      .send({ id: "poll-task-1", message: { role: "user", parts: [{ type: "text", text: "Hello" }] } });

    const res = await request(app).get("/tasks/poll-task-1");

    expect(res.status).toBe(200);
    expect(res.body.id).toBe("poll-task-1");
    expect(["submitted", "working", "completed", "failed"]).toContain(
      res.body.status.state
    );
  });

  it("returns 404 for unknown task", async () => {
    const res = await request(app).get("/tasks/nonexistent-999");
    expect(res.status).toBe(404);
  });
});

Testing Streaming with Server-Sent Events

A2A streaming uses SSE. Test the event stream:

// src/agent/__tests__/streaming.test.ts
import { describe, it, expect, vi } from "vitest";
import EventSource from "eventsource";
import { createServer } from "http";
import { app } from "../server";

vi.mock("../ai", () => ({
  summarizeTextStream: vi.fn(async function* () {
    yield "This text ";
    yield "covers testing. ";
    yield "It is comprehensive.";
  }),
}));

describe("POST /tasks/send-subscribe (streaming)", () => {
  let server: ReturnType<typeof createServer>;
  let baseUrl: string;

  beforeAll(
    () =>
      new Promise<void>((resolve) => {
        server = createServer(app).listen(0, () => {
          const port = (server.address() as { port: number }).port;
          baseUrl = `http://localhost:${port}`;
          resolve();
        });
      })
  );

  afterAll(() => server.close());

  it("streams task status updates via SSE", async () => {
    const events: any[] = [];

    await new Promise<void>((resolve, reject) => {
      const timeout = setTimeout(
        () => reject(new Error("Streaming test timed out")),
        10_000
      );

      // POST to get the SSE stream
      fetch(`${baseUrl}/tasks/send-subscribe`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          id: "stream-task-1",
          message: {
            role: "user",
            parts: [{ type: "text", text: "Summarize this long document..." }],
          },
        }),
      }).then((res) => {
        const reader = res.body!.getReader();
        const decoder = new TextDecoder();

        function read() {
          reader.read().then(({ done, value }) => {
            if (done) {
              clearTimeout(timeout);
              resolve();
              return;
            }

            const chunk = decoder.decode(value);
            const lines = chunk.split("\n");
            for (const line of lines) {
              if (line.startsWith("data: ")) {
                try {
                  events.push(JSON.parse(line.slice(6)));
                } catch {
                  // ignore parse errors on empty lines
                }
              }
            }
            read();
          });
        }

        read();
      });
    });

    // Should have received at least one working event and a final event
    const states = events.map((e) => e.status?.state).filter(Boolean);
    expect(states).toContain("completed");
  });
});

Testing an A2A Client

If you're building a client that calls other A2A agents, test the client's interaction with the protocol:

// src/client/a2aClient.ts
export class A2AClient {
  constructor(private agentUrl: string) {}

  async getCapabilities() {
    const res = await fetch(`${this.agentUrl}/.well-known/agent.json`);
    if (!res.ok) throw new Error(`Failed to get agent card: ${res.status}`);
    return res.json();
  }

  async sendTask(text: string): Promise<string> {
    const taskId = crypto.randomUUID();
    const res = await fetch(`${this.agentUrl}/tasks/send`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        id: taskId,
        message: {
          role: "user",
          parts: [{ type: "text", text }],
        },
      }),
    });

    if (!res.ok) throw new Error(`Task failed: ${res.status}`);

    const task = await res.json();
    const textPart = task.artifacts?.[0]?.parts?.find(
      (p: any) => p.type === "text"
    );
    return textPart?.text ?? "";
  }
}

Test the client:

// src/client/__tests__/a2aClient.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { A2AClient } from "../a2aClient";

global.fetch = vi.fn();

describe("A2AClient", () => {
  const client = new A2AClient("https://agent.example.com");

  beforeEach(() => vi.clearAllMocks());

  it("fetches the agent card from the well-known URL", async () => {
    vi.mocked(fetch).mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ name: "Test Agent", skills: [] }),
    } as Response);

    await client.getCapabilities();

    expect(fetch).toHaveBeenCalledWith(
      "https://agent.example.com/.well-known/agent.json"
    );
  });

  it("sends task and extracts text from artifact", async () => {
    vi.mocked(fetch).mockResolvedValue({
      ok: true,
      json: () =>
        Promise.resolve({
          id: "task-x",
          status: { state: "completed" },
          artifacts: [
            { parts: [{ type: "text", text: "Summary result here." }] },
          ],
        }),
    } as Response);

    const result = await client.sendTask("Long document...");

    expect(result).toBe("Summary result here.");
  });

  it("throws on non-200 task response", async () => {
    vi.mocked(fetch).mockResolvedValue({
      ok: false,
      status: 503,
    } as Response);

    await expect(client.sendTask("text")).rejects.toThrow("Task failed: 503");
  });
});

Testing Multi-Agent Orchestration

When your orchestrator delegates to multiple A2A agents, test the routing logic:

// src/orchestrator/__tests__/router.test.ts
import { describe, it, expect, vi } from "vitest";
import { AgentRouter } from "../router";

describe("AgentRouter", () => {
  it("routes summarization tasks to the summarizer agent", async () => {
    const mockSummarizerClient = { sendTask: vi.fn().mockResolvedValue("Summary") };
    const mockTranslatorClient = { sendTask: vi.fn().mockResolvedValue("Translation") };

    const router = new AgentRouter({
      summarizer: mockSummarizerClient,
      translator: mockTranslatorClient,
    });

    await router.route({ type: "summarize", text: "Long text..." });

    expect(mockSummarizerClient.sendTask).toHaveBeenCalled();
    expect(mockTranslatorClient.sendTask).not.toHaveBeenCalled();
  });

  it("routes translation tasks to the translator agent", async () => {
    const mockSummarizerClient = { sendTask: vi.fn() };
    const mockTranslatorClient = { sendTask: vi.fn().mockResolvedValue("Translated") };

    const router = new AgentRouter({
      summarizer: mockSummarizerClient,
      translator: mockTranslatorClient,
    });

    await router.route({ type: "translate", text: "Hello", targetLanguage: "es" });

    expect(mockTranslatorClient.sendTask).toHaveBeenCalled();
    expect(mockSummarizerClient.sendTask).not.toHaveBeenCalled();
  });
});

Key Things to Test in A2A Systems

  • Agent Card validity — every required field is present, skill IDs are unique, capability flags match actual behavior
  • Task creation — accepts valid input, rejects malformed requests, returns correct artifact structure
  • Task polling — 404 for unknown tasks, correct state in response
  • Streaming — SSE stream delivers status events and terminates cleanly
  • Client error handling — network errors, non-200 responses, malformed artifacts
  • Orchestration routing — tasks go to the right agent based on task type

A2A is a young protocol and tooling for testing it is still emerging. The approach above — test the HTTP layer with supertest, mock AI calls, and test client logic with mocked fetch — works today and will continue working as the ecosystem matures.

Read more

Start now free