タグ
黒いカードに白のCopilotKitのロゴタイプと、青と紫の凧に青い波形の尾が付いたマーク

AIMockでAIエージェントのテストを決定論的にする — LLM・ベクトルDB・MCP・A2Aを1つのポートでモックする

AIMockはLLM・ベクトルDB・MCP・A2Aを1つのポートでモックします。エージェントのテストスイートが3秒以内に、毎回同じ出力で完了します。

目次

はじめに

LLMを使うアプリケーションのテストには厄介な問題があります。同じプロンプトを送っても、毎回微妙に違うレスポンスが返ってくるのです。月曜に通ったテストが火曜に落ちる。原因はコードのバグではなく、LLMの非決定性です。

さらに2026年のエージェントアプリは、LLMだけでなくベクトルDB(RAG)、MCPツール、A2Aプロトコルによるエージェント間通信など、1回のリクエストで6〜7個のサービスに触れることも珍しくありません。それぞれのサービスをどうモックするかが課題になります。

CopilotKitが2026年4月にリリースしたオープンソースのモックサーバー「AIMock」は、これらすべてを1つのポートでモックします。この記事で構築するテストスイートは、モック化されたLLM・ベクトルDB・A2Aに対して18個のテストを2.35秒で実行し、毎回まったく同じ出力を返します。

この記事では、デモエージェントとそのテスト、実APIレスポンスの録画・再生、LLMockのカオス注入APIを扱います。後半では、GoogleのCodelabのバーガーエージェントをa2a-sdk v0.2からv1.0に移行し、Cloud Runにデプロイして、同じTypeScriptクライアントから接続するまでを追います。

AIMockとは何か

AIMockは、AIアプリが通信するすべてのサービスをモックするためのnpmパッケージです。

  • 11以上のLLMプロバイダーに対応(OpenAI、Claude、Gemini、Bedrock、Azure、Vertex AI、Ollama、Cohereなど)
  • MCP JSON-RPC 2.0のフルサポート
  • A2Aエージェントカードの探索とSSEストリーミング
  • ベクトルDB(Pinecone、Qdrant、ChromaDB)のモック
  • カオステスト(500エラー、不正JSON、ストリーム途中切断の注入。手動サーバーと setChaos() API両方)
  • ゼロ依存、Node.jsの組み込みモジュールのみ

「1つの設定ファイル、1つのポートで、AIスタック全体をモックする」というコンセプトです。

類似ツールとの位置づけ

AIMockの競合は何かと調べてみましたが、実はこのニッチには直接の競合がほぼ存在しません。

ツール フォーカス AIMockとの重複
Keploy 実トラフィック録画→テスト自動生成。汎用API向け AI固有プロトコル(MCP、A2A、ストリーミングSSE)非対応
VCR.py / Polly.js 古典的なHTTPリクエスト録画・再生 LLMモックに使えるが、ツールコール等のAI固有機能なし
Portkey / Helicone LLMゲートウェイ。キャッシュ・リプレイ可能 プロダクション向け。フォールト注入やMCPモックなし

エージェントアプリをMCP・A2A含めてテストしたい場合、現時点でAIMockが唯一の選択肢と言えます。単純にOpenAI APIのモックだけが必要なら、VCR系のHTTPリプレイで十分です。

フィクスチャの録画・再生はなぜ重要か

AIMockの中核機能の一つが「Record & Replay」です。実APIのレスポンスを録画し、テスト時にそのまま再生する仕組みです。

最初にこの機能を見たとき、「ワークフローの途中ステップをリトライする機能(n8nのような)とどう違うのか?」と疑問に思いました。調べてみると、解決する問題が根本的に異なります。

Record & Replayが解決する問題:

# 月曜: テスト通る
assert response.includes("SQL injection risk") ✅
# 火曜: 同じコード、同じプロンプト、OpenAIが言い換えた
assert response.includes("SQL injection risk") ❌
# "potential SQL vulnerability" が返ってきた

フィクスチャを使えば、LLMの呼び出しが純粋関数のように振る舞います。同じ入力に対して、毎回同じ出力です。

観点 フィクスチャなし フィクスチャあり
コスト テスト実行ごとに実APIコール ゼロ
速度 LLMコールあたり2〜30秒 1ms未満
決定性 毎回異なる出力 完全に同一
CI安定性 ネットワーク・レートリミット・API変更で不安定 安定
エッジケース 再現不可能 永続的にキャプチャ

CI/CDでモックを使うのは「逃げ」ではないか?

正直なところ、モックされたCIで本番の問題を検知できるのかという疑問は正当です。答えは「テストしているものが違う」です。

