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 (
+
+
+
+
+
+ );
+};
+
+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 [];
+ }
+};