diff --git a/ui/src/components/chat/AgentCallDisplay.tsx b/ui/src/components/chat/AgentCallDisplay.tsx index 0c8dcd1be..76c4d17a9 100644 --- a/ui/src/components/chat/AgentCallDisplay.tsx +++ b/ui/src/components/chat/AgentCallDisplay.tsx @@ -1,14 +1,17 @@ -import { createContext, useContext, useMemo, useState, useEffect } from "react"; -import { FunctionCall, TokenStats } from "@/types"; +import { createContext, useContext, useMemo, useState, useEffect, useCallback } from "react"; +import { FunctionCall, TokenStats, AgentResponse } 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, Activity } from "lucide-react"; import KagentLogo from "../kagent-logo"; import TokenStatsTooltip from "@/components/chat/TokenStatsTooltip"; import { getSubagentSessionWithEvents } from "@/app/actions/sessions"; +import { getAgent } from "@/app/actions/agents"; import { Message, Task } from "@a2a-js/sdk"; import { extractMessagesFromTasks } from "@/lib/messageHandlers"; import ChatMessage from "@/components/chat/ChatMessage"; +import ToolCallGroup, { groupToolCallMessages, buildToolCallResultsIndex, collectPendingApprovalIds } from "@/components/chat/ToolCallGroup"; +import { ChatMcpAppsProvider, useChatMcpApps } from "@/components/chat/ChatMcpAppsContext"; // Track and avoid too deep nested agent viewing to avoid UI issues // In theory this works for infinite depth @@ -32,12 +35,80 @@ interface AgentCallDisplayProps { interface SubagentActivityPanelProps { sessionId: string; isComplete: boolean; + /** The agent tool name (namespace__NS__agent_name) used to resolve the subagent's MCP apps. */ + agentToolName: string; } -function SubagentActivityPanel({ sessionId, isComplete }: SubagentActivityPanelProps) { +/** + * Renders the subagent transcript with the MCP apps registry from the + * ENCLOSING provider — the panel nests a ChatMcpAppsProvider scoped to the + * subagent so its MCP app tool calls (which the parent agent doesn't know + * about) resolve to their interactive UI. Tool-call runs fold into the same + * collapsible groups as the main chat. + */ +function SubagentMessageList({ messages }: { messages: Message[] }) { + const { getMcpAppForTool } = useChatMcpApps(); + + const isStandaloneToolName = useCallback( + (toolName: string) => isAgentToolName(toolName) || !!getMcpAppForTool(toolName), + [getMcpAppForTool], + ); + const renderItems = useMemo(() => { + const pendingApprovalIds = collectPendingApprovalIds(messages); + return groupToolCallMessages(messages, { isStandaloneToolName, pendingApprovalIds }); + }, [messages, isStandaloneToolName]); + const resultsByCallId = useMemo(() => buildToolCallResultsIndex(messages), [messages]); + + const renderMsg = (msg: Message) => ( + + ); + + return ( +
+ {renderItems.map((item) => + item.kind === "group" ? ( + + {item.messages.map(renderMsg)} + + ) : ( + renderMsg(item.message) + ), + )} +
+ ); +} + +function SubagentActivityPanel({ sessionId, isComplete, agentToolName }: SubagentActivityPanelProps) { const [messages, setMessages] = useState([]); const [error, setError] = useState(null); const [waiting, setWaiting] = useState(true); + const [subagent, setSubagent] = useState(null); + + // Resolve the subagent so its MCP app tools render their interactive UI. + // Tool names encode namespace/name as namespace__NS__name with hyphens + // replaced by underscores; convertToUserFriendlyName reverses both. + useEffect(() => { + if (!isAgentToolName(agentToolName)) return; + const [namespace, name] = convertToUserFriendlyName(agentToolName).split("/"); + if (!namespace || !name) return; + let cancelled = false; + getAgent(name, namespace) + .then((resp) => { + if (!cancelled && resp.data) setSubagent(resp.data); + }) + .catch(() => { + // Fall back to the parent chat's app registry. + }); + return () => { + cancelled = true; + }; + }, [agentToolName]); useEffect(() => { let cancelled = false; @@ -110,18 +181,10 @@ function SubagentActivityPanel({ sessionId, isComplete }: SubagentActivityPanelP ); } - return ( -
- {messages.map((msg) => ( - - ))} -
- ); + const list = ; + // Scope the MCP apps registry to the subagent's own tool servers; without + // the subagent (fetch pending/failed) fall through to the parent's provider. + return subagent ? {list} : list; } const AgentCallDisplay = ({ call, result, status = "requested", isError = false, tokenStats, subagentSessionId }: AgentCallDisplayProps) => { @@ -245,7 +308,7 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false, {activityExpanded && (
- +
)} diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index 174bb362b..0402512f9 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -1,7 +1,7 @@ "use client"; import type React from "react"; -import { useState, useRef, useEffect, useMemo } from "react"; +import { useState, useRef, useEffect, useMemo, useCallback } from "react"; import { ArrowBigUp, X, Loader2, Mic, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -14,6 +14,8 @@ import { useSpeechRecognition } from "@/hooks/useSpeechRecognition"; import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; import ChatMessage from "@/components/chat/ChatMessage"; +import ToolCallGroup, { groupToolCallMessages, buildToolCallResultsIndex, collectPendingApprovalIds } from "@/components/chat/ToolCallGroup"; +import { isAgentToolName } from "@/lib/utils"; import ChatMinimap from "@/components/chat/ChatMinimap"; import StreamingMessage from "./StreamingMessage"; import SessionTokenStatsDisplay from "@/components/chat/TokenStats"; @@ -151,6 +153,31 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const allMessages = useMemo(() => [...storedMessages, ...streamingMessages], [storedMessages, streamingMessages]); + // Fold consecutive runs of tool-call messages into collapsible groups. + // MCP app calls render interactive UI and subagent calls render the + // AgentCallDisplay activity panel, so both stay outside the groups. + // Approval requests stay outside only while undecided (pendingDecisions + // makes decided approvals fold in immediately, before the server responds). + const isStandaloneToolName = useCallback( + (toolName: string) => isAgentToolName(toolName) || !!getMcpAppForTool(toolName), + [getMcpAppForTool], + ); + const pendingApprovalIds = useMemo( + () => collectPendingApprovalIds(allMessages, pendingDecisions), + [allMessages, pendingDecisions], + ); + const groupingOptions = useMemo( + () => ({ isStandaloneToolName, pendingDecisions, pendingApprovalIds }), + [isStandaloneToolName, pendingDecisions, pendingApprovalIds], + ); + // Group over the COMBINED transcript (stored + streaming) so a run that + // spans the boundary — e.g. an approval request persisted at + // input_required and its tool result arriving on the post-approval stream — + // folds into a single group instead of two. + const renderItems = useMemo(() => groupToolCallMessages(allMessages, groupingOptions), [allMessages, groupingOptions]); + // Shared call_id -> is_error lookup so each group summary is O(group size). + const toolResultsByCallId = useMemo(() => buildToolCallResultsIndex(allMessages), [allMessages]); + const { handleMessageEvent } = useMemo(() => createMessageHandlers({ setMessages: setStreamingMessages, setIsStreaming, @@ -1026,6 +1053,36 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se } }; + const renderChatMessage = (message: Message, key: string) => ( + + ); + + const renderMessageItems = (items: ReturnType, keyPrefix: string) => + items.map(item => + item.kind === "group" ? ( +
+ + {item.messages.map((message, j) => renderChatMessage(message, `${keyPrefix}-${item.startIndex + j}`))} + +
+ ) : ( +
+ {renderChatMessage(item.message, `${keyPrefix}-msg-${item.startIndex}`)} +
+ ) + ); + if (sessionNotFound) { return (
@@ -1069,39 +1126,8 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se
) : ( <> - {/* Display stored messages from session */} - {storedMessages.map((message, index) => { - return
- -
- })} - - {/* Display streaming messages */} - {streamingMessages.map((message, index) => { - return
- -
- })} + {/* Display all messages (stored + streaming) as one grouped list */} + {renderMessageItems(renderItems, "msg")} {isStreaming && (
diff --git a/ui/src/components/chat/ToolCallDisplay.tsx b/ui/src/components/chat/ToolCallDisplay.tsx index cfe3491bd..18e7afbe5 100644 --- a/ui/src/components/chat/ToolCallDisplay.tsx +++ b/ui/src/components/chat/ToolCallDisplay.tsx @@ -1,9 +1,16 @@ import React, { useMemo } from "react"; -import { Message, TextPart } from "@a2a-js/sdk"; +import { Message } from "@a2a-js/sdk"; import ToolDisplay, { ToolCallStatus } from "@/components/ToolDisplay"; import AgentCallDisplay, { AgentCallStatus } from "@/components/chat/AgentCallDisplay"; import { isAgentToolName } from "@/lib/utils"; -import { ADKMetadata, ProcessedToolCallData, ProcessedToolResultData, ToolResponseData, normalizeToolResultToText, getMetadataValue } from "@/lib/messageHandlers"; +import { ADKMetadata, ProcessedToolCallData, getMetadataValue } from "@/lib/messageHandlers"; +import { + isToolCallRequestMessage, + isToolCallExecutionMessage, + isToolCallSummaryMessage, + extractToolCallRequests, + extractToolCallResults, +} from "@/lib/toolCallExtraction"; import { FunctionCall, ToolDecision, TokenStats } from "@/types"; import type { ChatMcpAppTool } from "@/components/chat/ChatMcpAppsContext"; @@ -29,154 +36,6 @@ interface ToolCallState { subagentSessionId?: string; } -// Helper functions to work with A2A SDK Messages -const isToolCallRequestMessage = (message: Message): boolean => { - // Check data parts for type metadata first - const hasDataParts = message.parts?.some(part => { - if (part.kind === "data" && part.metadata) { - return getMetadataValue(part.metadata as Record, "type") === "function_call"; - } - return false; - }) || false; - - // Fallback to streaming format check - if (!hasDataParts) { - const metadata = message.metadata as ADKMetadata; - return metadata?.originalType === "ToolCallRequestEvent" || metadata?.originalType === "ToolApprovalRequest"; - } - - return hasDataParts; -}; - -const isToolCallExecutionMessage = (message: Message): boolean => { - const hasDataParts = message.parts?.some(part => { - if (part.kind === "data" && part.metadata) { - return getMetadataValue(part.metadata as Record, "type") === "function_response"; - } - return false; - }) || false; - - // Fallback to streaming format check - if (!hasDataParts) { - const metadata = message.metadata as ADKMetadata; - return metadata?.originalType === "ToolCallExecutionEvent"; - } - - return hasDataParts; -}; - -const isToolCallSummaryMessage = (message: Message): boolean => { - const metadata = message.metadata as ADKMetadata; - return metadata?.originalType === "ToolCallSummaryMessage"; -}; - -const extractToolCallRequests = (message: Message): FunctionCall[] => { - if (!isToolCallRequestMessage(message)) return []; - - // Check for stored task format first (data parts) - const dataParts = message.parts?.filter(part => part.kind === "data") || []; - const functionCalls: FunctionCall[] = []; - - for (const part of dataParts) { - if (part.metadata) { - if (getMetadataValue(part.metadata as Record, "type") === "function_call") { - const data = part.data as unknown as FunctionCall; - // Skip ADK internal function calls (confirmation/auth) and ask_user (has its own display) - if ( - data.name === "adk_request_confirmation" || - data.name === "adk_request_credential" || - data.name === "ask_user" - ) { - continue; - } - functionCalls.push({ - id: data.id, - name: data.name, - args: data.args ?? {}, - }); - } - } - } - - // If we found function calls in data parts, return them - if (functionCalls.length > 0) { - return functionCalls; - } - - // Try streaming format (metadata or text content) - const textParts = message.parts?.filter(part => part.kind === "text") || []; - const content = textParts.map(part => (part as TextPart).text).join(""); - - try { - // Tool call data might be stored as JSON in content or metadata - const metadata = message.metadata as ADKMetadata; - const toolCallData = metadata?.toolCallData || JSON.parse(content || "[]"); - return Array.isArray(toolCallData) - ? toolCallData.filter(tc => - tc.name !== "adk_request_confirmation" && - tc.name !== "adk_request_credential" && - tc.name !== "ask_user" - ) - : []; - } catch { - return []; - } -}; - -const extractToolCallResults = (message: Message): ProcessedToolResultData[] => { - if (!isToolCallExecutionMessage(message)) return []; - - // Check for stored task format first (data parts) - const dataParts = message.parts?.filter(part => part.kind === "data") || []; - const toolResults: ProcessedToolResultData[] = []; - - for (const part of dataParts) { - if (part.metadata) { - if (getMetadataValue(part.metadata as Record, "type") === "function_response") { - const data = part.data as unknown as ToolResponseData; - - // For agent tool responses we receive { result, subagent_session_id } as FunctionResponse.response. - const textContent = normalizeToolResultToText(data); - let subagentSessionId: string | undefined; - if (isAgentToolName(data.name)) { - const responseObj = data.response as Record | undefined; - if (responseObj && typeof responseObj.subagent_session_id === "string") { - subagentSessionId = responseObj.subagent_session_id; - } - } - - toolResults.push({ - call_id: data.id, - name: data.name, - content: textContent, - is_error: data.response?.isError || false, - raw_result: data.response?.result ?? data.response, - ...(subagentSessionId ? { subagent_session_id: subagentSessionId } : {}), - }); - } - } - } - - // If we found tool results in data parts, return them - if (toolResults.length > 0) { - return toolResults; - } - - // Try streaming format (metadata or text content) - const textParts = message.parts?.filter(part => part.kind === "text") || []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const content = textParts.map(part => (part as any).text).join(""); - - try { - const metadata = message.metadata as ADKMetadata; - const resultData = metadata?.toolResultData || JSON.parse(content || "[]"); - return Array.isArray(resultData) ? resultData : []; - } catch { - return []; - } -}; - - const ToolCallDisplay = ({ currentMessage, allMessages, onApprove, onReject, pendingDecisions, getMcpAppForTool, onMcpAppSendMessage }: ToolCallDisplayProps) => { // Determine which tool call IDs this component instance "owns" by finding, // for each ID introduced by currentMessage, whether currentMessage is the diff --git a/ui/src/components/chat/ToolCallGroup.stories.tsx b/ui/src/components/chat/ToolCallGroup.stories.tsx new file mode 100644 index 000000000..dcf4ecb4c --- /dev/null +++ b/ui/src/components/chat/ToolCallGroup.stories.tsx @@ -0,0 +1,125 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import type { Message } from "@a2a-js/sdk"; +import ToolCallGroup, { buildToolCallResultsIndex } from "./ToolCallGroup"; +import ToolCallDisplay from "./ToolCallDisplay"; + +const meta = { + title: "Chat/ToolCallGroup", + component: ToolCallGroup, + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const requestMessage = (id: string, name: string, args: Record): Message => ({ + kind: "message", + messageId: `req-${id}`, + role: "agent", + parts: [ + { + kind: "data", + data: { id, name, args }, + metadata: { adk_type: "function_call" }, + }, + ], +}); + +const responseMessage = (id: string, name: string, result: string, isError = false): Message => ({ + kind: "message", + messageId: `res-${id}`, + role: "agent", + parts: [ + { + kind: "data", + data: { id, name, response: { result, isError } }, + metadata: { adk_type: "function_response" }, + }, + ], +}); + +const buildTranscript = (calls: Array<{ id: string; name: string; args: Record; result?: string; isError?: boolean }>) => { + const messages: Message[] = []; + for (const c of calls) { + messages.push(requestMessage(c.id, c.name, c.args)); + if (c.result !== undefined) { + messages.push(responseMessage(c.id, c.name, c.result, c.isError)); + } + } + return messages; +}; + +const renderGroup = (messages: Message[]) => ( + + {messages.map((m, i) => ( + + ))} + +); + +const allPassing = buildTranscript([ + { id: "c1", name: "k8s_get_resources", args: { kind: "Pod", namespace: "kagent" }, result: "3 pods running" }, + { id: "c2", name: "k8s_get_events", args: { namespace: "kagent" }, result: "No warning events" }, + { id: "c3", name: "k8s_describe_resource", args: { name: "kagent-controller" }, result: "Deployment healthy" }, +]); + +export const AllPassing: Story = { + args: { messages: allPassing, resultsByCallId: buildToolCallResultsIndex(allPassing), children: null }, + render: (args) => renderGroup(args.messages), +}; + +const withFailures = buildTranscript([ + { id: "c1", name: "k8s_get_resources", args: { kind: "Pod" }, result: "3 pods running" }, + { id: "c2", name: "k8s_apply_manifest", args: { manifest: "..." }, result: "error: forbidden", isError: true }, + { id: "c3", name: "k8s_get_events", args: {}, result: "ok" }, + { id: "c4", name: "helm_list", args: {}, result: "connection refused", isError: true }, +]); + +export const WithFailures: Story = { + args: { messages: withFailures, resultsByCallId: buildToolCallResultsIndex(withFailures), children: null }, + render: (args) => renderGroup(args.messages), +}; + +const inFlight = buildTranscript([ + { id: "c1", name: "k8s_get_resources", args: { kind: "Pod" }, result: "3 pods running" }, + { id: "c2", name: "k8s_get_events", args: {}, result: "ok" }, + { id: "c3", name: "helm_list", args: {} }, // no result yet +]); + +export const Running: Story = { + args: { messages: inFlight, resultsByCallId: buildToolCallResultsIndex(inFlight), children: null }, + render: (args) => renderGroup(args.messages), +}; + +const single = buildTranscript([ + { id: "c1", name: "k8s_get_resources", args: { kind: "Pod", namespace: "default" }, result: "12 pods" }, +]); + +export const SingleCall: Story = { + args: { messages: single, resultsByCallId: buildToolCallResultsIndex(single), children: null }, + render: (args) => renderGroup(args.messages), +}; + +const many = buildTranscript( + Array.from({ length: 9 }, (_, i) => ({ + id: `c${i}`, + name: ["k8s_get_resources", "k8s_get_events", "k8s_describe_resource", "helm_list", "helm_get_values", "k8s_get_logs"][i % 6], + args: { attempt: i }, + result: i === 4 ? "error: timeout" : "ok", + isError: i === 4, + })), +); + +export const ManyCalls: Story = { + args: { messages: many, resultsByCallId: buildToolCallResultsIndex(many), children: null }, + render: (args) => renderGroup(args.messages), +}; diff --git a/ui/src/components/chat/ToolCallGroup.tsx b/ui/src/components/chat/ToolCallGroup.tsx new file mode 100644 index 000000000..80543fcfd --- /dev/null +++ b/ui/src/components/chat/ToolCallGroup.tsx @@ -0,0 +1,342 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { Message } from "@a2a-js/sdk"; +import { ChevronRight, Wrench, Loader2, CheckCircle, XCircle } from "lucide-react"; +import { cn, convertToUserFriendlyName } from "@/lib/utils"; +import { extractToolCallRequests, extractToolCallResults } from "@/lib/toolCallExtraction"; +import { ADKMetadata, getMetadataValue } from "@/lib/messageHandlers"; +import { ToolDecision } from "@/types"; + +/** + * A render item produced by {@link groupToolCallMessages}: either a single + * regular chat message or a run of consecutive tool-call messages that should + * be rendered inside one collapsible {@link ToolCallGroup}. + */ +export type ChatRenderItem = + | { kind: "single"; message: Message; startIndex: number } + | { kind: "group"; messages: Message[]; startIndex: number }; + +/** Message types that always render standalone, even when tool-related. */ +const NEVER_GROUPED_TYPES = new Set(["AskUserRequest"]); + +/** Tool-related originalType values that carry no data parts (streaming format). */ +const STREAMING_TOOL_TYPES = new Set([ + "ToolCallRequestEvent", + "ToolCallExecutionEvent", + "ToolCallSummaryMessage", +]); + +export interface GroupingOptions { + /** Tools that always render standalone (e.g. MCP apps with interactive UI). */ + isStandaloneToolName?: (toolName: string) => boolean; + /** Local (optimistic) approval decisions keyed by tool call id. */ + pendingDecisions?: Record; + /** + * Call ids still awaiting a user decision, built from the FULL transcript + * with {@link collectPendingApprovalIds}. The approve/reject card renders + * under the first message that introduces a call id (which is usually the + * plain request message, not the ToolApprovalRequest itself), so every + * message referencing a pending id must stay outside the group. + */ + pendingApprovalIds?: ReadonlySet; +} + +/** + * True when every tool call in a ToolApprovalRequest message has been decided + * — either persisted in `metadata.approvalDecision` (uniform string or + * per-tool map) or recorded locally in `pendingDecisions`. Undecided + * approvals must stay visible outside any collapsed group. + */ +const isApprovalResolved = (message: Message, pendingDecisions?: Record): boolean => { + const requests = extractToolCallRequests(message); + if (requests.length === 0) return false; + + const rawDecision = (message.metadata as ADKMetadata)?.approvalDecision; + return requests.every(request => { + if (!request.id) return false; + if (typeof rawDecision === "object" && rawDecision !== null) { + if ((rawDecision as Record)[request.id]) return true; + } else if (rawDecision) { + return true; + } + return !!pendingDecisions?.[request.id]; + }); +}; + +/** + * Collect the call ids of every unresolved ToolApprovalRequest in the + * transcript. Compute once over the full message list (memoized in the + * parent) and pass to {@link groupToolCallMessages} so that the request and + * result messages tied to a pending approval stay visible outside groups. + */ +export const collectPendingApprovalIds = ( + messages: Message[], + pendingDecisions?: Record, +): Set => { + const ids = new Set(); + for (const message of messages) { + if ((message.metadata as ADKMetadata)?.originalType !== "ToolApprovalRequest") continue; + if (isApprovalResolved(message, pendingDecisions)) continue; + for (const request of extractToolCallRequests(message)) { + if (request.id) ids.add(request.id); + } + } + return ids; +}; + +/** + * How a message participates in tool-call grouping: + * - "group": folds into the current ToolCallGroup run. + * - "standalone": tool-related but must stay visible (MCP apps, ask-user, + * undecided approvals). Rendered outside the group WITHOUT + * breaking the run — models issue parallel tool batches where + * e.g. an MCP app call or an approval is interleaved with + * regular calls, and breaking the run would shatter one + * logical batch into several groups. + * - "other": regular chat content (text, user messages); closes the run. + */ +type ToolMessageKind = "group" | "standalone" | "other"; + +const classifyToolMessage = (message: Message, options?: GroupingOptions): ToolMessageKind => { + if (message.role === "user") return "other"; + + const metadata = message.metadata as ADKMetadata; + const originalType = metadata?.originalType; + if (originalType && NEVER_GROUPED_TYPES.has(originalType)) return "standalone"; + + const isToolMessage = + (originalType !== undefined && STREAMING_TOOL_TYPES.has(originalType)) || + originalType === "ToolApprovalRequest" || + (message.parts?.some(part => { + if (part.kind === "data" && part.metadata) { + const partType = getMetadataValue(part.metadata as Record, "type"); + return partType === "function_call" || partType === "function_response"; + } + return false; + }) ?? false); + if (!isToolMessage) return "other"; + + if (originalType === "ToolApprovalRequest" && !isApprovalResolved(message, options?.pendingDecisions)) { + return "standalone"; + } + + const { isStandaloneToolName, pendingApprovalIds } = options ?? {}; + if (isStandaloneToolName || pendingApprovalIds?.size) { + const requests = extractToolCallRequests(message); + const results = extractToolCallResults(message); + + // MCP app calls render interactive UI — always standalone. + if (isStandaloneToolName) { + const hasStandaloneCall = + requests.some(request => isStandaloneToolName(request.name)) || + results.some(result => result.name && isStandaloneToolName(result.name)); + if (hasStandaloneCall) return "standalone"; + } + + // The approve/reject card renders under the first message introducing a + // call id — keep every message tied to a pending approval outside groups. + if (pendingApprovalIds?.size) { + const referencesPendingApproval = + requests.some(request => request.id && pendingApprovalIds.has(request.id)) || + results.some(result => result.call_id && pendingApprovalIds.has(result.call_id)); + if (referencesPendingApproval) return "standalone"; + } + } + + return "group"; +}; + +/** + * True when a message renders as tool-call chrome (request/result/summary) and + * can be folded into a ToolCallGroup. Ask-user messages and *undecided* + * approval requests are excluded: they demand user attention and must never + * be hidden. Once the user responds, approval messages become groupable. + * + * `options.isStandaloneToolName` additionally excludes calls by tool name — + * e.g. MCP app tools that render interactive UI and must stay visible + * outside the group. + */ +export const isGroupableToolMessage = (message: Message, options?: GroupingOptions): boolean => + classifyToolMessage(message, options) === "group"; + +/** + * Partition a message list into render items, folding runs of tool-call + * messages into groups. Standalone tool messages (MCP apps, ask-user, + * undecided approvals) are emitted as singles but do NOT close the current + * run — a parallel tool batch with an interleaved MCP app or approval stays + * one group, with the standalone cards floating right after it. Regular chat + * content (text, user messages) closes the run. + */ +export const groupToolCallMessages = (messages: Message[], options?: GroupingOptions): ChatRenderItem[] => { + const items: ChatRenderItem[] = []; + let openGroup: { kind: "group"; messages: Message[]; startIndex: number } | null = null; + + messages.forEach((message, index) => { + const kind = classifyToolMessage(message, options); + if (kind === "group") { + if (!openGroup) { + openGroup = { kind: "group", messages: [], startIndex: index }; + items.push(openGroup); + } + openGroup.messages.push(message); + } else { + items.push({ kind: "single", message, startIndex: index }); + if (kind === "other") openGroup = null; + } + }); + + return items; +}; + +interface ToolCallGroupSummary { + total: number; + passed: number; + failed: number; + running: number; + /** Deduped, user-friendly tool names in call order. */ + names: string[]; +} + +/** + * Build a `call_id -> is_error` lookup for every tool result in the + * transcript. Compute this ONCE per transcript (memoized in the parent) and + * share it across all ToolCallGroups — results can arrive in messages outside + * a group's boundary, and per-group transcript scans would be + * O(#groups × #messages). + */ +export const buildToolCallResultsIndex = (messages: Message[]): Map => { + const index = new Map(); + for (const message of messages) { + for (const result of extractToolCallResults(message)) { + if (result.call_id && !index.has(result.call_id)) { + index.set(result.call_id, !!result.is_error); + } + } + } + return index; +}; + +const summarize = (groupMessages: Message[], resultsByCallId: ReadonlyMap): ToolCallGroupSummary => { + // id -> tool name for every call requested inside this group + const requests = new Map(); + for (const message of groupMessages) { + for (const request of extractToolCallRequests(message)) { + if (request.id) requests.set(request.id, request.name); + } + } + + let passed = 0; + let failed = 0; + for (const id of requests.keys()) { + const isError = resultsByCallId.get(id); + if (isError === undefined) continue; // still running + if (isError) failed++; + else passed++; + } + + return { + total: requests.size, + passed, + failed, + running: requests.size - passed - failed, + names: [...new Set([...requests.values()].map(convertToUserFriendlyName))], + }; +}; + +interface ToolCallGroupProps { + /** The consecutive tool-call messages folded into this group. */ + messages: Message[]; + /** + * Shared `call_id -> is_error` lookup built from the full transcript with + * {@link buildToolCallResultsIndex} (results may live outside the group). + */ + resultsByCallId: ReadonlyMap; + /** The already-rendered tool call displays for the grouped messages. */ + children: React.ReactNode; +} + +const MAX_PREVIEW_NAMES = 3; + +/** + * Collapsible wrapper around a run of tool calls in the chat transcript. + * Collapsed (the default) it renders a single slim summary row — total calls, + * pass/fail counts, and a live progress indicator while calls are in flight — + * so long tool-heavy turns stop drowning out the conversation. + */ +const ToolCallGroup = ({ messages, resultsByCallId, children }: ToolCallGroupProps) => { + const [open, setOpen] = useState(false); + const summary = useMemo(() => summarize(messages, resultsByCallId), [messages, resultsByCallId]); + + // Nothing visible inside (e.g. only internal/filtered calls) — no chrome. + if (summary.total === 0) return <>{children}; + + const { total, passed, failed, running } = summary; + const isRunning = running > 0; + const previewNames = summary.names.slice(0, MAX_PREVIEW_NAMES).join(", "); + const hiddenNames = summary.names.length - MAX_PREVIEW_NAMES; + + return ( +
+ + +
+
+
+ {children} +
+
+
+
+ ); +}; + +export default ToolCallGroup; diff --git a/ui/src/components/chat/__tests__/ToolCallGroup.test.tsx b/ui/src/components/chat/__tests__/ToolCallGroup.test.tsx new file mode 100644 index 000000000..3cd4014a5 --- /dev/null +++ b/ui/src/components/chat/__tests__/ToolCallGroup.test.tsx @@ -0,0 +1,288 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { Message } from "@a2a-js/sdk"; +import ToolCallGroup, { groupToolCallMessages, isGroupableToolMessage, buildToolCallResultsIndex, collectPendingApprovalIds } from "@/components/chat/ToolCallGroup"; +import { isAgentToolName } from "@/lib/utils"; + +const textMessage = (text: string, role: "user" | "agent" = "agent"): Message => ({ + kind: "message", + messageId: `text-${text}`, + role, + parts: [{ kind: "text", text }], +}); + +const requestMessage = (id: string, name: string): Message => ({ + kind: "message", + messageId: `req-${id}`, + role: "agent", + parts: [ + { + kind: "data", + data: { id, name, args: {} }, + metadata: { adk_type: "function_call" }, + }, + ], +}); + +const responseMessage = (id: string, name: string, isError = false): Message => ({ + kind: "message", + messageId: `res-${id}`, + role: "agent", + parts: [ + { + kind: "data", + data: { id, name, response: { result: isError ? "boom" : "ok", isError } }, + metadata: { adk_type: "function_response" }, + }, + ], +}); + +const approvalMessage = (id: string, approvalDecision?: unknown): Message => ({ + kind: "message", + messageId: `approval-${id}`, + role: "agent", + metadata: { originalType: "ToolApprovalRequest", ...(approvalDecision !== undefined ? { approvalDecision } : {}) }, + parts: [ + { + kind: "data", + data: { id, name: "dangerous_tool", args: {} }, + metadata: { adk_type: "function_call" }, + }, + ], +}); + +describe("isGroupableToolMessage", () => { + it("accepts function_call and function_response messages", () => { + expect(isGroupableToolMessage(requestMessage("c1", "t"))).toBe(true); + expect(isGroupableToolMessage(responseMessage("c1", "t"))).toBe(true); + }); + + it("rejects user and plain text messages", () => { + expect(isGroupableToolMessage(textMessage("hi", "user"))).toBe(false); + expect(isGroupableToolMessage(textMessage("hello"))).toBe(false); + }); + + it("never groups undecided approval or ask-user messages", () => { + expect(isGroupableToolMessage(approvalMessage("c1"))).toBe(false); + expect( + isGroupableToolMessage({ + kind: "message", + messageId: "ask-1", + role: "agent", + metadata: { originalType: "AskUserRequest" }, + parts: [], + }), + ).toBe(false); + }); + + it("groups approval messages once decided", () => { + // Persisted decision (uniform string) + expect(isGroupableToolMessage(approvalMessage("c1", "approve"))).toBe(true); + // Persisted decision (per-tool map) + expect(isGroupableToolMessage(approvalMessage("c1", { c1: "reject" }))).toBe(true); + // Local optimistic decision + expect(isGroupableToolMessage(approvalMessage("c1"), { pendingDecisions: { c1: "approve" } })).toBe(true); + // Map decision for a different call id does not count + expect(isGroupableToolMessage(approvalMessage("c1", { other: "approve" }))).toBe(false); + }); +}); + +describe("groupToolCallMessages", () => { + it("folds consecutive tool messages into one group and keeps text standalone", () => { + const messages = [ + textMessage("question", "user"), + requestMessage("c1", "tool_a"), + responseMessage("c1", "tool_a"), + requestMessage("c2", "tool_b"), + responseMessage("c2", "tool_b"), + textMessage("answer"), + ]; + + const items = groupToolCallMessages(messages); + expect(items).toHaveLength(3); + expect(items[0]).toMatchObject({ kind: "single", startIndex: 0 }); + expect(items[1]).toMatchObject({ kind: "group", startIndex: 1 }); + expect((items[1] as { messages: Message[] }).messages).toHaveLength(4); + expect(items[2]).toMatchObject({ kind: "single", startIndex: 5 }); + }); + + it("floats undecided approvals outside without breaking the run; decided ones fold in", () => { + const messages = [ + requestMessage("c1", "tool_a"), + responseMessage("c1", "tool_a"), + approvalMessage("c2"), + requestMessage("c3", "tool_c"), + ]; + + const items = groupToolCallMessages(messages); + // The pending approval floats out as a single, but the run stays open so + // c1 and c3 share one group. + expect(items.map(i => i.kind)).toEqual(["group", "single"]); + expect((items[0] as { messages: Message[] }).messages).toHaveLength(3); + + // Same transcript after the user decides: one contiguous group. + const decided = groupToolCallMessages(messages, { pendingDecisions: { c2: "approve" } }); + expect(decided.map(i => i.kind)).toEqual(["group"]); + expect((decided[0] as { messages: Message[] }).messages).toHaveLength(4); + }); + + it("keeps the request message that carries a pending approval card outside the group", () => { + // The approve/reject buttons render under the FIRST message introducing + // the call id — the plain function_call request — not the + // ToolApprovalRequest message itself. + const messages = [ + requestMessage("c1", "tool_a"), + responseMessage("c1", "tool_a"), + requestMessage("c2", "dangerous_tool"), + approvalMessage("c2"), + requestMessage("c3", "tool_c"), + ]; + + const pending = groupToolCallMessages(messages, { + pendingApprovalIds: collectPendingApprovalIds(messages), + }); + // Both the request (owning the approval card) and the approval message + // float out as singles; the run stays open so c1 and c3 share one group. + expect(pending.map(i => i.kind)).toEqual(["group", "single", "single"]); + expect((pending[0] as { messages: Message[] }).messages).toHaveLength(3); + + // After the user decides, everything folds into one group. + const pendingDecisions = { c2: "approve" as const }; + const decided = groupToolCallMessages(messages, { + pendingDecisions, + pendingApprovalIds: collectPendingApprovalIds(messages, pendingDecisions), + }); + expect(decided.map(i => i.kind)).toEqual(["group"]); + }); + + it("floats standalone tools (e.g. MCP apps) outside without breaking the run", () => { + const isMcpApp = (name: string) => name === "mcp_app_tool"; + const messages = [ + requestMessage("c1", "tool_a"), + responseMessage("c1", "tool_a"), + requestMessage("c2", "mcp_app_tool"), + responseMessage("c2", "mcp_app_tool"), + requestMessage("c3", "tool_c"), + ]; + + const items = groupToolCallMessages(messages, { isStandaloneToolName: isMcpApp }); + expect(items.map(i => i.kind)).toEqual(["group", "single", "single"]); + expect(items[0]).toMatchObject({ kind: "group", startIndex: 0 }); + // The run stays open across the MCP app call: c1 and c3 share one group. + expect((items[0] as { messages: Message[] }).messages).toHaveLength(3); + expect(items[1]).toMatchObject({ kind: "single", startIndex: 2 }); + expect(items[2]).toMatchObject({ kind: "single", startIndex: 3 }); + }); + + it("floats subagent (agent-tool) calls outside without breaking the run", () => { + // Mirrors ChatInterface's predicate: agent tools (namespaced names) and + // MCP app tools both render standalone. + const isStandalone = (name: string) => isAgentToolName(name) || name === "mcp_app_tool"; + const messages = [ + requestMessage("c1", "tool_a"), + requestMessage("c2", "kagent__NS__researcher"), + responseMessage("c1", "tool_a"), + responseMessage("c2", "kagent__NS__researcher"), + requestMessage("c3", "tool_c"), + responseMessage("c3", "tool_c"), + ]; + + const items = groupToolCallMessages(messages, { isStandaloneToolName: isStandalone }); + expect(items.map(i => i.kind)).toEqual(["group", "single", "single"]); + // The subagent request/response float out; the rest share one group. + expect((items[0] as { messages: Message[] }).messages).toHaveLength(4); + expect((items[1] as { message: Message }).message.messageId).toBe("req-c2"); + expect((items[2] as { message: Message }).message.messageId).toBe("res-c2"); + }); + + it("keeps an interleaved parallel batch as one group (MCP app + approval mid-batch)", () => { + // Models issue parallel batches: [events, logs, weather(MCP), datetime(approval)]. + // The MCP app and the decided approval must not shatter the batch. + const isMcpApp = (name: string) => name === "show-weather-dashboard"; + const messages = [ + requestMessage("c1", "k8s_get_events"), + requestMessage("c2", "k8s_get_pod_logs"), + requestMessage("c3", "show-weather-dashboard"), + requestMessage("c4", "datetime_get_current_time"), + responseMessage("c1", "k8s_get_events"), + responseMessage("c2", "k8s_get_pod_logs"), + responseMessage("c3", "show-weather-dashboard"), + approvalMessage("c4", "approve"), + responseMessage("c4", "datetime_get_current_time"), + requestMessage("c5", "k8s_get_pod_logs"), + responseMessage("c5", "k8s_get_pod_logs"), + textMessage("answer"), + ]; + + const items = groupToolCallMessages(messages, { isStandaloneToolName: isMcpApp }); + // One group for the whole batch + the two MCP app singles + final text. + expect(items.map(i => i.kind)).toEqual(["group", "single", "single", "single"]); + const group = items[0] as { messages: Message[] }; + expect(group.messages).toHaveLength(9); + + // The approval card repeats c4's request — the summary must dedupe by id. + render( + +
+ , + ); + expect(screen.getByRole("button")).toHaveTextContent("4 tool calls"); + }); +}); + +describe("ToolCallGroup", () => { + const messages = [ + requestMessage("c1", "tool_a"), + responseMessage("c1", "tool_a"), + requestMessage("c2", "tool_b"), + responseMessage("c2", "tool_b", true), + requestMessage("c3", "tool_c"), + responseMessage("c3", "tool_c"), + ]; + + const renderGroup = () => + render( + +
tool call details
+
, + ); + + it("shows total and pass/fail counts collapsed by default", () => { + renderGroup(); + const toggle = screen.getByRole("button", { expanded: false }); + expect(toggle).toHaveTextContent("3 tool calls"); + expect(toggle).toHaveTextContent("2"); + expect(toggle).toHaveTextContent("1"); + expect(screen.getByText("succeeded")).toBeInTheDocument(); + expect(screen.getByText("failed")).toBeInTheDocument(); + }); + + it("expands and collapses on toggle", () => { + renderGroup(); + const toggle = screen.getByRole("button"); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + }); + + it("shows running progress while calls are in flight", () => { + const inFlight = [requestMessage("c1", "tool_a"), responseMessage("c1", "tool_a"), requestMessage("c2", "tool_b")]; + render( + +
+ , + ); + expect(screen.getByRole("button")).toHaveTextContent("Running tools 1/2"); + }); + + it("renders children without chrome when there are no visible calls", () => { + render( + +
+ , + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByTestId("bare-child")).toBeInTheDocument(); + }); +}); diff --git a/ui/src/lib/__tests__/toolCallExtraction.test.ts b/ui/src/lib/__tests__/toolCallExtraction.test.ts new file mode 100644 index 000000000..c01dbad23 --- /dev/null +++ b/ui/src/lib/__tests__/toolCallExtraction.test.ts @@ -0,0 +1,142 @@ +import type { Message } from "@a2a-js/sdk"; +import { + isToolCallRequestMessage, + isToolCallExecutionMessage, + isToolCallSummaryMessage, + extractToolCallRequests, + extractToolCallResults, +} from "@/lib/toolCallExtraction"; + +const message = (overrides: Partial): Message => ({ + kind: "message", + messageId: "m1", + role: "agent", + parts: [], + ...overrides, +}); + +const requestPart = (id: string, name: string, prefix: "adk" | "kagent" = "adk") => ({ + kind: "data" as const, + data: { id, name, args: { foo: "bar" } }, + metadata: { [`${prefix}_type`]: "function_call" }, +}); + +const responsePart = (id: string, name: string, response: Record, prefix: "adk" | "kagent" = "adk") => ({ + kind: "data" as const, + data: { id, name, response }, + metadata: { [`${prefix}_type`]: "function_response" }, +}); + +describe("message type predicates", () => { + it("detects request/execution messages from data parts", () => { + expect(isToolCallRequestMessage(message({ parts: [requestPart("c1", "t")] }))).toBe(true); + expect(isToolCallExecutionMessage(message({ parts: [responsePart("c1", "t", { result: "ok" })] }))).toBe(true); + }); + + it("supports the kagent_ metadata prefix", () => { + expect(isToolCallRequestMessage(message({ parts: [requestPart("c1", "t", "kagent")] }))).toBe(true); + expect(isToolCallExecutionMessage(message({ parts: [responsePart("c1", "t", { result: "ok" }, "kagent")] }))).toBe(true); + }); + + it("falls back to originalType for streaming messages", () => { + expect(isToolCallRequestMessage(message({ metadata: { originalType: "ToolCallRequestEvent" } }))).toBe(true); + expect(isToolCallRequestMessage(message({ metadata: { originalType: "ToolApprovalRequest" } }))).toBe(true); + expect(isToolCallExecutionMessage(message({ metadata: { originalType: "ToolCallExecutionEvent" } }))).toBe(true); + expect(isToolCallSummaryMessage(message({ metadata: { originalType: "ToolCallSummaryMessage" } }))).toBe(true); + }); + + it("rejects plain text messages", () => { + const text = message({ parts: [{ kind: "text", text: "hello" }] }); + expect(isToolCallRequestMessage(text)).toBe(false); + expect(isToolCallExecutionMessage(text)).toBe(false); + expect(isToolCallSummaryMessage(text)).toBe(false); + }); +}); + +describe("extractToolCallRequests", () => { + it("extracts calls from data parts", () => { + const msg = message({ parts: [requestPart("c1", "tool_a"), requestPart("c2", "tool_b")] }); + expect(extractToolCallRequests(msg)).toEqual([ + { id: "c1", name: "tool_a", args: { foo: "bar" } }, + { id: "c2", name: "tool_b", args: { foo: "bar" } }, + ]); + }); + + it("filters ADK-internal calls and ask_user", () => { + const msg = message({ + parts: [ + requestPart("c1", "adk_request_confirmation"), + requestPart("c2", "adk_request_credential"), + requestPart("c3", "ask_user"), + requestPart("c4", "real_tool"), + ], + }); + expect(extractToolCallRequests(msg).map(c => c.name)).toEqual(["real_tool"]); + }); + + it("falls back to metadata.toolCallData for streaming messages", () => { + const msg = message({ + metadata: { + originalType: "ToolCallRequestEvent", + toolCallData: [ + { id: "c1", name: "tool_a", args: {} }, + { id: "c2", name: "ask_user", args: {} }, + ], + }, + }); + expect(extractToolCallRequests(msg).map(c => c.name)).toEqual(["tool_a"]); + }); + + it("returns [] for non-request messages and malformed JSON content", () => { + expect(extractToolCallRequests(message({ parts: [{ kind: "text", text: "hi" }] }))).toEqual([]); + expect( + extractToolCallRequests( + message({ + metadata: { originalType: "ToolCallRequestEvent" }, + parts: [{ kind: "text", text: "not json" }], + }), + ), + ).toEqual([]); + }); +}); + +describe("extractToolCallResults", () => { + it("extracts results with error flags", () => { + const msg = message({ + parts: [ + responsePart("c1", "tool_a", { result: "ok", isError: false }), + responsePart("c2", "tool_b", { result: "boom", isError: true }), + ], + }); + const results = extractToolCallResults(msg); + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ call_id: "c1", name: "tool_a", is_error: false, raw_result: "ok" }); + expect(results[1]).toMatchObject({ call_id: "c2", name: "tool_b", is_error: true, raw_result: "boom" }); + }); + + it("extracts subagent_session_id for agent tool responses", () => { + const msg = message({ + parts: [ + responsePart("c1", "ns__NS__helper", { result: "done", subagent_session_id: "sess-42" }), + ], + }); + expect(extractToolCallResults(msg)[0]).toMatchObject({ + call_id: "c1", + subagent_session_id: "sess-42", + }); + }); + + it("falls back to metadata.toolResultData for streaming messages", () => { + const msg = message({ + metadata: { + originalType: "ToolCallExecutionEvent", + toolResultData: [{ call_id: "c1", name: "tool_a", content: "ok", is_error: false }], + }, + }); + expect(extractToolCallResults(msg)).toEqual([{ call_id: "c1", name: "tool_a", content: "ok", is_error: false }]); + }); + + it("returns [] for non-execution messages", () => { + expect(extractToolCallResults(message({ parts: [requestPart("c1", "t")] }))).toEqual([]); + }); +}); diff --git a/ui/src/lib/messageHandlers.ts b/ui/src/lib/messageHandlers.ts index dc6a1c7ee..e8006c27e 100644 --- a/ui/src/lib/messageHandlers.ts +++ b/ui/src/lib/messageHandlers.ts @@ -115,6 +115,14 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { } else if (partType === "function_response") { const toolData = dp.data as unknown as ToolResponseData; + // Skip internal HITL markers (parity with the streaming path): the + // before_tool_callback confirmation stub and the ask_user pending + // stub — the real result arrives in a later function_response. + const respStatus = (toolData.response as Record | undefined)?.status as string | undefined; + if (respStatus === "confirmation_requested" || respStatus === "pending") { + hasConvertedParts = true; + continue; + } let frSubagentSessionId: string | undefined; if (isAgentToolName(toolData.name)) { const responseObj = toolData.response as Record | undefined; diff --git a/ui/src/lib/toolCallExtraction.ts b/ui/src/lib/toolCallExtraction.ts new file mode 100644 index 000000000..4ac9c0257 --- /dev/null +++ b/ui/src/lib/toolCallExtraction.ts @@ -0,0 +1,153 @@ +import { Message, TextPart } from "@a2a-js/sdk"; +import { ADKMetadata, ProcessedToolResultData, ToolResponseData, normalizeToolResultToText, getMetadataValue } from "@/lib/messageHandlers"; +import { FunctionCall } from "@/types"; +import { isAgentToolName } from "@/lib/utils"; + +// Helper functions to work with A2A SDK Messages carrying tool call data. +// Extracted from ToolCallDisplay so non-component code (e.g. ToolCallGroup +// summaries) can reuse them without pulling in component dependencies. + +export const isToolCallRequestMessage = (message: Message): boolean => { + // Check data parts for type metadata first + const hasDataParts = message.parts?.some(part => { + if (part.kind === "data" && part.metadata) { + return getMetadataValue(part.metadata as Record, "type") === "function_call"; + } + return false; + }) || false; + + // Fallback to streaming format check + if (!hasDataParts) { + const metadata = message.metadata as ADKMetadata; + return metadata?.originalType === "ToolCallRequestEvent" || metadata?.originalType === "ToolApprovalRequest"; + } + + return hasDataParts; +}; + +export const isToolCallExecutionMessage = (message: Message): boolean => { + const hasDataParts = message.parts?.some(part => { + if (part.kind === "data" && part.metadata) { + return getMetadataValue(part.metadata as Record, "type") === "function_response"; + } + return false; + }) || false; + + // Fallback to streaming format check + if (!hasDataParts) { + const metadata = message.metadata as ADKMetadata; + return metadata?.originalType === "ToolCallExecutionEvent"; + } + + return hasDataParts; +}; + +export const isToolCallSummaryMessage = (message: Message): boolean => { + const metadata = message.metadata as ADKMetadata; + return metadata?.originalType === "ToolCallSummaryMessage"; +}; + +export const extractToolCallRequests = (message: Message): FunctionCall[] => { + if (!isToolCallRequestMessage(message)) return []; + + // Check for stored task format first (data parts) + const dataParts = message.parts?.filter(part => part.kind === "data") || []; + const functionCalls: FunctionCall[] = []; + + for (const part of dataParts) { + if (part.metadata) { + if (getMetadataValue(part.metadata as Record, "type") === "function_call") { + const data = part.data as unknown as FunctionCall; + // Skip ADK internal function calls (confirmation/auth) and ask_user (has its own display) + if ( + data.name === "adk_request_confirmation" || + data.name === "adk_request_credential" || + data.name === "ask_user" + ) { + continue; + } + functionCalls.push({ + id: data.id, + name: data.name, + args: data.args ?? {}, + }); + } + } + } + + // If we found function calls in data parts, return them + if (functionCalls.length > 0) { + return functionCalls; + } + + // Try streaming format (metadata or text content) + const textParts = message.parts?.filter(part => part.kind === "text") || []; + const content = textParts.map(part => (part as TextPart).text).join(""); + + try { + // Tool call data might be stored as JSON in content or metadata + const metadata = message.metadata as ADKMetadata; + const toolCallData = metadata?.toolCallData || JSON.parse(content || "[]"); + return Array.isArray(toolCallData) + ? toolCallData.filter(tc => + tc.name !== "adk_request_confirmation" && + tc.name !== "adk_request_credential" && + tc.name !== "ask_user" + ) + : []; + } catch { + return []; + } +}; + +export const extractToolCallResults = (message: Message): ProcessedToolResultData[] => { + if (!isToolCallExecutionMessage(message)) return []; + + // Check for stored task format first (data parts) + const dataParts = message.parts?.filter(part => part.kind === "data") || []; + const toolResults: ProcessedToolResultData[] = []; + + for (const part of dataParts) { + if (part.metadata) { + if (getMetadataValue(part.metadata as Record, "type") === "function_response") { + const data = part.data as unknown as ToolResponseData; + + // For agent tool responses we receive { result, subagent_session_id } as FunctionResponse.response. + const textContent = normalizeToolResultToText(data); + let subagentSessionId: string | undefined; + if (isAgentToolName(data.name)) { + const responseObj = data.response as Record | undefined; + if (responseObj && typeof responseObj.subagent_session_id === "string") { + subagentSessionId = responseObj.subagent_session_id; + } + } + + toolResults.push({ + call_id: data.id, + name: data.name, + content: textContent, + is_error: data.response?.isError || false, + raw_result: data.response?.result ?? data.response, + ...(subagentSessionId ? { subagent_session_id: subagentSessionId } : {}), + }); + } + } + } + + // If we found tool results in data parts, return them + if (toolResults.length > 0) { + return toolResults; + } + + // Try streaming format (metadata or text content) + const textParts = message.parts?.filter(part => part.kind === "text") || []; + const content = textParts.map(part => (part as TextPart).text).join(""); + + try { + const metadata = message.metadata as ADKMetadata; + const resultData = metadata?.toolResultData || JSON.parse(content || "[]"); + return Array.isArray(resultData) ? resultData : []; + } catch { + return []; + } +};