テスト対象 実APIが必要?
JSONパーサーがこのレスポンス形状を処理できるか? No。フィクスチャで十分
リトライロジックが500エラーで動作するか? No。カオス注入の方が効果的
プロンプトが良い出力を生成するか? Yes。モックでは不可能
UIがレスポンスを正しくレンダリングするか? No。フィクスチャで十分

実務では2層のCIパイプラインが最適なパターンです。

  • 全PR: モック化された高速テスト(コードロジックの検証)
  • ナイトリー/週次: 実APIを使った統合テスト(ドリフト検知、プロンプト検証)

AIMockのドリフト検知機能がこれを補完します。CIで毎日、SDKの型定義・実APIレスポンス・AIMockの出力を三方向比較し、プロバイダーのAPI変更をユーザーより先にキャッチします。

デモの構築: 購入代行エージェント

AIMockの主要機能を実際に使い、テストを書いてみました。題材は「購入代行エージェント」です。

アーキテクチャ

1 つのエージェント、4 回の呼び出し、3 つのモック中央のバイヤーエージェントの枠に、検索・計画・セラーエージェントへの照会・評価という 4 つの番号付きステップが並ぶ。ステップ 1 は左の VectorMock を、ステップ 3 は左の自作セラーエージェントモックを呼び、ステップ 2 と 4 はどちらも右の LLMock を呼ぶ。VectorMock と LLMock は AIMock 由来、セラーエージェントのモックは自作。最後にエージェントがユーザーへ推薦を返す。ユーザー:「旅行用の軽量ノート PC、予算 1,200 ドル」バイヤーエージェント(TypeScript)1検索過去のレビューと商品知識を取得2計画LLM が検索戦略を組み立てる3セラーエージェントに照会A2A で条件に合う商品を問い合わせ4評価LLM が返ってきた提案を比較VectorMockAIMock・Pinecone 互換セラーエージェントのモック自作・A2A v1.0LLMockAIMock・OpenAI /v1ユーザーに推薦を返す

1 リクエストでエージェントは 4 回外に出ます。その呼び出し先はすべて localhost 上のモックです。

テストでは以下を使い分けます。

  • LLMock: OpenAI APIのモック(@copilotkit/aimockLLMock クラス)
  • VectorMock: Pinecone互換のベクトルDBモック(VectorMock クラス)
  • A2Aモック: http.createServer で自作したA2Aセラーエージェント

プロジェクト構成

aimock-a2a-demo/
├── src/
│ ├── buyer-agent/
│ │ ├── index.ts # メインのBuyerAgentオーケストレーター
│ │ ├── a2a-client.ts # A2Aプロトコルクライアント
│ │ ├── knowledge-base.ts # ベクトルDB / RAGレイヤー
│ │ └── reasoner.ts # LLM推論
│ └── types/
│ ├── a2a.ts # A2Aプロトコル型定義
│ └── product.ts # 商品関連の型
├── tests/
│ ├── setup.ts # AIMockのセットアップ
│ ├── buyer-agent.test.ts # メインテストスイート
│ └── chaos.test.ts # カオステスト
├── fixtures/llm/ # 録画されたLLMレスポンス
├── aimock.json # AIMock設定ファイル
└── package.json

Step 1: LLMockのセットアップ

LLMockは systemMessageuserMessage のパターンマッチでフィクスチャを登録します。

tests/setup.ts
import { LLMock, VectorMock } from "@copilotkit/aimock";
const llmock = new LLMock({ port: 0 }); // ランダムポート
// システムプロンプトの内容でフィクスチャを振り分け
llmock.on({ systemMessage: "evaluating product offers" }, {
content: JSON.stringify({
topPickIndex: 0,
reasoning: "The MacBook Air M4 offers the best combination...",
confidence: 0.92,
}),
});
llmock.on({ systemMessage: "purchasing concierge" }, {
content: JSON.stringify({
refinedQuery: "ultrabook laptop under 1.3kg...",
priorities: ["weight under 1.3kg", "battery life 15+ hours"],
priceRange: { min: 900, max: 1300 },
}),
});
await llmock.start();
process.env.OPENAI_BASE_URL = `${llmock.url}/v1`;
process.env.OPENAI_API_KEY = "mock-key";

実装時の学び: onMessage() はユーザーメッセージのテキストのみにマッチします。同じアプリ内でLLMを複数回呼び出す場合(検索戦略の立案とオファー評価で2回など)、ユーザーメッセージの内容が類似していると意図しないフィクスチャにマッチすることがあります。on({ systemMessage: "..." }) を使ってシステムプロンプトの内容で振り分けるのが確実です。

