feat: add E2E test for two Claude agents communicating via SynapBus

Python script using Anthropic SDK + SynapBus MCP Streamable HTTP.
Two AI agents (Alice, Bob) autonomously exchange messages through
SynapBus tools: discover_agents, send_message, read_inbox, mark_done.

Usage: ANTHROPIC_API_KEY=... python tests/e2e/test_two_agents.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-13 14:03:34 +02:00
co-authored by Claude Opus 4.6
parent 83f555f1e3
commit 9bc9a74177
+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
"""
E2E test: Two Claude-powered agents communicate through SynapBus MCP.
Prerequisites:
1. SynapBus running: ./synapbus serve --port 8080
2. ANTHROPIC_API_KEY set in environment
3. pip install anthropic httpx
Usage:
python tests/e2e/test_two_agents.py [--port 8080] [--model claude-sonnet-4-6]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Optional
import httpx
import anthropic
SYNAPBUS_URL = ""
MCP_URL = ""
# ---------------------------------------------------------------------------
# SynapBus MCP client (Streamable HTTP JSON-RPC)
# ---------------------------------------------------------------------------
class SynapBusMCP:
"""Thin MCP client for SynapBus Streamable HTTP transport."""
def __init__(self, base_url: str, api_key: Optional[str] = None):
self.url = f"{base_url}/mcp"
self.api_key = api_key
self.session_id: Optional[str] = None
self._req_id = 0
self._client = httpx.Client(timeout=30)
def _next_id(self) -> int:
self._req_id += 1
return self._req_id
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.api_key:
h["Authorization"] = f"Bearer {self.api_key}"
if self.session_id:
h["Mcp-Session-Id"] = self.session_id
return h
def _rpc(self, method: str, params: dict | None = None) -> dict:
body = {
"jsonrpc": "2.0",
"id": self._next_id(),
"method": method,
}
if params:
body["params"] = params
resp = self._client.post(self.url, json=body, headers=self._headers())
resp.raise_for_status()
# Capture session ID from initialize response
if sid := resp.headers.get("Mcp-Session-Id"):
self.session_id = sid
return resp.json()
def initialize(self) -> dict:
return self._rpc("initialize", {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "synapbus-e2e-test", "version": "1.0"},
})
def call_tool(self, name: str, arguments: dict) -> dict:
result = self._rpc("tools/call", {"name": name, "arguments": arguments})
if "error" in result:
return result
# Parse the text content from MCP result
content = result.get("result", {}).get("content", [])
for block in content:
if block.get("type") == "text":
return json.loads(block["text"])
return result
def list_tools(self) -> list[dict]:
result = self._rpc("tools/list")
return result.get("result", {}).get("tools", [])
def close(self):
self._client.close()
# ---------------------------------------------------------------------------
# Setup: register user + agents, get API keys
# ---------------------------------------------------------------------------
def setup_agents(base_url: str) -> tuple[str, str]:
"""Register admin user (if needed) and two agents. Returns (alice_key, bob_key)."""
client = httpx.Client(timeout=10)
# Try to register a test user (might already exist)
client.post(f"{base_url}/auth/register", json={
"username": "e2e-tester",
"password": "testpass123456",
"display_name": "E2E Tester",
})
# Login
resp = client.post(f"{base_url}/auth/login", json={
"username": "e2e-tester",
"password": "testpass123456",
})
if resp.status_code != 200:
# Fall back to admin (auto-created on first run)
print(" [!] Could not login as e2e-tester, trying admin...")
print(" You may need to provide the admin password.")
sys.exit(1)
cookies = resp.cookies
# Register agents (ignore errors if already exist)
alice_resp = client.post(f"{base_url}/api/agents", json={
"name": "alice",
"display_name": "Alice the Researcher",
"type": "ai",
"capabilities": {"research": True, "summarization": True},
}, cookies=cookies)
bob_resp = client.post(f"{base_url}/api/agents", json={
"name": "bob",
"display_name": "Bob the Analyst",
"type": "ai",
"capabilities": {"data_analysis": True, "coding": True},
}, cookies=cookies)
alice_key = alice_resp.json().get("api_key", "")
bob_key = bob_resp.json().get("api_key", "")
if not alice_key or not bob_key:
print(" [!] Could not register agents. They may already exist.")
print(" Delete test-data/ and restart the server for a clean test.")
sys.exit(1)
client.close()
return alice_key, bob_key
# ---------------------------------------------------------------------------
# Claude-powered agent loop
# ---------------------------------------------------------------------------
# Tool definitions for Claude (subset of SynapBus MCP tools)
TOOLS = [
{
"name": "send_message",
"description": "Send a message to another agent via SynapBus",
"input_schema": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient agent name"},
"body": {"type": "string", "description": "Message body"},
"subject": {"type": "string", "description": "Conversation subject"},
},
"required": ["to", "body"],
},
},
{
"name": "read_inbox",
"description": "Read messages from your inbox",
"input_schema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Max messages to return"},
},
},
},
{
"name": "discover_agents",
"description": "Discover other agents by capability",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Capability search query"},
},
},
},
{
"name": "claim_messages",
"description": "Claim pending messages for processing (atomic lock)",
"input_schema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Max messages to claim"},
},
},
},
{
"name": "mark_done",
"description": "Mark a claimed message as done",
"input_schema": {
"type": "object",
"properties": {
"message_id": {"type": "integer", "description": "Message ID to mark done"},
"status": {"type": "string", "enum": ["done", "failed"]},
},
"required": ["message_id", "status"],
},
},
]
def run_agent(
agent_name: str,
mcp: SynapBusMCP,
system_prompt: str,
user_prompt: str,
model: str = "claude-sonnet-4-6",
max_turns: int = 5,
) -> str:
"""Run a Claude agent that uses SynapBus MCP tools."""
client = anthropic.Anthropic()
messages = [{"role": "user", "content": user_prompt}]
final_text = ""
for turn in range(max_turns):
print(f" [{agent_name}] Turn {turn + 1}/{max_turns}")
response = client.messages.create(
model=model,
max_tokens=1024,
system=system_prompt,
tools=TOOLS,
messages=messages,
)
# Collect assistant response
assistant_content = []
tool_uses = []
for block in response.content:
if block.type == "text":
final_text += block.text
assistant_content.append({"type": "text", "text": block.text})
print(f" [{agent_name}] {block.text[:200]}")
elif block.type == "tool_use":
tool_uses.append(block)
assistant_content.append({
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
})
print(f" [{agent_name}] -> tool: {block.name}({json.dumps(block.input)[:100]})")
messages.append({"role": "assistant", "content": assistant_content})
if response.stop_reason == "end_turn":
break
# Execute tool calls
tool_results = []
for tool_use in tool_uses:
try:
result = mcp.call_tool(tool_use.name, tool_use.input)
result_text = json.dumps(result, indent=2)
print(f" [{agent_name}] <- {tool_use.name}: {result_text[:150]}")
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": result_text,
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": f"Error: {e}",
"is_error": True,
})
messages.append({"role": "user", "content": tool_results})
return final_text
# ---------------------------------------------------------------------------
# Main test scenario
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="SynapBus two-agent E2E test")
parser.add_argument("--port", type=int, default=8080, help="SynapBus port")
parser.add_argument("--model", default="claude-sonnet-4-6", help="Claude model")
args = parser.parse_args()
global SYNAPBUS_URL, MCP_URL
SYNAPBUS_URL = f"http://localhost:{args.port}"
MCP_URL = f"{SYNAPBUS_URL}/mcp"
if not os.environ.get("ANTHROPIC_API_KEY"):
print("ERROR: ANTHROPIC_API_KEY not set")
sys.exit(1)
print(f"=== SynapBus E2E Test ===")
print(f"Server: {SYNAPBUS_URL}")
print(f"Model: {args.model}")
# 1. Setup
print("\n[1/4] Setting up agents...")
alice_key, bob_key = setup_agents(SYNAPBUS_URL)
print(f" Alice key: {alice_key[:16]}...")
print(f" Bob key: {bob_key[:16]}...")
# 2. Initialize MCP sessions
print("\n[2/4] Initializing MCP sessions...")
alice_mcp = SynapBusMCP(SYNAPBUS_URL, alice_key)
bob_mcp = SynapBusMCP(SYNAPBUS_URL, bob_key)
alice_mcp.initialize()
bob_mcp.initialize()
print(f" Alice session: {alice_mcp.session_id}")
print(f" Bob session: {bob_mcp.session_id}")
# 3. Alice sends a message
print("\n[3/4] Alice sends a research request to Bob...")
alice_result = run_agent(
"Alice",
alice_mcp,
system_prompt=(
"You are Alice, a research agent. You communicate with other agents "
"through SynapBus messaging tools. Be concise and direct."
),
user_prompt=(
"Discover what agents are available, then send a message to 'bob' "
"asking him to analyze the pros and cons of using MCP (Model Context "
"Protocol) for agent-to-agent communication. Include a specific "
"question in your message."
),
model=args.model,
)
# 4. Bob reads inbox and replies
print("\n[4/4] Bob reads inbox and replies...")
bob_result = run_agent(
"Bob",
bob_mcp,
system_prompt=(
"You are Bob, a data analyst agent. You communicate with other agents "
"through SynapBus messaging tools. When you receive messages, read them, "
"process the request, and send a thoughtful reply. Be concise."
),
user_prompt=(
"Check your inbox for new messages. Read any pending messages, then "
"reply to each sender with a helpful response to their question. "
"After replying, mark the original messages as done."
),
model=args.model,
)
# 5. Verify Alice got the reply
print("\n=== Verification ===")
print("Checking Alice's inbox for Bob's reply...")
alice_inbox = alice_mcp.call_tool("read_inbox", {"limit": 10})
msgs = alice_inbox.get("messages", [])
if msgs:
for msg in msgs:
print(f" From: {msg['from_agent']}")
print(f" Body: {msg['body'][:200]}")
print(f" Status: {msg['status']}")
print("\n SUCCESS: Alice received Bob's reply")
else:
print(" WARNING: No messages in Alice's inbox yet")
# Cleanup
alice_mcp.close()
bob_mcp.close()
print("\n=== Test Complete ===")
if __name__ == "__main__":
main()