From b674e5d53924ccf57aa559d8be008d6889b2cd5b Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Wed, 18 Jun 2025 02:01:54 +0000 Subject: [PATCH 01/11] feat: Update OpenAPI specification and add A2A types - Modified the OpenAPI YAML file to update server tags, paths, and response schemas. - Added new A2A (Agent-to-Agent) types in a new TypeScript file for better type safety and structure. - Changed the OAS download script in package.json to point to the correct repository. Signed-off-by: Eden Reich --- app/api/v1/a2a/agents/[id]/route.ts | 124 +++++ app/api/v1/a2a/agents/route.ts | 219 ++++++++ components/a2a-agents-button.tsx | 87 +++ components/a2a-agents-dialog.tsx | 317 +++++++++++ components/input-area.tsx | 2 + components/tool-call-bubble.tsx | 4 +- lib/api.ts | 40 ++ openapi.yaml | 812 ++++++++++++++++------------ package.json | 2 +- types/a2a.ts | 53 ++ 10 files changed, 1298 insertions(+), 362 deletions(-) create mode 100644 app/api/v1/a2a/agents/[id]/route.ts create mode 100644 app/api/v1/a2a/agents/route.ts create mode 100644 components/a2a-agents-button.tsx create mode 100644 components/a2a-agents-dialog.tsx create mode 100644 types/a2a.ts diff --git a/app/api/v1/a2a/agents/[id]/route.ts b/app/api/v1/a2a/agents/[id]/route.ts new file mode 100644 index 0000000..bec75eb --- /dev/null +++ b/app/api/v1/a2a/agents/[id]/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from 'next/server'; +import type { A2AAgentDetails } from '@/types/a2a'; + +// Mock agent details data +const mockAgentDetails: Record = { + 'weather-agent-1': { + agent: { + id: 'weather-agent-1', + name: 'Weather Intelligence Agent', + description: + 'Provides comprehensive weather data, forecasts, and climate analysis using multiple data sources', + version: '2.1.0', + author: 'Weather Corp', + homepage: 'https://weather-agent.example.com', + license: 'MIT', + capabilities: [ + { + name: 'current_weather', + description: 'Get current weather conditions for any location worldwide', + input_schema: { + type: 'object', + properties: { + location: { type: 'string', description: 'City name or coordinates' }, + }, + }, + output_schema: { + type: 'object', + properties: { + temperature: { type: 'number' }, + conditions: { type: 'string' }, + humidity: { type: 'number' }, + }, + }, + }, + { + name: 'weather_forecast', + description: 'Get detailed weather forecast for up to 14 days', + }, + { + name: 'severe_alerts', + description: 'Get active severe weather alerts for a region', + }, + ], + endpoints: [ + { + name: 'weather', + method: 'POST', + path: '/api/weather', + description: 'Main weather endpoint', + }, + { + name: 'health', + method: 'GET', + path: '/health', + description: 'Health check endpoint', + }, + ], + status: 'available', + lastUpdated: '2024-12-15T10:30:00Z', + }, + health_status: 'healthy', + response_time_ms: 150, + last_health_check: '2024-12-15T11:00:00Z', + }, + 'data-analyst-1': { + agent: { + id: 'data-analyst-1', + name: 'Data Analysis Agent', + description: + 'Advanced data processing, analysis, and visualization capabilities with machine learning insights', + version: '1.8.2', + author: 'DataLab Inc', + homepage: 'https://datalab.example.com', + license: 'Apache-2.0', + capabilities: [ + { + name: 'analyze_dataset', + description: 'Perform comprehensive statistical analysis on datasets', + }, + { + name: 'create_visualization', + description: 'Generate charts and graphs from data', + }, + { + name: 'ml_insights', + description: 'Apply machine learning models for pattern recognition', + }, + ], + endpoints: [ + { + name: 'analyze', + method: 'POST', + path: '/api/analyze', + description: 'Data analysis endpoint', + }, + ], + status: 'available', + lastUpdated: '2024-12-14T16:45:00Z', + }, + health_status: 'healthy', + response_time_ms: 280, + last_health_check: '2024-12-15T10:58:00Z', + }, +}; + +export async function GET(request: Request, { params }: { params: { id: string } }) { + try { + const { id } = params; + + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 300)); + + const agentDetails = mockAgentDetails[id]; + + if (!agentDetails) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }); + } + + return NextResponse.json(agentDetails); + } catch (error) { + console.error('Error fetching A2A agent details:', error); + return NextResponse.json({ error: 'Failed to fetch agent details' }, { status: 500 }); + } +} diff --git a/app/api/v1/a2a/agents/route.ts b/app/api/v1/a2a/agents/route.ts new file mode 100644 index 0000000..8a46869 --- /dev/null +++ b/app/api/v1/a2a/agents/route.ts @@ -0,0 +1,219 @@ +import { NextResponse } from 'next/server'; +import type { A2AAgentsResponse } from '@/types/a2a'; + +// Mock data for A2A agents +const mockAgents: A2AAgentsResponse = { + agents: [ + { + id: 'weather-agent-1', + name: 'Weather Intelligence Agent', + description: + 'Provides comprehensive weather data, forecasts, and climate analysis using multiple data sources', + version: '2.1.0', + author: 'Weather Corp', + homepage: 'https://weather-agent.example.com', + license: 'MIT', + capabilities: [ + { + name: 'current_weather', + description: 'Get current weather conditions for any location worldwide', + input_schema: { + type: 'object', + properties: { + location: { type: 'string', description: 'City name or coordinates' }, + }, + }, + output_schema: { + type: 'object', + properties: { + temperature: { type: 'number' }, + conditions: { type: 'string' }, + humidity: { type: 'number' }, + }, + }, + }, + { + name: 'weather_forecast', + description: 'Get detailed weather forecast for up to 14 days', + input_schema: { + type: 'object', + properties: { + location: { type: 'string' }, + days: { type: 'number', maximum: 14 }, + }, + }, + }, + { + name: 'severe_alerts', + description: 'Get active severe weather alerts for a region', + }, + ], + endpoints: [ + { + name: 'weather', + method: 'POST', + path: '/api/weather', + description: 'Main weather endpoint', + }, + { + name: 'health', + method: 'GET', + path: '/health', + description: 'Health check endpoint', + }, + ], + status: 'available', + lastUpdated: '2024-12-15T10:30:00Z', + }, + { + id: 'data-analyst-1', + name: 'Data Analysis Agent', + description: + 'Advanced data processing, analysis, and visualization capabilities with machine learning insights', + version: '1.8.2', + author: 'DataLab Inc', + homepage: 'https://datalab.example.com', + license: 'Apache-2.0', + capabilities: [ + { + name: 'analyze_dataset', + description: 'Perform comprehensive statistical analysis on datasets', + input_schema: { + type: 'object', + properties: { + data: { type: 'array' }, + analysis_type: { type: 'string', enum: ['descriptive', 'predictive', 'diagnostic'] }, + }, + }, + }, + { + name: 'create_visualization', + description: 'Generate charts and graphs from data', + }, + { + name: 'ml_insights', + description: 'Apply machine learning models for pattern recognition', + }, + { + name: 'trend_analysis', + description: 'Identify trends and anomalies in time series data', + }, + ], + endpoints: [ + { + name: 'analyze', + method: 'POST', + path: '/api/analyze', + description: 'Data analysis endpoint', + }, + { + name: 'visualize', + method: 'POST', + path: '/api/visualize', + description: 'Data visualization endpoint', + }, + ], + status: 'available', + lastUpdated: '2024-12-14T16:45:00Z', + }, + { + id: 'translation-agent-1', + name: 'Multi-Language Translation Agent', + description: + 'Professional-grade translation service supporting 100+ languages with context awareness', + version: '3.0.1', + author: 'LinguaTech', + license: 'Commercial', + capabilities: [ + { + name: 'translate_text', + description: 'Translate text between any supported language pair', + input_schema: { + type: 'object', + properties: { + text: { type: 'string' }, + source_lang: { type: 'string' }, + target_lang: { type: 'string' }, + }, + }, + }, + { + name: 'detect_language', + description: 'Automatically detect the language of input text', + }, + { + name: 'batch_translate', + description: 'Translate multiple texts in a single request', + }, + ], + endpoints: [ + { + name: 'translate', + method: 'POST', + path: '/api/translate', + description: 'Translation endpoint', + }, + ], + status: 'unavailable', + lastUpdated: '2024-12-10T09:15:00Z', + }, + { + id: 'research-agent-1', + name: 'Academic Research Assistant', + description: + 'Comprehensive research capabilities including paper analysis, citation management, and knowledge synthesis', + version: '1.5.0', + author: 'ResearchBot Labs', + homepage: 'https://researchbot.example.com', + license: 'GPL-3.0', + capabilities: [ + { + name: 'search_papers', + description: 'Search academic databases for relevant research papers', + }, + { + name: 'analyze_paper', + description: 'Extract key insights and summaries from research papers', + }, + { + name: 'generate_citations', + description: 'Generate properly formatted citations in multiple styles', + }, + { + name: 'synthesize_knowledge', + description: 'Combine information from multiple sources into coherent summaries', + }, + ], + endpoints: [ + { + name: 'search', + method: 'GET', + path: '/api/search', + description: 'Paper search endpoint', + }, + { + name: 'analyze', + method: 'POST', + path: '/api/analyze', + description: 'Paper analysis endpoint', + }, + ], + status: 'error', + lastUpdated: '2024-12-12T14:20:00Z', + }, + ], + total_count: 4, + available_count: 2, +}; + +export async function GET() { + try { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 500)); + + return NextResponse.json(mockAgents); + } catch (error) { + console.error('Error fetching A2A agents:', error); + return NextResponse.json({ error: 'Failed to fetch A2A agents' }, { status: 500 }); + } +} diff --git a/components/a2a-agents-button.tsx b/components/a2a-agents-button.tsx new file mode 100644 index 0000000..dc2dff9 --- /dev/null +++ b/components/a2a-agents-button.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Users, Loader2 } from 'lucide-react'; +import { fetchA2AAgents } from '@/lib/api'; +import { A2AAgentsDialog } from './a2a-agents-dialog'; +import type { A2AAgent } from '@/types/a2a'; + +export function A2AAgentsButton() { + const [agents, setAgents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + + const availableAgents = agents.filter(agent => agent.status === 'available'); + + useEffect(() => { + const loadAgents = async () => { + try { + setIsLoading(true); + setError(null); + const response = await fetchA2AAgents(); + setAgents(response.agents); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load A2A agents'); + } finally { + setIsLoading(false); + } + }; + + loadAgents(); + }, []); + + const handleClick = () => { + setDialogOpen(true); + }; + + return ( + <> + + + { + const loadAgents = async () => { + try { + setIsLoading(true); + setError(null); + const response = await fetchA2AAgents(); + setAgents(response.agents); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load A2A agents'); + } finally { + setIsLoading(false); + } + }; + loadAgents(); + }} + /> + + ); +} diff --git a/components/a2a-agents-dialog.tsx b/components/a2a-agents-dialog.tsx new file mode 100644 index 0000000..7255b57 --- /dev/null +++ b/components/a2a-agents-dialog.tsx @@ -0,0 +1,317 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + RefreshCw, + Users, + CheckCircle, + XCircle, + AlertCircle, + ExternalLink, + Info, + Clock, +} from 'lucide-react'; +import type { A2AAgent } from '@/types/a2a'; + +interface A2AAgentsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + agents: A2AAgent[]; + isLoading: boolean; + error: string | null; + onRefresh: () => void; +} + +export function A2AAgentsDialog({ + open, + onOpenChange, + agents, + isLoading, + error, + onRefresh, +}: A2AAgentsDialogProps) { + const [selectedAgent, setSelectedAgent] = useState(null); + + const availableAgents = agents.filter(agent => agent.status === 'available'); + const unavailableAgents = agents.filter(agent => agent.status !== 'available'); + + const getStatusIcon = (status: A2AAgent['status']) => { + switch (status) { + case 'available': + return ; + case 'unavailable': + return ; + case 'error': + return ; + default: + return ; + } + }; + + const getStatusBadgeVariant = (status: A2AAgent['status']) => { + switch (status) { + case 'available': + return 'default' as const; + case 'unavailable': + return 'destructive' as const; + case 'error': + return 'secondary' as const; + default: + return 'outline' as const; + } + }; + + return ( + + + +
+
+ + A2A Agents +
+ +
+ + Agent-to-Agent communication endpoints for distributed AI workflows. + {agents.length > 0 && ( + + {availableAgents.length} available, {unavailableAgents.length} unavailable + + )} + +
+ +
+ {/* Agent List */} +
+ + {error && ( + + + {error} + + )} + + {agents.length === 0 && !isLoading && !error && ( +
+ +

No A2A agents configured

+

Configure agents to enable Agent-to-Agent communication

+
+ )} + + {isLoading && ( +
+ +

Loading A2A agents...

+
+ )} + +
+ {availableAgents.map(agent => ( + setSelectedAgent(agent)} + > + +
+ {agent.name} +
+ {getStatusIcon(agent.status)} + + {agent.status} + +
+
+ + {agent.description} + +
+ +
+ {agent.capabilities.length} capabilities + v{agent.version} + {agent.lastUpdated && ( +
+ + {new Date(agent.lastUpdated).toLocaleDateString()} +
+ )} +
+
+
+ ))} + + {unavailableAgents.length > 0 && availableAgents.length > 0 && ( + + )} + + {unavailableAgents.map(agent => ( + setSelectedAgent(agent)} + > + +
+ {agent.name} +
+ {getStatusIcon(agent.status)} + + {agent.status} + +
+
+ + {agent.description} + +
+ +
+ {agent.capabilities.length} capabilities + v{agent.version} +
+
+
+ ))} +
+
+
+ + {/* Agent Details */} + {selectedAgent && ( + <> + +
+ +
+
+
+

{selectedAgent.name}

+
+ {getStatusIcon(selectedAgent.status)} + + {selectedAgent.status} + +
+
+

{selectedAgent.description}

+
+ +
+
+ Version: {selectedAgent.version} +
+ {selectedAgent.author && ( +
+ Author: {selectedAgent.author} +
+ )} + {selectedAgent.license && ( +
+ License: {selectedAgent.license} +
+ )} + {selectedAgent.homepage && ( +
+ Homepage: + + + Link + +
+ )} +
+ + + +
+

+ + Capabilities ({selectedAgent.capabilities.length}) +

+
+ {selectedAgent.capabilities.map((capability, index) => ( + + + {capability.name} + + {capability.description} + + + + ))} +
+
+ + {selectedAgent.endpoints.length > 0 && ( + <> + +
+

+ Endpoints ({selectedAgent.endpoints.length}) +

+
+ {selectedAgent.endpoints.map((endpoint, index) => ( +
+ + {endpoint.method} + + {endpoint.path} + {endpoint.description && ( + + - {endpoint.description} + + )} +
+ ))} +
+
+ + )} +
+
+
+ + )} +
+
+
+ ); +} diff --git a/components/input-area.tsx b/components/input-area.tsx index 736386f..cd23683 100644 --- a/components/input-area.tsx +++ b/components/input-area.tsx @@ -17,6 +17,7 @@ import { useIsMobile } from '@/hooks/use-mobile'; import { useRef, useState, useEffect, useMemo } from 'react'; import { TokenUsage } from './token-usage'; import { MCPToolsButton } from './mcp-tools-button'; +import { A2AAgentsButton } from './a2a-agents-button'; interface CommandOption { name: string; @@ -353,6 +354,7 @@ export function InputArea({ + )} diff --git a/components/tool-call-bubble.tsx b/components/tool-call-bubble.tsx index b9ec513..ff5324e 100644 --- a/components/tool-call-bubble.tsx +++ b/components/tool-call-bubble.tsx @@ -70,7 +70,9 @@ export default function ToolCallBubble({ toolCalls }: ToolCallBubbleProps) { className="bg-[hsl(var(--thinking-bubble-content-bg))] border border-[hsl(var(--thinking-bubble-content-border))] rounded-lg shadow-sm" > @@ -52,6 +67,7 @@ export default function ToolCallBubble({ toolCalls }: ToolCallBubbleProps) { {toolCalls.map((toolCall, index) => { const isToolExpanded = expandedToolIds.has(toolCall.id || `tool-${index}`); const isMCP = isMCPTool(toolCall.function.name); + const isA2A = isA2ATool(toolCall.function.name); let parsedArguments: Record = {}; try { @@ -76,12 +92,19 @@ export default function ToolCallBubble({ toolCalls }: ToolCallBubbleProps) { className="w-full flex items-center justify-between p-3 hover:bg-[hsl(var(--thinking-bubble-hover-bg))] transition-colors duration-200" >
- {isMCP ? ( + {isA2A ? ( + + ) : isMCP ? ( ) : ( )} {toolCall.function.name} + {isA2A && ( + + A2A + + )} {isMCP && ( MCP @@ -113,7 +136,7 @@ export default function ToolCallBubble({ toolCalls }: ToolCallBubbleProps) { Arguments:
- {isMCP && typeof parsedArguments === 'object' ? ( + {(isMCP || isA2A) && typeof parsedArguments === 'object' ? (
{Object.entries(parsedArguments).map(([key, value]) => (
diff --git a/components/tool-response-bubble.tsx b/components/tool-response-bubble.tsx index 0769272..2cc7c1c 100644 --- a/components/tool-response-bubble.tsx +++ b/components/tool-response-bubble.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import { ChevronDown, ChevronUp, ArrowLeft, Wrench, CheckCircle, XCircle } from 'lucide-react'; +import { ChevronDown, ChevronUp, ArrowLeft, Wrench, CheckCircle, XCircle, Bot } from 'lucide-react'; import { CodeBlock } from './code-block'; import logger from '@/lib/logger'; -import { isMCPTool } from '@/lib/tools'; +import { isMCPTool, isA2ATool } from '@/lib/tools'; interface ToolResponse { query: string; @@ -37,12 +37,13 @@ export default function ToolResponseBubble({ response, toolName }: ToolResponseB } const isMCP = isMCPTool(toolName || ''); + const isA2A = isA2ATool(toolName || ''); let formattedResponse = null; let mcpResponse: MCPToolResponse | null = null; let isError = false; try { - if (isMCP) { + if (isMCP || isA2A) { try { mcpResponse = JSON.parse(response) as MCPToolResponse; isError = mcpResponse.isError || false; @@ -87,6 +88,7 @@ export default function ToolResponseBubble({ response, toolName }: ToolResponseB >
+ {isA2A && } {isMCP && } {isError ? ( @@ -96,6 +98,11 @@ export default function ToolResponseBubble({ response, toolName }: ToolResponseB
{toolName ? `${toolName} Response` : 'Tool Response'} + {isA2A && ( + + A2A + + )} {isMCP && ( MCP @@ -120,6 +127,12 @@ export default function ToolResponseBubble({ response, toolName }: ToolResponseB }`} >
+ {isA2A && !isError && ( +
+ + A2A Tool executed successfully +
+ )} {isMCP && !isError && (
@@ -128,7 +141,7 @@ export default function ToolResponseBubble({ response, toolName }: ToolResponseB )}
- {isMCP && formattedResponse && !formattedResponse.startsWith('{') ? ( + {(isMCP || isA2A) && formattedResponse && !formattedResponse.startsWith('{') ? (
{formattedResponse}
diff --git a/examples/docker-compose/a2a/Taskfile.yml b/examples/docker-compose/a2a/Taskfile.yml deleted file mode 100644 index 1884cc7..0000000 --- a/examples/docker-compose/a2a/Taskfile.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -version: 3 - -tasks: diff --git a/lib/api.ts b/lib/api.ts index 2fda059..4be494d 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -57,12 +57,16 @@ export async function fetchA2AAgents(session?: Session): Promise ({ ...agent, status: 'available' as const, + capabilities: { - skills: [], // TODO: I need to add it to the inference gateway - will send the AgentCard as is + skills: agent.skills || agent.capabilities?.skills || [], + extensions: agent.capabilities?.extensions || [], + pushNotifications: agent.capabilities?.pushNotifications || false, + stateTransitionHistory: agent.capabilities?.stateTransitionHistory || false, + streaming: agent.capabilities?.streaming || false, }, endpoints: [ { diff --git a/lib/tools.ts b/lib/tools.ts index 2f1b47d..0af2472 100644 --- a/lib/tools.ts +++ b/lib/tools.ts @@ -16,6 +16,16 @@ interface PageContent { } const BUILTIN_TOOLS = ['web_search', 'fetch_page']; +const A2A_TOOLS = ['query_a2a_agent_card', 'submit_task_to_agent']; + +/** + * Centralized function to detect if a tool is an A2A (Agent-to-Agent) tool + * @param toolName - The name of the tool to check + * @returns true if the tool is an A2A tool, false otherwise + */ +export const isA2ATool = (toolName: string): boolean => { + return A2A_TOOLS.includes(toolName); +}; /** * Centralized function to detect if a tool is an MCP tool @@ -28,7 +38,7 @@ export const isMCPTool = (toolName: string, tools?: SchemaChatCompletionTool[]): return !tools.some(tool => tool.function.name === toolName); } - return !BUILTIN_TOOLS.includes(toolName); + return !BUILTIN_TOOLS.includes(toolName) && !A2A_TOOLS.includes(toolName); }; export const WebSearchTool: SchemaChatCompletionTool = { diff --git a/tests/components/tool-response-bubble.test.tsx b/tests/components/tool-response-bubble.test.tsx index 07c77ff..2ef276b 100644 --- a/tests/components/tool-response-bubble.test.tsx +++ b/tests/components/tool-response-bubble.test.tsx @@ -5,7 +5,23 @@ jest.mock('@/components/code-block', () => ({ CodeBlock: ({ children }: { children: string }) =>
{children}
, })); +jest.mock('@/lib/tools', () => ({ + isMCPTool: jest.fn(), + isA2ATool: jest.fn(), +})); + +import { isMCPTool, isA2ATool } from '@/lib/tools'; + +const mockIsMCPTool = isMCPTool as jest.MockedFunction; +const mockIsA2ATool = isA2ATool as jest.MockedFunction; + describe('ToolResponseBubble', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsMCPTool.mockReturnValue(false); + mockIsA2ATool.mockReturnValue(false); + }); + const jsonResponse = JSON.stringify({ results: [ { @@ -16,42 +32,228 @@ describe('ToolResponseBubble', () => { ], }); - test('renders tool response header', () => { - render(); - const button = screen.getByRole('button'); - expect(button).toHaveTextContent('web_search Response'); + describe('Regular Tools', () => { + test('renders tool response header', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toHaveTextContent('web_search Response'); + }); + + test('renders nothing for empty content', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('renders collapsible content', () => { + const validResponse = JSON.stringify({ + results: [ + { + title: 'Test Result', + url: 'https://example.com', + snippet: 'This is a test result', + }, + ], + }); + + render(); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('web_search Response'); + }); + + test('expands and shows content when clicked', () => { + render(); + const button = screen.getByRole('button'); + + expect(screen.queryByTestId('code-block')).not.toBeInTheDocument(); + + fireEvent.click(button); + expect(screen.getByTestId('code-block')).toBeInTheDocument(); + expect(screen.getByTestId('code-block')).toHaveTextContent(/Test Result/); + }); + + test('handles error responses for regular tools', () => { + const errorResponse = JSON.stringify({ + error: 'Tool execution failed', + }); + + render(); + const button = screen.getByRole('button'); + + expect(button).toHaveClass('bg-red-50', 'border-red-200'); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + }); + + describe('A2A Tools', () => { + beforeEach(() => { + mockIsA2ATool.mockReturnValue(true); + mockIsMCPTool.mockReturnValue(false); + }); + + test('renders A2A tool badge and success message', () => { + const a2aResponse = JSON.stringify({ + content: [{ type: 'text', text: 'Task completed successfully' }], + isError: false, + }); + + render(); + + expect(screen.getByText('A2A')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('A2A Tool executed successfully')).toBeInTheDocument(); + }); + + test('handles A2A tool errors', () => { + const a2aErrorResponse = JSON.stringify({ + content: [{ type: 'text', text: 'Agent execution failed' }], + isError: true, + }); + + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('bg-red-50', 'border-red-200'); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('A2A')).toBeInTheDocument(); + }); + + test('handles plain text A2A responses', () => { + const plainTextResponse = 'Task completed successfully'; + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('A2A Tool executed successfully')).toBeInTheDocument(); + expect(screen.getByText(plainTextResponse)).toBeInTheDocument(); + }); + + test('handles malformed A2A JSON responses', () => { + const malformedResponse = 'invalid json {'; + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('A2A Tool executed successfully')).toBeInTheDocument(); + expect(screen.getByText(malformedResponse)).toBeInTheDocument(); + }); }); - test('renders nothing for empty content', () => { - const { container } = render(); - expect(container.firstChild).toBeNull(); + describe('MCP Tools', () => { + beforeEach(() => { + mockIsA2ATool.mockReturnValue(false); + mockIsMCPTool.mockReturnValue(true); + }); + + test('renders MCP tool badge and success message', () => { + const mcpResponse = JSON.stringify({ + content: [{ type: 'text', text: 'MCP operation completed' }], + isError: false, + }); + + render(); + + expect(screen.getByText('MCP')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('MCP Tool executed successfully')).toBeInTheDocument(); + }); + + test('handles MCP tool errors', () => { + const mcpErrorResponse = JSON.stringify({ + content: [{ type: 'text', text: 'MCP operation failed' }], + isError: true, + }); + + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('bg-red-50', 'border-red-200'); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('MCP')).toBeInTheDocument(); + }); + + test('handles MCP responses without content', () => { + const mcpResponse = JSON.stringify({ + isError: false, + }); + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('MCP Tool executed successfully')).toBeInTheDocument(); + }); + + test('handles plain text MCP responses', () => { + const plainTextResponse = 'MCP operation completed'; + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('MCP Tool executed successfully')).toBeInTheDocument(); + expect(screen.getByText(plainTextResponse)).toBeInTheDocument(); + }); }); - test('renders collapsible content', () => { - const validResponse = JSON.stringify({ - results: [ - { - title: 'Test Result', - url: 'https://example.com', - snippet: 'This is a test result', - }, - ], - }); - - render(); - const button = screen.getByRole('button'); - expect(button).toBeInTheDocument(); - expect(button).toHaveTextContent('web_search Response'); + describe('Response Formatting', () => { + test('displays formatted JSON for complex responses', () => { + mockIsA2ATool.mockReturnValue(false); + mockIsMCPTool.mockReturnValue(true); + + const complexResponse = JSON.stringify({ + content: [{ type: 'text', text: '{"result": "complex data"}' }], + isError: false, + }); + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByTestId('code-block')).toBeInTheDocument(); + }); + + test('displays plain text for simple responses', () => { + mockIsA2ATool.mockReturnValue(true); + mockIsMCPTool.mockReturnValue(false); + + const simpleResponse = 'Simple text response'; + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.queryByTestId('code-block')).not.toBeInTheDocument(); + expect(screen.getByText(simpleResponse)).toBeInTheDocument(); + }); }); - test('expands and shows content when clicked', () => { - render(); - const button = screen.getByRole('button'); + describe('Edge Cases', () => { + test('handles empty response gracefully', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('handles null response gracefully', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('handles undefined toolName', () => { + render(); + expect(screen.getByText('Tool Response')).toBeInTheDocument(); + }); - expect(screen.queryByTestId('code-block')).not.toBeInTheDocument(); + test('toggles expansion state correctly', () => { + render(); - fireEvent.click(button); - expect(screen.getByTestId('code-block')).toBeInTheDocument(); - expect(screen.getByTestId('code-block')).toHaveTextContent(/Test Result/); + const button = screen.getByRole('button'); + + expect(screen.queryByText('test response')).not.toBeInTheDocument(); + + fireEvent.click(button); + expect(screen.getByText('test response')).toBeInTheDocument(); + + fireEvent.click(button); + expect(screen.queryByText('test response')).not.toBeInTheDocument(); + }); }); }); diff --git a/types/a2a.ts b/types/a2a.ts index ae69911..adc9c6a 100644 --- a/types/a2a.ts +++ b/types/a2a.ts @@ -8,24 +8,41 @@ export interface A2AAgent { name: string; description: string; url: string; + version?: string; + capabilities?: A2ACapabilities; + skills?: A2ASkill[]; + provider?: A2AProvider; + defaultInputModes?: string[]; + defaultOutputModes?: string[]; status?: 'available' | 'unavailable' | 'error'; lastUpdated?: string; - capabilities?: A2ACapabilities; endpoints?: A2AEndpoint[]; - version?: string; author?: string; homepage?: string; license?: string; } +export interface A2AProvider { + organization: string; + url: string; +} + export interface A2ACapabilities { - skills: A2ASkill[]; + skills?: A2ASkill[]; + extensions?: string[]; + pushNotifications?: boolean; + stateTransitionHistory?: boolean; + streaming?: boolean; } export interface A2ASkill { id: string; name: string; description: string; + examples?: string[]; + inputModes?: string[]; + outputModes?: string[]; + tags?: string[]; input_schema?: Record; output_schema?: Record; }