また、フィクスチャの登録順序が重要です。LLMockは最初にマッチしたフィクスチャを返すため、より具体的なパターンを先に登録し、キャッチオール(onMessage(/.*/))を最後に登録します。

Step 2: VectorMockのセットアップ

VectorMockはPinecone互換のAPIを提供します。

tests/setup.ts
const vectorMock = new VectorMock({ port: 0 });
vectorMock.addCollection("product-knowledge", { dimension: 1536 });
// 静的な結果を登録 — 毎回同じ結果が返る
vectorMock.onQuery("product-knowledge", [
{
id: "review-macbook-air",
score: 0.94,
metadata: {
brand: "Apple",
model: "MacBook Air M4",
text: "MacBook Air M4: 1.24kg, 18hr battery life...",
},
},
// ... 他のエントリ
]);
await vectorMock.start();

実装時の学び: QueryResult 型には text フィールドがありません。テキストデータは metadata.text に格納し、アプリ側で m.metadata?.text として取得する必要があります。

また、VectorMockはLLMockの mount() で同一ポートにマウントすることも理論上は可能ですが、パスのプレフィックスが剥がされないため、別ポートで起動する方が確実に動作します。

Step 3: A2Aプロトコルのモック

A2AはGoogleとLinux Foundationが策定した、エージェント間通信のオープンプロトコルです(v1.2、150以上の組織が参加)。

A2Aが真に価値を発揮するのは組織をまたいだエージェント通信です。自社のエージェントが他社のエージェント(例: 決済処理エージェント、物流エージェント)と通信するケースです。同じアプリ内のサブエージェントにA2Aを使うのはオーバーキルで、関数呼び出しで十分です。

今回のデモでは、買い手エージェントと売り手エージェントの間にA2Aを使います。これは異なる組織のエージェントが通信する想定なので、A2Aの正当なユースケースです。

A2Aモックは http.createServer で自作しました。A2A v1.0プロトコルに準拠しています。

tests/setup.ts
const server = createServer((req, res) => {
// Agent Card探索: GET /.well-known/agent-card.json (v1.0)
if (req.url === "/.well-known/agent-card.json" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
name: "Mock Electronics Seller",
skills: [{ id: "product-search", name: "Product Search", ... }],
...
}));
return;
}
// JSON-RPC: POST / — SendMessage (v1.0)
if (req.method === "POST") {
const rpc = JSON.parse(body);
if (rpc.method === "SendMessage") {
res.end(JSON.stringify({
jsonrpc: "2.0", id: rpc.id,
result: {
message: {
role: "ROLE_AGENT",
parts: [{ data: { offers: mockOffers } }],
messageId: `reply-${rpc.id}`,
contextId: rpc.params?.message?.contextId,
},
},
}));
}
}
});

Step 4: テストの実行

18個のテストケースを用意しました。すべて3秒以内で完了します。

$ pnpm test
✓ tests/buyer-agent.test.ts (8 tests) 46ms
✓ A2A: Seller Agent Discovery > discovers seller agent card
✓ A2A: Seller Agent Discovery > checks skill availability
✓ A2A: Seller Agent Discovery > sends message and receives offers
✓ Vector DB: Knowledge Base > returns deterministic results
✓ Vector DB: Knowledge Base > same query twice returns identical results
✓ Vector DB: Knowledge Base > queryWithFallback returns empty on unreachable server
✓ Integration: Full Purchasing Flow > end-to-end: query → RAG → LLM → A2A → LLM
✓ Integration: Full Purchasing Flow > works without RAG knowledge
✓ tests/chaos.test.ts (10 tests) 2026ms
✓ Chaos: Vector DB failures > survives connection refused
✓ Chaos: Vector DB failures > respects timeout
✓ Chaos: Vector DB failures > throws on server error
✓ Chaos: LLM failures > malformed JSON
✓ Chaos: LLM failures > empty response
✓ Chaos: LLMock setChaos() > malformedRate — every response is garbled
✓ Chaos: LLMock setChaos() > dropRate — every request gets 500
✓ Chaos: LLMock setChaos() > clearChaos() restores normal operation
✓ Chaos: LLMock nextRequestError() > single request fails, next succeeds
✓ Resilience > agent degrades gracefully
Test Files 2 passed (2)
Tests 18 passed (18)
Duration 2.35s

特に注目すべきテストケースを紹介します。

決定性の証明:

tests/buyer-agent.test.ts
test("same query twice returns identical results (determinism proof)", async () => {
const results1 = await kb.query("laptop");
const results2 = await kb.query("laptop");
// 実際のPineconeでは保証されない
expect(results1).toEqual(results2);
});

