/**
* Integration test for two-agent communication.
*
* This test exercises the full MCP → REST → DB flow with two agents
* communicating through the registry. It mocks the HTTP layer but
* tests the complete client→tool chain.
*
* For E2E testing against a real registry, see the README.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RegistryApiClient, createApiClient } from '../../src/client/api.js';
import { lookup } from '../../src/tools/lookup.js';
import { send, type SendResult } from '../../src/tools/send.js';
import { inbox, type InboxResult } from '../../src/tools/inbox.js';
import { reply, type ReplyResult } from '../../src/tools/reply.js';
import { whoami } from '../../src/tools/whoami.js';
import { testAgent1Config, testAgent2Config, testAgent1, testAgent2 } from '../fixtures/keys.js';
import type { Config } from '../../src/config/index.js';
// Mock fetch globally
const originalFetch = global.fetch;
describe('Two-Agent Communication Flow', () => {
let agentAClient: RegistryApiClient;
let agentBClient: RegistryApiClient;
let fetchMock: ReturnType<typeof vi.fn>;
// Simulated message store (in-memory database)
const messages: Array<{
id: string;
thread_id: string;
from_agent_id: string;
from_origin: string;
to_agent_id: string;
to_origin: string;
body: string;
subject?: string;
created_at: string;
read_at: string | null;
}> = [];
// Use crypto.randomUUID for proper UUID generation
const generateUuid = () => crypto.randomUUID();
// Counters for generating IDs
let threadCounter = 0;
let messageCounter = 0;
beforeEach(() => {
// Reset state
messages.length = 0;
threadCounter = 0;
messageCounter = 0;
// Create mock fetch that simulates API responses
fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const urlStr = url instanceof Request ? url.url : url.toString();
const method = init?.method || 'GET';
const rawHeaders = init?.headers || {};
const headers: Record<string, string> = {};
// Normalize headers to a plain object
if (rawHeaders instanceof Headers) {
rawHeaders.forEach((value, key) => {
headers[key] = value;
});
} else if (Array.isArray(rawHeaders)) {
rawHeaders.forEach(([key, value]) => {
headers[key] = value;
});
} else {
Object.assign(headers, rawHeaders);
}
let body: unknown;
if (init?.body) {
try {
body = JSON.parse(init.body as string);
} catch {
body = undefined;
}
}
// Determine which agent is making the request based on origin header
const origin = headers['X-Agent-Origin'];
const isAgentA = origin === testAgent1Config.origin;
const currentAgent = isAgentA ? testAgent1 : testAgent2;
const currentConfig = isAgentA ? testAgent1Config : testAgent2Config;
// Route based on URL and method
if (urlStr.includes('/api/v1/whoami') && method === 'GET') {
return new Response(JSON.stringify({
agent: currentAgent,
key: currentAgent.keys[0],
origin: currentConfig.origin,
}), { status: 200 });
}
if (urlStr.includes('/api/v1/agents/by-domain/')) {
const domain = decodeURIComponent(urlStr.split('/api/v1/agents/by-domain/')[1]);
const agent = domain === testAgent1.domain ? testAgent1 : testAgent2;
return new Response(JSON.stringify(agent), { status: 200 });
}
if (urlStr.includes('/api/v1/messages/send') && method === 'POST') {
const reqBody = body as { to_origin?: string; to_agent_id?: string; body: string; subject?: string; thread_id?: string };
++threadCounter;
++messageCounter;
const threadId = reqBody.thread_id || generateUuid();
const messageId = generateUuid();
// Find target agent by domain or ID
let toAgent = testAgent1;
if (reqBody.to_origin) {
toAgent = reqBody.to_origin === testAgent1.domain ? testAgent1 : testAgent2;
} else if (reqBody.to_agent_id) {
toAgent = reqBody.to_agent_id === testAgent1.id ? testAgent1 : testAgent2;
}
messages.push({
id: messageId,
thread_id: threadId,
from_agent_id: currentAgent.id,
from_origin: currentConfig.origin,
to_agent_id: toAgent.id,
to_origin: toAgent.domain!,
body: reqBody.body,
subject: reqBody.subject,
created_at: new Date().toISOString(),
read_at: null,
});
return new Response(JSON.stringify({
message_id: messageId,
thread_id: threadId,
}), { status: 201 });
}
if (urlStr.includes('/api/v1/messages/inbox') && method === 'GET') {
const agentMessages = messages.filter(m => m.to_agent_id === currentAgent.id);
return new Response(JSON.stringify({
messages: agentMessages,
total: agentMessages.length,
unread: agentMessages.filter(m => !m.read_at).length,
}), { status: 200 });
}
if (urlStr.match(/\/api\/v1\/messages\/threads\/[\w-]+\/reply/) && method === 'POST') {
const threadId = urlStr.match(/\/threads\/([\w-]+)\/reply/)?.[1];
if (!threadId) {
return new Response(JSON.stringify({ error: 'invalid_thread' }), { status: 400 });
}
// Find original message in thread to get recipient
const originalMessage = messages.find(m => m.thread_id === threadId);
if (!originalMessage) {
return new Response(JSON.stringify({ error: 'thread_not_found' }), { status: 404 });
}
const reqBody = body as { body: string };
++messageCounter;
const messageId = generateUuid();
// Reply goes to the original sender
messages.push({
id: messageId,
thread_id: threadId,
from_agent_id: currentAgent.id,
from_origin: currentConfig.origin,
to_agent_id: originalMessage.from_agent_id,
to_origin: originalMessage.from_origin,
body: reqBody.body,
created_at: new Date().toISOString(),
read_at: null,
});
return new Response(JSON.stringify({
message_id: messageId,
thread_id: threadId,
}), { status: 201 });
}
return new Response(JSON.stringify({ error: 'not_found' }), { status: 404 });
});
global.fetch = fetchMock;
// Create clients for both agents
agentAClient = createApiClient(testAgent1Config as Config);
agentBClient = createApiClient(testAgent2Config as Config);
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('Full Communication Flow', () => {
it('should allow Agent A to send a message to Agent B', async () => {
// Agent A sends to Agent B
const sendResult = await send(
{ to: testAgent2.domain!, body: 'Hello from Agent A!' },
agentAClient
);
expect(sendResult.success).toBe(true);
expect(sendResult.messageId).toBeDefined();
expect(sendResult.threadId).toBeDefined();
// Agent B checks inbox
const inboxResult = await inbox({}, agentBClient);
expect(inboxResult.success).toBe(true);
expect(inboxResult.messages).toHaveLength(1);
expect(inboxResult.messages?.[0].body).toBe('Hello from Agent A!');
expect(inboxResult.messages?.[0].from.origin).toBe(testAgent1Config.origin);
});
it('should allow Agent B to reply to Agent A', async () => {
// Agent A sends initial message
const sendResult = await send(
{ to: testAgent2.domain!, body: 'Hello!' },
agentAClient
);
const threadId = sendResult.threadId!;
// Agent B replies
const replyResult = await reply(
{ threadId, body: 'Hello back!' },
agentBClient
);
expect(replyResult.success).toBe(true);
expect(replyResult.messageId).toBeDefined();
expect(replyResult.threadId).toBe(threadId);
// Agent A checks inbox and sees the reply
const inboxResult = await inbox({}, agentAClient);
expect(inboxResult.success).toBe(true);
expect(inboxResult.messages).toHaveLength(1);
expect(inboxResult.messages?.[0].body).toBe('Hello back!');
expect(inboxResult.messages?.[0].threadId).toBe(threadId);
});
it('should support a multi-turn conversation', async () => {
// Turn 1: Agent A starts conversation
const turn1 = await send(
{ to: testAgent2.domain!, body: 'Turn 1: A to B' },
agentAClient
);
const threadId = turn1.threadId!;
// Turn 2: Agent B replies
const turn2 = await reply(
{ threadId, body: 'Turn 2: B to A' },
agentBClient
);
// Turn 3: Agent A replies again
const turn3 = await reply(
{ threadId, body: 'Turn 3: A to B' },
agentAClient
);
// Verify all messages are in the same thread
expect(turn1.threadId).toBe(threadId);
expect(turn2.threadId).toBe(threadId);
expect(turn3.threadId).toBe(threadId);
// Verify message count
expect(messages).toHaveLength(3);
expect(messages.filter(m => m.thread_id === threadId)).toHaveLength(3);
});
});
describe('Agent Identity', () => {
it('should return correct identity for Agent A', async () => {
const result = await whoami({}, testAgent1Config as Config, agentAClient);
expect(result.origin).toBe(testAgent1Config.origin);
expect(result.keyId).toBe(testAgent1Config.pubkeyId);
expect(result.registryConnected).toBe(true);
expect(result.agent?.name).toBe(testAgent1.name);
});
it('should return correct identity for Agent B', async () => {
const result = await whoami({}, testAgent2Config as Config, agentBClient);
expect(result.origin).toBe(testAgent2Config.origin);
expect(result.keyId).toBe(testAgent2Config.pubkeyId);
expect(result.registryConnected).toBe(true);
expect(result.agent?.name).toBe(testAgent2.name);
});
});
describe('Agent Lookup', () => {
it('should allow Agent A to lookup Agent B by domain', async () => {
const result = await lookup({ domain: testAgent2.domain! }, agentAClient);
expect(result.found).toBe(true);
expect(result.agent?.id).toBe(testAgent2.id);
expect(result.agent?.name).toBe(testAgent2.name);
expect(result.agent?.domain).toBe(testAgent2.domain);
});
it('should allow Agent B to lookup Agent A by domain', async () => {
const result = await lookup({ domain: testAgent1.domain! }, agentBClient);
expect(result.found).toBe(true);
expect(result.agent?.id).toBe(testAgent1.id);
expect(result.agent?.name).toBe(testAgent1.name);
expect(result.agent?.domain).toBe(testAgent1.domain);
});
});
describe('Error Handling', () => {
it('should handle sending to non-existent agent', async () => {
fetchMock.mockImplementationOnce(async () => {
return new Response(JSON.stringify({
error: { code: 'not_found', message: 'Agent not found' },
}), { status: 404 });
});
const result = await send(
{ to: 'nonexistent.agent.example', body: 'Hello?' },
agentAClient
);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should handle empty inbox gracefully', async () => {
// Agent A has no messages yet
const result = await inbox({}, agentAClient);
expect(result.success).toBe(true);
expect(result.messages).toHaveLength(0);
expect(result.total).toBe(0);
});
});
});