diff --git a/ui/src/components/chat/AgentCallDisplay.tsx b/ui/src/components/chat/AgentCallDisplay.tsx index 6ce80d14c..3d0e75e52 100644 --- a/ui/src/components/chat/AgentCallDisplay.tsx +++ b/ui/src/components/chat/AgentCallDisplay.tsx @@ -1,12 +1,28 @@ import { useMemo, useState } from "react"; import { FunctionCall } from "@/types"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { convertToUserFriendlyName } from "@/lib/utils"; +import { convertToUserFriendlyName, isAgentToolName } from "@/lib/utils"; import { ChevronDown, ChevronUp, MessageSquare, Loader2, AlertCircle, CheckCircle } from "lucide-react"; import KagentLogo from "../kagent-logo"; +import ToolDisplay, { ToolCallStatus } from "@/components/ToolDisplay"; export type AgentCallStatus = "requested" | "executing" | "completed"; +// Constants +const MAX_NESTING_DEPTH = 10; +const NESTING_INDENT_REM = 1.5; + +interface NestedToolCall { + id: string; + call: FunctionCall; + result?: { + content: string; + is_error?: boolean; + }; + status: ToolCallStatus; + nestedCalls?: NestedToolCall[]; +} + interface AgentCallDisplayProps { call: FunctionCall; result?: { @@ -15,14 +31,31 @@ interface AgentCallDisplayProps { }; status?: AgentCallStatus; isError?: boolean; + nestedCalls?: NestedToolCall[]; // Support for nested agent/tool calls + depth?: number; // Track nesting depth for visual indentation } -const AgentCallDisplay = ({ call, result, status = "requested", isError = false }: AgentCallDisplayProps) => { +const AgentCallDisplay = ({ call, result, status = "requested", isError = false, nestedCalls = [], depth = 0 }: AgentCallDisplayProps) => { const [areInputsExpanded, setAreInputsExpanded] = useState(false); const [areResultsExpanded, setAreResultsExpanded] = useState(false); + const [areNestedCallsExpanded, setAreNestedCallsExpanded] = useState(true); // Expanded by default for better visibility const agentDisplay = useMemo(() => convertToUserFriendlyName(call.name), [call.name]); const hasResult = result !== undefined; + const hasNestedCalls = nestedCalls && nestedCalls.length > 0; + + // Protection against infinite recursion + if (depth > MAX_NESTING_DEPTH) { + console.warn(`Maximum nesting depth (${MAX_NESTING_DEPTH}) reached for agent call:`, call.name); + return ( +
+ ⚠️ Maximum nesting depth reached +
+ ); + } + + // Calculate left margin based on nesting depth + const marginLeft = depth > 0 ? `${depth * NESTING_INDENT_REM}rem` : '0'; const getStatusDisplay = () => { if (isError && status === "executing") { @@ -69,59 +102,100 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false }; return ( - - - -
- - {agentDisplay} -
-
{call.id}
-
-
- {getStatusDisplay()} -
-
- -
- - {areInputsExpanded && ( -
-
{JSON.stringify(call.args, null, 2)}
+
+ 0 ? 'border-l-4 border-l-blue-400' : ''}`}> + + +
+ + {agentDisplay} + {depth > 0 && (nested level {depth})}
- )} -
+
{call.id}
+ +
+ {getStatusDisplay()} +
+ + +
+ + {areInputsExpanded && ( +
+
{JSON.stringify(call.args, null, 2)}
+
+ )} +
-
- {status === "executing" && !hasResult && ( -
- - {agentDisplay} is responding... -
- )} - {hasResult && result?.content && ( -
- + {areResultsExpanded && ( +
+
+                      {result?.content}
+                    
+
+ )} +
+ )} +
+ + {/* Nested agent/tool calls section */} + {hasNestedCalls && ( +
+ - {areResultsExpanded && ( -
-
-                    {result?.content}
-                  
+ {areNestedCallsExpanded && ( +
+ {nestedCalls.map((nestedCall) => ( + isAgentToolName(nestedCall.call.name) ? ( + + ) : ( + + ) + ))}
)}
)} -
-
- + + +
); }; diff --git a/ui/src/components/chat/ToolCallDisplay.tsx b/ui/src/components/chat/ToolCallDisplay.tsx index d4823e881..990cd565a 100644 --- a/ui/src/components/chat/ToolCallDisplay.tsx +++ b/ui/src/components/chat/ToolCallDisplay.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Message, TextPart } from "@a2a-js/sdk"; import ToolDisplay, { ToolCallStatus } from "@/components/ToolDisplay"; import AgentCallDisplay from "@/components/chat/AgentCallDisplay"; @@ -19,6 +19,7 @@ interface ToolCallState { is_error?: boolean; }; status: ToolCallStatus; + nestedCalls?: ToolCallState[]; // Track nested agent calls } // Create a global cache to track tool calls across components @@ -163,28 +164,34 @@ const ToolCallDisplay = ({ currentMessage, allMessages }: ToolCallDisplayProps) }; }, [currentMessage]); - useEffect(() => { + // Memoize the expensive nested call building operation + const processedToolCalls = useMemo(() => { if (ownedCallIds.size === 0) { - // If the component doesn't own any call IDs, ensure toolCalls is empty and return. - if (toolCalls.size > 0) { - setToolCalls(new Map()); - } - return; + return new Map(); } - const newToolCalls = new Map(); + try { + const newToolCalls = new Map(); + const allToolCallsMap = new Map(); // Track ALL tool calls for nesting - // First pass: collect all tool call requests that this component owns + // First pass: collect all tool call requests (both owned and nested) for (const message of allMessages) { if (isToolCallRequestMessage(message)) { const requests = extractToolCallRequests(message); for (const request of requests) { - if (request.id && ownedCallIds.has(request.id)) { - newToolCalls.set(request.id, { + if (request.id) { + const toolCallState: ToolCallState = { id: request.id, call: request, - status: "requested" - }); + status: "requested", + nestedCalls: [] + }; + + allToolCallsMap.set(request.id, toolCallState); + + if (ownedCallIds.has(request.id)) { + newToolCalls.set(request.id, toolCallState); + } } } } @@ -195,8 +202,8 @@ const ToolCallDisplay = ({ currentMessage, allMessages }: ToolCallDisplayProps) if (isToolCallExecutionMessage(message)) { const results = extractToolCallResults(message); for (const result of results) { - if (result.call_id && newToolCalls.has(result.call_id)) { - const existingCall = newToolCalls.get(result.call_id)!; + if (result.call_id && allToolCallsMap.has(result.call_id)) { + const existingCall = allToolCallsMap.get(result.call_id)!; existingCall.result = { content: result.content, is_error: result.is_error @@ -217,26 +224,94 @@ const ToolCallDisplay = ({ currentMessage, allMessages }: ToolCallDisplayProps) } if (summaryMessageEncountered) { - newToolCalls.forEach((call, id) => { - // Only update owned calls that are in 'executing' state and have a result - if (call.status === "executing" && call.result && ownedCallIds.has(id)) { + allToolCallsMap.forEach((call) => { + // Only update calls that are in 'executing' state and have a result + if (call.status === "executing" && call.result) { call.status = "completed"; } }); } else { // For stored tasks without summary messages, auto-complete tool calls that have results - newToolCalls.forEach((call, id) => { - if (call.status === "executing" && call.result && ownedCallIds.has(id)) { + allToolCallsMap.forEach((call) => { + if (call.status === "executing" && call.result) { call.status = "completed"; } }); } - + + // Fourth pass: Build nested call hierarchy + // Map to track which message created which call (callId -> message that created it) + const callIdToMessage = new Map(); + + for (const message of allMessages) { + if (isToolCallRequestMessage(message)) { + const requests = extractToolCallRequests(message); + for (const request of requests) { + if (request.id) { + callIdToMessage.set(request.id, message); + } + } + } + } + + // Build parent-child relationships + // A call B is nested under call A if: + // - Both A and B are agent calls + // - B's message appeared after A's message but before A completed + // - B is not in ownedCallIds (not a top-level call) + allToolCallsMap.forEach((parentCall) => { + if (!isAgentToolName(parentCall.call.name)) return; + + const nestedCallsList: ToolCallState[] = []; + const parentMessage = callIdToMessage.get(parentCall.id); + if (!parentMessage) return; + + const parentIndex = allMessages.indexOf(parentMessage); + + // Find where parent completed (if it did) + let parentCompletionIndex = allMessages.length; + for (let i = parentIndex + 1; i < allMessages.length; i++) { + if (isToolCallExecutionMessage(allMessages[i])) { + const results = extractToolCallResults(allMessages[i]); + if (results.some(r => r.call_id === parentCall.id)) { + parentCompletionIndex = i; + break; + } + } + } + + // Look for child calls between parent start and completion + allToolCallsMap.forEach((potentialChild, childId) => { + // Skip if it's the parent itself or if it's a top-level owned call + if (childId === parentCall.id || ownedCallIds.has(childId)) return; + + const childMessage = callIdToMessage.get(childId); + if (!childMessage) return; + + const childIndex = allMessages.indexOf(childMessage); + + // Child must appear after parent but before parent completes + if (childIndex > parentIndex && childIndex < parentCompletionIndex) { + nestedCallsList.push(potentialChild); + } + }); + + parentCall.nestedCalls = nestedCallsList; + }); + + return newToolCalls; + } catch (error) { + console.error("Error building nested call hierarchy:", error); + return new Map(); // Return empty map on error + } + }, [allMessages, ownedCallIds]); + + // Update state when processed calls change + useEffect(() => { // Only update state if there's a change, to prevent unnecessary re-renders. - // This is a shallow comparison, but sufficient for this case. - let changed = newToolCalls.size !== toolCalls.size; + let changed = processedToolCalls.size !== toolCalls.size; if (!changed) { - for (const [key, value] of newToolCalls) { + for (const [key, value] of processedToolCalls) { const oldVal = toolCalls.get(key); if (!oldVal || oldVal.status !== value.status || oldVal.result?.content !== value.result?.content) { changed = true; @@ -246,10 +321,9 @@ const ToolCallDisplay = ({ currentMessage, allMessages }: ToolCallDisplayProps) } if (changed) { - setToolCalls(newToolCalls); + setToolCalls(processedToolCalls); } - - }, [allMessages, ownedCallIds, toolCalls]); + }, [processedToolCalls, toolCalls]); // If no tool calls to display for this message, return null const currentDisplayableCalls = Array.from(toolCalls.values()).filter(call => ownedCallIds.has(call.id)); @@ -265,6 +339,7 @@ const ToolCallDisplay = ({ currentMessage, allMessages }: ToolCallDisplayProps) result={toolCall.result} status={toolCall.status} isError={toolCall.result?.is_error} + nestedCalls={toolCall.nestedCalls} /> ) : ( ({ + __esModule: true, + default: ({ call }: any) =>
Tool: {call.name}
, +})); + +describe('AgentCallDisplay - Nested Rendering', () => { + const basicCall: FunctionCall = { + id: 'test-1', + name: 'kagent__NS__test-agent', + args: { query: 'test' }, + }; + + test('renders agent call without nesting at depth 0', () => { + render(); + + expect(screen.getByText(/kagent\/test-agent/)).toBeInTheDocument(); + expect(screen.queryByText(/nested level/)).not.toBeInTheDocument(); + }); + + test('renders agent call with nested level indicator at depth 1', () => { + render(); + + expect(screen.getByText(/nested level 1/)).toBeInTheDocument(); + }); + + test('renders agent call with nested level indicator at depth 2', () => { + render(); + + expect(screen.getByText(/nested level 2/)).toBeInTheDocument(); + }); + + test('displays "Delegated Calls" section when nestedCalls provided', () => { + const nestedCalls = [ + { + id: 'nested-1', + call: { + id: 'nested-1', + name: 'kagent__NS__sub-agent', + args: {}, + }, + status: 'completed' as const, + }, + ]; + + render(); + + expect(screen.getByText(/Delegated Calls \(1\)/)).toBeInTheDocument(); + }); + + test('displays correct count for multiple nested calls', () => { + const nestedCalls = [ + { + id: 'nested-1', + call: { + id: 'nested-1', + name: 'kagent__NS__sub-agent-1', + args: {}, + }, + status: 'completed' as const, + }, + { + id: 'nested-2', + call: { + id: 'nested-2', + name: 'kagent__NS__sub-agent-2', + args: {}, + }, + status: 'completed' as const, + }, + { + id: 'nested-3', + call: { + id: 'nested-3', + name: 'read_file', // Tool call + args: {}, + }, + status: 'completed' as const, + }, + ]; + + render(); + + expect(screen.getByText(/Delegated Calls \(3\)/)).toBeInTheDocument(); + }); + + test('does not display "Delegated Calls" section when no nested calls', () => { + render(); + + expect(screen.queryByText(/Delegated Calls/)).not.toBeInTheDocument(); + }); + + test('renders nested tool calls within delegated section', () => { + const nestedCalls = [ + { + id: 'tool-1', + call: { + id: 'tool-1', + name: 'read_file', + args: { path: '/test' }, + }, + status: 'completed' as const, + }, + ]; + + render(); + + // Should render ToolDisplay component for non-agent calls + expect(screen.getByTestId('tool-tool-1')).toBeInTheDocument(); + }); + + test('displays different status indicators correctly', () => { + const { rerender } = render(); + expect(screen.getByText(/Delegating/)).toBeInTheDocument(); + + rerender(); + expect(screen.getByText(/Awaiting response/)).toBeInTheDocument(); + + rerender(); + expect(screen.getByText(/Completed/)).toBeInTheDocument(); + }); + + test('displays error status correctly', () => { + const result = { + content: 'Error occurred', + is_error: true, + }; + + render(); + + expect(screen.getByText(/Failed/)).toBeInTheDocument(); + }); + + test('applies correct styling for nested depth', () => { + const { container } = render( + + ); + + // Check for border styling on nested calls + const card = container.querySelector('.border-l-4'); + expect(card).toBeInTheDocument(); + }); + + test('recursive nesting - nested call can have its own nested calls', () => { + const deeplyNestedCalls = [ + { + id: 'level2', + call: { + id: 'level2', + name: 'kagent__NS__level2-agent', + args: {}, + }, + status: 'completed' as const, + nestedCalls: [ + { + id: 'level3', + call: { + id: 'level3', + name: 'kagent__NS__level3-agent', + args: {}, + }, + status: 'completed' as const, + }, + ], + }, + ]; + + render( + + ); + + // Should have delegated calls sections (multiple due to recursion) + const delegatedSections = screen.getAllByText(/Delegated Calls \(1\)/); + expect(delegatedSections.length).toBeGreaterThan(0); + }); + + test('displays input/output sections', () => { + const callWithArgs: FunctionCall = { + id: 'test-1', + name: 'kagent__NS__test-agent', + args: { query: 'search term', limit: 10 }, + }; + + const result = { + content: 'Search completed successfully', + is_error: false, + }; + + render(); + + // Input and Output sections should be present + expect(screen.getByText(/Input/)).toBeInTheDocument(); + expect(screen.getByText(/Output/)).toBeInTheDocument(); + }); + + test('enforces maximum nesting depth limit', () => { + const MAX_DEPTH = 10; + + render(); + + // Should display warning instead of rendering normally + expect(screen.getByText(/Maximum nesting depth reached/)).toBeInTheDocument(); + expect(screen.queryByText(/kagent\/test-agent/)).not.toBeInTheDocument(); + }); + + test('renders normally at maximum allowed depth', () => { + const MAX_DEPTH = 10; + + render(); + + // Should render normally at exactly max depth + expect(screen.getByText(/kagent\/test-agent/)).toBeInTheDocument(); + expect(screen.queryByText(/Maximum nesting depth reached/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/chat/__tests__/ToolCallDisplay.test.tsx b/ui/src/components/chat/__tests__/ToolCallDisplay.test.tsx new file mode 100644 index 000000000..2cfe33b9b --- /dev/null +++ b/ui/src/components/chat/__tests__/ToolCallDisplay.test.tsx @@ -0,0 +1,330 @@ +import { describe, test, expect } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Message } from '@a2a-js/sdk'; +import ToolCallDisplay from '../ToolCallDisplay'; + +// Mock the child components +jest.mock('../AgentCallDisplay', () => ({ + __esModule: true, + default: ({ call, nestedCalls, depth }: any) => ( +
+ Agent: {call.name} + {nestedCalls && nestedCalls.length > 0 && ( +
+ Nested: {nestedCalls.length} +
+ )} +
+ ), +})); + +jest.mock('@/components/ToolDisplay', () => ({ + __esModule: true, + default: ({ call }: any) =>
Tool: {call.name}
, +})); + +describe('ToolCallDisplay - Nested Agent Calls', () => { + test('displays simple agent call without nesting', () => { + const currentMessage: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'call-1', + name: 'kagent__NS__test-agent', + args: { query: 'test' }, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const allMessages = [currentMessage]; + + render(); + + expect(screen.getByTestId('agent-call-call-1')).toBeInTheDocument(); + expect(screen.getByText(/Agent: kagent__NS__test-agent/)).toBeInTheDocument(); + }); + + test('builds nested call hierarchy for agent->subagent calls', () => { + const parentCall: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'parent-1', + name: 'kagent__NS__main-agent', + args: {}, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const nestedCall: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'nested-1', + name: 'kagent__NS__sub-agent', + args: {}, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const parentResult: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'parent-1', + name: 'kagent__NS__main-agent', + response: { result: 'done' }, + }, + metadata: { + kagent_type: 'function_response', + }, + }, + ], + }; + + const allMessages = [parentCall, nestedCall, parentResult]; + + render(); + + // Parent call should be displayed + expect(screen.getByTestId('agent-call-parent-1')).toBeInTheDocument(); + + // Should have nested calls indicator + expect(screen.getByTestId('nested-calls-parent-1')).toBeInTheDocument(); + expect(screen.getByText(/Nested: 1/)).toBeInTheDocument(); + }); + + test('handles multi-level nesting (agent->subagent->subagent)', () => { + const level1Call: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'level1', + name: 'kagent__NS__main', + args: {}, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const level2Call: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'level2', + name: 'kagent__NS__research', + args: {}, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const level3Call: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'level3', + name: 'kagent__NS__data', + args: {}, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const level1Result: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'level1', + name: 'kagent__NS__main', + response: { result: 'complete' }, + }, + metadata: { + kagent_type: 'function_response', + }, + }, + ], + }; + + const allMessages = [level1Call, level2Call, level3Call, level1Result]; + + render(); + + // Level 1 should be displayed with nested calls + expect(screen.getByTestId('agent-call-level1')).toBeInTheDocument(); + expect(screen.getByTestId('nested-calls-level1')).toBeInTheDocument(); + }); + + test('handles regular tool calls within agent calls', () => { + const agentCall: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'agent-1', + name: 'kagent__NS__agent', + args: {}, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const toolCall: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'tool-1', + name: 'read_file', // No __NS__ = regular tool + args: { path: '/test' }, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const agentResult: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'agent-1', + name: 'kagent__NS__agent', + response: { result: 'done' }, + }, + metadata: { + kagent_type: 'function_response', + }, + }, + ], + }; + + const allMessages = [agentCall, toolCall, agentResult]; + + render(); + + // Should display agent call with nested tool + expect(screen.getByTestId('agent-call-agent-1')).toBeInTheDocument(); + }); + + test('does not nest calls that appear after parent completion', () => { + const parentCall: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'parent', + name: 'kagent__NS__parent', + args: {}, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const parentResult: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'parent', + name: 'kagent__NS__parent', + response: { result: 'done' }, + }, + metadata: { + kagent_type: 'function_response', + }, + }, + ], + }; + + const afterCall: Message = { + kind: 'message', + role: 'assistant', + parts: [ + { + kind: 'data', + data: { + id: 'after', + name: 'kagent__NS__after', + args: {}, + }, + metadata: { + kagent_type: 'function_call', + }, + }, + ], + }; + + const allMessages = [parentCall, parentResult, afterCall]; + + render(); + + // Parent should not have nested calls (call came after completion) + expect(screen.getByTestId('agent-call-parent')).toBeInTheDocument(); + expect(screen.queryByTestId('nested-calls-parent')).not.toBeInTheDocument(); + }); +});