グレースフルデグラデーション:

tests/buyer-agent.test.ts
test("works without RAG knowledge (graceful degradation)", async () => {
const agent = new BuyerAgent({
sellerAgentUrl: sellerUrl,
knowledgeBase: {
apiKey: "mock-key",
baseUrl: "http://localhost:1", // 到達不能 → フォールバック
indexName: "product-knowledge",
},
ragTimeoutMs: 500,
});
const result = await agent.findProduct({
query: "I need a lightweight laptop for travel",
});
// RAGが落ちてもエージェントはLLM + A2Aで動作する
expect(result.topPick).toBeDefined();
expect(result.knowledgeUsed).toHaveLength(0);
});

このテストはモック基盤なしでは書くのが難しいものです。実際にPineconeインスタンスを停止してテストするわけにはいきません。

A2Aエコシステムの現状

A2Aプロトコルの調査で、公開されていて自由にインタラクトできるエージェントを探してみました。

見つかったもの

  • Hello World Agent: https://hello-world-gxfr.onrender.com/.well-known/agent.json。最初の公開A2Aエージェント。エコー応答のみですが、クライアントの動作確認には使えます
  • A2A Registry Playground: a2a-registry.org/playground。登録されたエージェントと対話できるプレイグラウンド
  • Google Codelab: Purchasing Concierge。Cloud Runに2つのエージェントをデプロイし、A2Aで通信させるハンズオン。無料枠で実行可能

率直な評価

存在するもの まだないもの
Hello World / エコーエージェント 実企業が公開する本番用A2Aエンドポイント
Google Codelabのデモ Stripe、Twilioなどが公開するA2A API
コミュニティのトイエージェント 実用的な機能を持つエージェントマーケットプレイス

A2Aは150以上の組織が参加し、GitHubスターも22,000以上ですが、公開されていて自由にインタラクトできる実用的なエージェントはほぼ存在しません。エンタープライズでのA2A導入(Azure AI Foundry、Bedrock AgentCore)はほとんど認証の背後にあります。

プロトコルは成熟しています(v1.2、Linux Foundation管轄)。エージェントが追いついていない状況です。

Google Codelabの実A2Aエージェントをローカルで動かす

前半ではモックされたA2Aセラーエージェントを http.createServer で自作しました。次のステップとして、Google CodelabのPurchasing Conciergeに含まれる実A2Aエージェントをローカルで動かしてみました。

リポジトリのクローンと調整

ターミナルウィンドウ
git clone https://github.com/alphinside/purchasing-concierge-intro-a2a-codelab-starter.git
cd purchasing-concierge-intro-a2a-codelab-starter/remote_seller_agents/burger_agent

このburger agentはCrewAI + LiteLLM + Vertex AI Geminiで構築されたA2Aセラーエージェントです。ユーザーからのバーガー注文を受け付けて処理します。

元のリポジトリから以下の調整が必要でした。

1. Python 3.13への更新

pyproject.toml
requires-python = ">=3.13"
Dockerfile
FROM python:3.13-slim

2. uv.lock の再生成

uv.lock は各パッケージのダウンロードURLを完全な形でハードコードするため、生成した環境が向いていたインデックスがそのまま残ります。手元の uv.lock はセキュリティプロキシミラー配下で生成されたもので、全URLがそのミラー経由になっていました。このミラーに到達できない環境ではダウンロードがタイムアウトします。

ターミナルウィンドウ
rm uv.lock
UV_DEFAULT_INDEX=https://pypi.org/simple/ uv lock --python 3.13

これは uv sync --frozenロックファイルにハードコードされたURLをそのまま使う仕様によるものです。インデックス設定を変更しても、ロックファイル内のURLは変わりません。再生成が必要です。

プロジェクト内でもインデックスをオーバーライドしておくと安心です。

pyproject.toml
[[tool.uv.index]]
url = "https://pypi.org/simple/"
default = true

3. litellm の明示的な依存追加

agent.pyimport litellm しているのに、pyproject.toml の依存には含まれていませんでした。以前はCrewAIの推移的依存として入っていたようですが、新しいバージョンでは外れています。

ターミナルウィンドウ
UV_DEFAULT_INDEX=https://pypi.org/simple/ uv add litellm

a2a-sdk v0.2 → v1.0 マイグレーション

元のコードは a2a-sdk v0.2系向けに書かれていますが、>=0.2.16 の指定で v1.1.2 がインストールされ、破壊的変更によりインポートエラーが発生します。v1.0に合わせてコードを修正しました。

