Introduction
An AI agent rarely works alone today. A support agent asks a billing agent for a refund. A travel agent asks an airline's agent for seats. The Agent2Agent protocol (A2A) is the open standard for these calls: an agent publishes a card that says what it can do, and other agents send it messages.
This changes what a test has to check. An A2A agent is a server and a client at the same time:
- As a server, it must speak the protocol. Does it publish the right skills? Does a task end in the right state?
- As a client, it delegates work. Which agent did it ask? What exactly did it send?
- As a reader, it uses what other agents answer. What does it do when one of them is down, or when one of them lies?
A chat test sees none of this. It sees the final sentence.
tamesu (試す, Japanese for to try, to test) is an open-source CLI in Go for testing AI agents. Its a2a target tests an A2A agent from both sides. In front of your agent, tamesu plays the user. Behind your agent, tamesu plays the other agents, records every message they receive, and can make them fail on purpose.
This is the third article about tamesu. Agentic Browser Testing with tamesu covers web apps. Testing AI Agents with tamesu covers agents behind a REST API, with personas, LLM judges and a red team. This one covers agents that talk to other agents.
Why A2A Agents Need Their Own Tests
| A chat test | tamesu with the a2a target | |
|---|---|---|
| What it talks to | A chat endpoint | The A2A endpoint: the card, the messages and the tasks |
| The protocol | Not checked | Task states and results, turn by turn |
| Delegations | Invisible | Every message to another agent is recorded and asserted |
| The other agents | All of them must be running | tamesu plays them with scripted replies |
| An agent is down | You find out in production | One line in the test: fuzz_tools: { billing: error } |
| An agent lies | Nobody tries | One line in the test: fuzz_tools: { shipping: hostile } |
| Two agents talking | Not possible | A second agent plays the user |
What You Will Build
You will test a concierge agent for Mochi Shoes, a small online shoe store. The agent is real: Genkit Go, Gemini and the official A2A SDK for Go. It answers customers, and it delegates to two other agents: a shipping agent and a billing agent.
You will:
- Build the concierge and serve it over A2A.
- Let tamesu play the shipping agent and the billing agent.
- Write eight tests: the agent card, the task lifecycle, delegations, an agent that fails, an agent that lies, and a second agent that plays the customer.
- Make a one-line prompt edit that looks harmless, and watch three checks catch it.
- Put tamesu in front of a real remote agent and record what your agent sends it.
What You Need
- tamesu 1.21.0 or newer:
brew install xavidop/tap/tamesuon macOS, orgo install github.com/xavidop/tamesu/cmd/tamesu@latest. - Go 1.27 or newer, for the agent.
- A Gemini API key in
GEMINI_API_KEY. The agent uses it, and so do tamesu's simulated user and judge.
A2A in One Minute
You need five words to read the rest of this guide:
- Agent card: a JSON document at
/.well-known/agent-card.json. It holds the agent's name, its URL and its skills (what it can do). - Message: one turn of the conversation. It is made of parts: text, structured data or files.
- Task: the unit of work. The first message opens a task, and the agent reports its state.
- State: where the task is.
submitted,working,input-required(the agent needs an answer from you) andcompleted. A task can also end asfailed,canceledorrejected. - Artifact: the result of a completed task.
Messages that belong together share a context id. In protocol 0.3, the version tamesu speaks, a client sends a message with message/send and gets the task back. With message/stream it gets the task's events while the agent works.
Step 1: Build the Agent Under Test
The concierge has two tools, and each tool is another agent:
-
ask_shippingsends a message to the shipping agent over A2A. -
ask_billingsends a message to the billing agent over A2A.
Note where the refund policy lives. The billing agent refunds what it is asked to refund. Only the concierge's prompt applies the 30-day rule. That is common in multi-agent systems, and it is why the concierge needs tests.
The code
Create a folder named concierge with this main.go:
// The Mochi Shoes concierge: an A2A agent built with Genkit Go. // It answers customers and delegates to two other A2A agents: shipping and billing. package main import ( "context" "fmt" "iter" "log" "net/http" "os" "os/signal" "strings" "sync" "syscall" "time" "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2aclient" "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/googlegenai" ) // answer is what the model returns on every turn. type answer struct { Reply string `json:"reply" jsonschema:"description=What the concierge says to the customer"` NeedsInput bool `json:"needs_input" jsonschema:"description=True when the reply asks the customer for something the concierge needs to go on"` } type concierge struct { g *genkit.Genkit tools []ai.ToolRef system string mu sync.Mutex sessions map[string][]*ai.Message // one conversation per A2A context } // Execute runs one turn: it is called for every message a client sends. func (c *concierge) Execute(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { return func(yield func(a2a.Event, error) bool) { if ec.StoredTask == nil { // a new task if !yield(a2a.NewSubmittedTask(ec, ec.Message), nil) { return } } if !yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateWorking, nil), nil) { return } out, err := c.answer(ctx, ec.ContextID, text(ec.Message)) if err != nil { log.Printf("turn failed: %v", err) sorry := a2a.NewMessageForTask(a2a.MessageRoleAgent, ec, a2a.NewTextPart("Sorry, something went wrong on our side.")) yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateFailed, sorry), nil) return } if out.NeedsInput { // the task stays open and waits for the customer ask := a2a.NewMessageForTask(a2a.MessageRoleAgent, ec, a2a.NewTextPart(out.Reply)) yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateInputRequired, ask), nil) return } if !yield(a2a.NewArtifactEvent(ec, a2a.NewTextPart(out.Reply)), nil) { return } yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateCompleted, nil), nil) } } func (c *concierge) Cancel(_ context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { return func(yield func(a2a.Event, error) bool) { yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateCanceled, nil), nil) } } // answer asks Gemini for the next turn, with the history of this context. func (c *concierge) answer(ctx context.Context, contextID, userText string) (*answer, error) { ctx, cancel := context.WithTimeout(ctx, 45*time.Second) // a model call that hangs must not hang the task defer cancel() c.mu.Lock() history := c.sessions[contextID] c.mu.Unlock() out, resp, err := genkit.GenerateData[answer](ctx, c.g, ai.WithSystem(c.system), ai.WithMessages(history...), ai.WithPrompt(userText), ai.WithTools(c.tools...), ai.WithMaxTurns(6), ai.WithCustomConstrainedOutput(), // ask for the JSON in the prompt: with native JSON mode and tools, Gemini keeps calling tools ) if err != nil { return nil, err } var next []*ai.Message for _, m := range resp.History() { if m.Role != ai.RoleSystem { next = append(next, m) } } c.mu.Lock() c.sessions[contextID] = next c.mu.Unlock() return out, nil } // resolver reads agent cards in the 1.0 format and in the 0.3 format. var resolver = &agentcard.Resolver{ Client: &http.Client{Timeout: 10 * time.Second}, CardParser: a2av0.NewAgentCardParser(), } // delegate sends one message to another A2A agent and returns its answer. func delegate(ctx context.Context, baseURL, message string) (string, error) { card, err := resolver.Resolve(ctx, baseURL) if err != nil { return "", fmt.Errorf("agent card: %w", err) } client, err := a2aclient.NewFromCard(ctx, card, a2av0.WithJSONRPCTransport(a2av0.JSONRPCTransportConfig{}), // also talk to agents on protocol 0.3 ) if err != nil { return "", err } defer client.Destroy() msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart(message)) res, err := client.SendMessage(ctx, &a2a.SendMessageRequest{Message: msg}) if err != nil { return "", err } switch r := res.(type) { case *a2a.Message: return text(r), nil case *a2a.Task: if r.Status.State == a2a.TaskStateFailed || r.Status.State == a2a.TaskStateRejected { return "", fmt.Errorf("the agent could not do it (task %s)", r.Status.State) } var b strings.Builder b.WriteString(text(r.Status.Message)) for _, art := range r.Artifacts { for _, p := range art.Parts { b.WriteString(p.Text()) } } return b.String(), nil } return "", fmt.Errorf("unexpected result %T", res) } func text(m *a2a.Message) string { if m == nil { return "" } var b strings.Builder for _, p := range m.Parts { b.WriteString(p.Text()) } return b.String() } func main() { ctx := context.Background() g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{}), genkit.WithDefaultModel("googleai/gemini-3.8-flash"), ) type ask struct { Message string `json:"message" jsonschema:"description=The full request in plain English, with the order id"` } remote := func(name, description, url string) ai.ToolRef { return genkit.DefineTool(g, name, description, func(tc *ai.ToolContext, in ask) (string, error) { log.Printf("%s <- %q", name, in.Message) reply, err := delegate(tc, url, in.Message) if err != nil { // the model sees the failure and decides what to tell the customer log.Printf("%s failed: %v", name, err) return "ERROR: the agent is not available right now: " + err.Error(), nil } log.Printf("%s -> %q", name, reply) return reply, nil }) } prompt, err := os.ReadFile(envOr("AGENT_PROMPT", "prompt.md")) if err != nil { log.Fatal(err) } agent := &concierge{ g: g, system: string(prompt), sessions: map[string][]*ai.Message{}, tools: []ai.ToolRef{ remote("ask_shipping", "Ask the shipping agent where an order is and when it was or will be delivered.", envOr("SHIPPING_AGENT_URL", "http://127.0.0.1:8096")), remote("ask_billing", "Ask the billing agent to refund an order or to look up an invoice.", envOr("BILLING_AGENT_URL", "http://127.0.0.1:8097")), }, } addr := envOr("ADDR", "127.0.0.1:8100") card := &a2a.AgentCard{ Name: "Mochi Shoes concierge", Description: "Order tracking, returns and refunds for the Mochi Shoes store.", Version: "1.0.0", // Two interfaces: protocol 1.0 at /a2a and protocol 0.3 at /a2a/v0. SupportedInterfaces: []*a2a.AgentInterface{ a2a.NewAgentInterface("http://"+addr+"/a2a", a2a.TransportProtocolJSONRPC), {URL: "http://" + addr + "/a2a/v0", ProtocolBinding: a2a.TransportProtocolJSONRPC, ProtocolVersion: a2av0.Version}, }, DefaultInputModes: []string{"text/plain"}, DefaultOutputModes: []string{"text/plain"}, Capabilities: a2a.AgentCapabilities{Streaming: true}, Skills: []a2a.AgentSkill{ {ID: "order-status", Name: "Order status", Description: "Where an order is and when it arrives", Tags: []string{"orders"}, Examples: []string{"Where is order ORD-1001?"}}, {ID: "returns", Name: "Returns and refunds", Description: "The returns policy, and refunds inside the return window", Tags: []string{"returns"}, Examples: []string{"I want to return order ORD-1001"}}, }, } handler := a2asrv.NewHandler(agent) mux := http.NewServeMux() mux.Handle("/a2a", a2asrv.NewJSONRPCHandler(handler)) mux.Handle("/a2a/v0", a2av0.NewJSONRPCHandler(handler)) mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewAgentCardHandler(a2av0.NewStaticAgentCardProducer(card))) // Genkit installs its own signal handler, so stop the server ourselves on Ctrl-C. srv := &http.Server{Addr: addr, Handler: mux} sig, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop(...