主な変更点:

項目 v0.2 v1.0
サーバー構築 A2AStarletteApplication クラス create_jsonrpc_routes() / create_agent_card_routes() ルートファクトリ
AgentCard url フィールド supported_interfaces リスト(AgentInterface
RequestHandler agent_card 不要 agent_card が必須パラメータ
Part構築 Part(root=TextPart(text="...")) Part(text="...")(Protobufベース)
JSON-RPCメソッド tasks/send SendMessage
エージェントカードパス /.well-known/agent.json /.well-known/agent-card.json
プロトコルバージョン なし(暗黙的に0.3) A2A-Version: 1.0 ヘッダーが必要
エラー型 ServerError UnsupportedOperationError を直接使用

__main__.py の変更(サーバー構築):

__main__.py
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.types import AgentCard, AgentInterface, AgentCapabilities, AgentSkill
from starlette.applications import Starlette
agent_card = AgentCard(
name="burger_seller_agent",
description="Helps with creating burger orders",
version="1.0.0",
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[
AgentInterface(protocol_binding="JSONRPC", url=agent_host_url)
],
skills=[skill],
)
request_handler = DefaultRequestHandler(
agent_executor=BurgerSellerAgentExecutor(),
task_store=InMemoryTaskStore(),
agent_card=agent_card, # v1.0で必須に
)
routes = []
routes.extend(create_agent_card_routes(agent_card))
routes.extend(create_jsonrpc_routes(request_handler, "/"))
app = Starlette(routes=routes)
uvicorn.run(app, host=host, port=port)

agent_executor.py の変更(Protobufベースの型):

agent_executor.py
from a2a.types import Message, Part, Role, Task
from a2a.utils.errors import UnsupportedOperationError
class BurgerSellerAgentExecutor(AgentExecutor):
async def execute(self, context, event_queue):
query = context.message.parts[0].text if context.message else ""
result = await self.agent.invoke(query, context.context_id)
# v1.0: Part(text="...") で直接構築(TextPartラッパー不要)
await event_queue.enqueue_event(
Message(
role=Role.ROLE_AGENT,
parts=[Part(text=str(result))],
message_id=context.task_id or "msg",
context_id=context.context_id,
)
)

agent.py の変更(async化):

CrewAIの kickoff() は同期メソッドですが、a2a-sdk v1.0ではリクエストハンドラが非同期コンテキストから呼ばれます。同期メソッドを非同期コンテキスト内で呼ぶと Agent execution was invoked synchronously from within a running event loop エラーが発生します。

agent.py
# Before: def invoke(self, query, sessionId) -> str:
async def invoke(self, query, sessionId) -> str:
# ...
# Before: response = crew.kickoff(inputs)
response = await crew.kickoff_async(inputs)
return response

動作確認

ターミナルウィンドウ
# Vertex AI APIの有効化が必要
gcloud services enable aiplatform.googleapis.com
# ローカルで起動
uv run . --host 127.0.0.1 --port 8080

エージェントカードの確認:

ターミナルウィンドウ
$ curl http://127.0.0.1:8080/.well-known/agent-card.json | jq .name
"burger_seller_agent"

A2A v1.0プロトコルでのメッセージ送信:

ターミナルウィンドウ
$ curl -X POST http://127.0.0.1:8080/ \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-d '{"jsonrpc":"2.0","id":"1","method":"SendMessage",
"params":{"message":{"role":"ROLE_USER",
"parts":[{"text":"What burgers do you have?"}],
"messageId":"m1","contextId":"ctx1"}}}'

レスポンス:

{
"result": {
"message": {
"role": "ROLE_AGENT",
"parts": [{
"text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. Do any of these sound good?"
}]
}
},
"id": "1",
"jsonrpc": "2.0"
}

Vertex AI Gemini → CrewAI → A2A v1.0プロトコルの全経路が動作しています。

Cloud Runへのデプロイ

ローカルで動作確認ができたら、Cloud Runにデプロイします。このburger agentはStarlette ASGIサーバー(uvicornで起動)なので、Cloud FunctionsではなくCloud Runが適しています。Cloud FunctionsはリクエストごとにPython関数を呼び出すモデルですが、A2AエージェントはStarletteの複数ルート(/.well-known/agent-card.json/ のJSON-RPCエンドポイント)を持つ常駐HTTPサーバーです。

ターミナルウィンドウ
gcloud run deploy burger-agent \
--source . \
--port 8080 \
--allow-unauthenticated \
--region us-central1 \
--min-instances 0 \
--max-instances 1 \
--memory 1Gi

--source . でDockerfileを使ったビルドがCloud Build上で実行され、Artifact Registryにイメージが保存された後、Cloud Runにデプロイされます。

デプロイ後、エージェントカードの url フィールドにCloud RunのURLを反映させるため、環境変数を設定します。

ターミナルウィンドウ
gcloud run services update burger-agent \
--region us-central1 \
--set-env-vars HOST_OVERRIDE=https://burger-agent-XXXXXXXXXX.us-central1.run.app

動作確認:

ターミナルウィンドウ
$ curl https://burger-agent-XXXXXXXXXX.us-central1.run.app/.well-known/agent-card.json | jq .name
"burger_seller_agent"
$ curl -X POST https://burger-agent-XXXXXXXXXX.us-central1.run.app/ \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-d '{"jsonrpc":"2.0","id":"1","method":"SendMessage",
"params":{"message":{"role":"ROLE_USER",
"parts":[{"text":"What burgers do you have?"}],
"messageId":"m1","contextId":"ctx1"}}}'
{
"result": {
"message": {
"role": "ROLE_AGENT",
"parts": [{
"text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. What would you like to order?"
}]
}
}
}

コストに関する注意: --min-instances 0 を指定しているので、リクエストがない間はインスタンスが0にスケールダウンし、課金されません。ただしコールドスタートに10〜15秒かかります。デモやテスト用途であれば問題ありません。

TypeScriptバイヤーエージェントをA2A v1.0に対応させる

Cloud Runにデプロイした実セラーエージェントに接続するため、TypeScript側のA2Aクライアントもv1.0プロトコルに対応させました。

A2Aクライアントの変更

src/buyer-agent/a2a-client.ts
export class A2AClient {
// v1.0: agent-card.json(旧: agent.json)
async discover(): Promise<AgentCard> {
const url = `${this.agentUrl}/.well-known/agent-card.json`;
const res = await fetch(url);
this.card = await res.json();
return this.card;
}
// v1.0: SendMessage + A2A-Version ヘッダー
async sendMessage(text: string, contextId?: string): Promise<A2AMessage> {
const rpcRequest = {
jsonrpc: "2.0",
id: messageId,
method: "SendMessage", // 旧: "tasks/send"
params: {
message: {
role: "ROLE_USER", // 旧: "user"
parts: [{ text }], // 旧: { type: "text", text }
messageId,
contextId,
},
},
};
const res = await fetch(this.agentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"A2A-Version": "1.0", // v1.0で必須
},
body: JSON.stringify(rpcRequest),
});
const rpcResponse = await res.json();
return rpcResponse.result.message; // 旧: result(タスクオブジェクト)
}
}

v0.3 → v1.0 変更点まとめ(TypeScript側)

項目 v0.3 v1.0
エージェントカードパス /.well-known/agent.json /.well-known/agent-card.json
JSON-RPCメソッド tasks/send SendMessage
ロール値 "user" / "agent" "ROLE_USER" / "ROLE_AGENT"
パート構造 { type: "text", text: "..." } { text: "..." }
ヘッダー なし A2A-Version: 1.0
レスポンス result = Task(messagesの配列) result.message = 単一Message

実Cloud Runエージェントとの接続テスト

TypeScript A2Aクライアントから実Cloud Runバーガーエージェントへの接続を確認しました。

const client = new A2AClient("https://burger-agent-XXXXXXXXXX.us-central1.run.app");
const card = await client.discover();
// → Name: burger_seller_agent, Skills: ["create_burger_order"]
const reply = await client.sendMessage("What burgers do you have?");
// → role: ROLE_AGENT
// → "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, ..."

モックテストと実エージェントが同じA2Aクライアントコード、同じv1.0プロトコルで動作します。テスト時はAIMockのモックサーバー、本番では実Cloud Runエージェントに向き先を変えるだけです。

フィクスチャの録画を実際にやってみる

記事の前半で「Record & Replay」の概念を説明しましたが、実際にやってみました。

llmockの起動

@copilotkit/aimock パッケージには llmock というCLIバイナリが同梱されています。--record フラグで録画モードのプロキシサーバーとして起動します。

ターミナルウィンドウ
# llmockを録画モードで起動(ポート4010でリッスン)
pnpm mock:record
# → llmock --record --provider-openai https://api.openai.com -f ./fixtures/llm

アプリをllmock経由で実行

OPENAI_BASE_URL を llmock に向けるだけで、アプリのOpenAI SDK呼び出しがすべてllmock経由になります。APIキーは .env からアプリが読み込み、llmockが透過的にOpenAIに転送します。

ターミナルウィンドウ
OPENAI_BASE_URL=http://localhost:4010/v1 \
SELLER_URL=https://burger-agent-XXXXXXXXXX.us-central1.run.app \
pnpm dev

録画されたフィクスチャ

fixtures/llm/recorded/ に以下のようなJSONが保存されます。

{
"fixtures": [{
"match": {
"userMessage": "User request: \"I need a lightweight laptop...\"",
"model": "gpt-4o-mini",
"turnIndex": 0,
"hasToolResult": false
},
"response": {
"content": "{\"refinedQuery\": \"lightweight travel laptop under $1200\", ...}"
},
"metadata": {
"systemHash": "a11524e7"
}
}]
}

match のフィールド(userMessagemodelturnIndex)がマッチング条件になります。次回以降、同じ条件のリクエストが来たらOpenAIを呼ばずにこのレスポンスを返します。

Record & Replayの実感

実際にやってみると、このワークフローのシンプルさに驚きます。

  1. pnpm mock:record でプロキシ起動
  2. OPENAI_BASE_URL=http://localhost:4010/v1 pnpm dev で普通にアプリを動かす
  3. fixtures/llm/recorded/ にフィクスチャが自動保存
  4. 次回以降は pnpm mock:start でフィクスチャを再生するだけ

コードの変更は一切不要OPENAI_BASE_URL の向き先を変えるだけです。

LLMockのカオス注入機能を試す

先ほどのテスト結果のうち、setChaos()nextRequestError() の4つはまだ説明していませんでした。これらはLLMockに組み込まれたプログラマティックなカオス注入APIを使っています。最初の5つのカオステストが500エラーやタイムアウトを再現するために手動でHTTPサーバーを立てているのに対し、こちらはテストサーバーを自作せずに済みます。

setChaos():確率的フォールト注入

setChaos() で全リクエストに対する障害確率を設定します。

tests/chaos.test.ts
test("setChaos({ malformedRate: 1 }) — every response is garbled", async () => {
const mock = new LLMock({ port: 0 });
// 正常なフィクスチャを登録(/.*/で全メッセージにマッチ)
mock.onMessage(/.*/, {
content: '{"refinedQuery": "test", "priorities": [], "priceRange": {"min": 0, "max": 100}}',
});
// カオス設定: 100%の確率でレスポンスを破壊
mock.setChaos({ malformedRate: 1.0 });
await mock.start();
process.env.OPENAI_BASE_URL = `http://localhost:${mock.port}/v1`;
const reasoner = new Reasoner("gpt-4o-mini");
// フィクスチャはマッチするが、カオスが応答を壊す
await expect(
reasoner.buildSearchStrategy("laptop", [])
).rejects.toThrow();
await mock.stop();
});

ChaosConfig には3つの確率パラメータがあります。

パラメータ 効果 フィクスチャ不要
dropRate 500エラーを返す はい
malformedRate {malformed json: <<<chaos>>>} を返す いいえ(要マッチ)
disconnectRate TCP接続を途中切断する いいえ(要マッチ)

注意点として、onMessage("*")ワイルドカードではなくリテラル文字列 * としてマッチします。全メッセージにマッチさせるには正規表現 /.*/ を使います。

clearChaos():カオスの解除

clearChaos() でカオス設定を解除すると、正常なフィクスチャ応答に戻ります。障害からの復旧をテストするのに便利です。

tests/chaos.test.ts
test("clearChaos() restores normal operation", async () => {
const mock = new LLMock({ port: 0 });
mock.onMessage(/.*/, { content: validJson });
mock.setChaos({ malformedRate: 1.0 });
await mock.start();
// ... env setup ...
const reasoner = new Reasoner("gpt-4o-mini");
// カオスON — 失敗する
await expect(
reasoner.buildSearchStrategy("laptop", [])
).rejects.toThrow();
// カオス解除 — 正常に動作する
mock.clearChaos();
const strategy = await reasoner.buildSearchStrategy("laptop", []);
expect(strategy.refinedQuery).toBe("laptop"); // ✓ 正常なJSON
});

nextRequestError():ワンショットエラー注入

nextRequestError()次の1リクエストだけにエラーを返し、その後は自動的に消費されます。「1回だけ失敗して、リトライで復旧する」シナリオのテストに最適です。

tests/chaos.test.ts
test("nextRequestError(400) — single request fails, next succeeds", async () => {
const mock = new LLMock({ port: 0 });
mock.onMessage(/.*/, { content: validJson });
// ワンショット: 次の1リクエストだけ400を返す
mock.nextRequestError(400, { message: "Bad Request" });
await mock.start();
// ... env setup ...
const reasoner = new Reasoner("gpt-4o-mini");
// 1回目: 400エラー
await expect(
reasoner.buildSearchStrategy("laptop", [])
).rejects.toThrow();
// 2回目: ワンショットが消費済み → 正常応答
const strategy = await reasoner.buildSearchStrategy("laptop", []);
expect(strategy.refinedQuery).toBe("laptop"); // ✓
});

注意: OpenAI SDKは5xxエラー(500、503等)を自動リトライします。ワンショットで確実にエラーをテストするには、リトライされない400番台を使います。

テスト実行結果

$ pnpm test
✓ tests/buyer-agent.test.ts (8 tests) 42ms
✓ tests/chaos.test.ts (10 tests) 1918ms
✓ Chaos: Vector DB failures > survives connection refused
✓ Chaos: Vector DB failures > respects timeout
✓ Chaos: Vector DB failures > throws on server error
✓ Chaos: LLM failures > malformed JSON
✓ Chaos: LLM failures > empty response
✓ Chaos: LLMock setChaos() > malformedRate — every response is garbled
✓ Chaos: LLMock setChaos() > dropRate — every request gets 500
✓ Chaos: LLMock setChaos() > clearChaos() restores normal operation
✓ Chaos: LLMock nextRequestError() > single request fails, next succeeds
✓ Resilience > agent degrades gracefully
Test Files 2 passed (2)
Tests 18 passed (18)
Duration 2.2s

手動でHTTPサーバーを立てるカオステスト(最初の5つ)と、LLMockの組み込みカオスAPI(それに続く4つ。最後の agent degrades gracefully はどちらにも属しません)の両方が動作しています。どちらのアプローチも有効ですが、LLMockのAPIを使う方がコードが少なく、意図が明確です。

まとめ

AIMockを使ったAIエージェントのテストを構築し、さらにGoogle Codelabの実A2Aエージェントをa2a-sdk v1.0にマイグレーションし、ローカルでの動作確認からCloud Runへのデプロイ、そしてTypeScriptバイヤーエージェントからの接続まで一気通貫でやってみました。

AIMockが効果的なケース:

  • LLM + ベクトルDB + 複数サービスを組み合わせたエージェントアプリのテスト
  • CI/CDでの高速・低コスト・安定したテスト実行
  • フォールト注入による耐障害性の検証

AIMockがオーバーキルなケース:

  • OpenAI APIだけをモックしたい場合(VCR系のHTTPリプレイで十分)
  • プロンプトの品質を評価したい場合(モックでは不可能)

AIMock実装上の注意点:

  • onMessage() はユーザーメッセージのみにマッチ。on({ systemMessage }) でシステムプロンプトでの振り分けが可能
  • onMessage("*") はワイルドカードではなくリテラル * にマッチ。全メッセージにマッチさせるには onMessage(/.*/) を使う
  • フィクスチャの登録順序が重要(先にマッチしたものが返る)
  • VectorMockの QueryResulttext フィールドはない。metadata 内に格納する
  • VectorMockはLLMockとは別ポートで起動するのが安全
  • setChaos()malformedRatedisconnectRate はフィクスチャのマッチが必要。dropRate のみフィクスチャ不要で動作する
  • OpenAI SDKは5xxエラーを自動リトライする。nextRequestError() でエラーをテストする場合は400番台を使う

a2a-sdk v1.0マイグレーションとデプロイの学び:

  • A2AStarletteApplication は廃止。ルートファクトリ(create_jsonrpc_routes / create_agent_card_routes)を使って標準のStarletteアプリに組み込む
  • 型システムがPydanticからProtobufベースに変更。Part(text="...") のように直接構築する
  • エージェントカードのパスが agent.jsonagent-card.json に変更
  • JSON-RPCメソッド名が tasks/sendSendMessage に変更、A2A-Version: 1.0 ヘッダーが必須
  • uv.lock にPyPIミラーのURLがハードコードされる問題は UV_DEFAULT_INDEX で再生成して解決
  • A2AエージェントはStarlette ASGIサーバーのため、Cloud Functions(単一関数ハンドラモデル)ではなくCloud Run(Dockerコンテナ)でデプロイする
  • Cloud Runでは HOST_OVERRIDE 環境変数でエージェントカードの公開URLを設定する
  • TypeScript側のA2Aクライアントも同様にv1.0対応が必要(エンドポイントパス、メソッド名、ロール値、ヘッダーの変更)
  • モックと実エージェントで同じクライアントコード・同じプロトコルが使えるため、向き先(URL)を変えるだけでテスト↔本番を切り替えられる

参考リンク

この記事をシェア