diff --git a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx
index 2246f2d729..aff6e91de9 100644
--- a/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx
+++ b/ui/app/(prowler)/lighthouse/_components/chat/message-bubble.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Bot, Check, Copy, UserRound, Wrench } from "lucide-react";
+import { Bot, Check, Copy, UserRound } from "lucide-react";
import { useState } from "react";
import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format";
@@ -9,21 +9,19 @@ import {
LIGHTHOUSE_V2_MESSAGE_ROLE,
LIGHTHOUSE_V2_PART_TYPE,
type LighthouseV2Message,
+ type LighthouseV2Part,
} from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
import { cn } from "@/lib/utils";
import { MessageMarkdown } from "./message-markdown";
+import { ToolCalls } from "./tool-call-part";
export function MessageBubble({ message }: { message: LighthouseV2Message }) {
const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER;
- const textParts = message.parts.filter(
- (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT,
- );
- const toolCallCount = message.parts.filter(
- (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL,
- ).length;
- const messageText = textParts
+ // Text-only join feeds the copy button; tool calls are rendered separately.
+ const messageText = message.parts
+ .filter((part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT)
.map((part) => getTextContent(part.content))
.filter(Boolean)
.join("\n\n");
@@ -50,18 +48,12 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) {
: "bg-bg-neutral-tertiary text-text-neutral-primary",
)}
>
- {toolCallCount > 0 && (
-
-
- {toolCallCount} {toolCallCount === 1 ? "tool" : "tools"} called
-
- )}
- {/* User text stays plain to preserve HTML-like tags; assistant text
- renders as markdown. */}
+ {/* User text stays plain to preserve HTML-like tags; assistant
+ renders parts in order so tool calls sit between text blocks. */}
{isUser ? (
{messageText}
) : (
-
+
)}
part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL,
+ );
+ const textParts = parts.filter(
+ (part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT,
+ );
+
+ return (
+
+
+ {textParts.map((part, index) => {
+ const text = getTextContent(part.content);
+ return text ? (
+
+ ) : null;
+ })}
+
+ );
+}
+
function MessageMeta({
isUser,
text,
diff --git a/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx b/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx
index 188f363ab0..48983fabb3 100644
--- a/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx
+++ b/ui/app/(prowler)/lighthouse/_components/chat/streaming-message.tsx
@@ -12,6 +12,7 @@ import {
LIGHTHOUSE_V2_STREAM_STATUS,
type LighthouseV2StreamState,
} from "@/app/(prowler)/lighthouse/_lib/event-reducer";
+import { formatToolName } from "@/app/(prowler)/lighthouse/_lib/tool-calls";
import { cn } from "@/lib/utils";
import { MessageMarkdown } from "./message-markdown";
@@ -97,7 +98,7 @@ function getActivityHeader(streamState: LighthouseV2StreamState): string {
function getToolCallLabel(
toolCall: LighthouseV2StreamState["toolCalls"][number],
): string {
- return `${toolCall.status === "running" ? "Calling" : "Called"} ${
- toolCall.name
- }`;
+ return `${toolCall.status === "running" ? "Calling" : "Called"} ${formatToolName(
+ toolCall.name,
+ )}`;
}
diff --git a/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx b/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx
new file mode 100644
index 0000000000..64eb0606ba
--- /dev/null
+++ b/ui/app/(prowler)/lighthouse/_components/chat/tool-call-part.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { Check, ChevronDown, TriangleAlert, Wrench } from "lucide-react";
+
+import {
+ ChainOfThought,
+ ChainOfThoughtContent,
+ ChainOfThoughtHeader,
+} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought";
+import {
+ formatToolName,
+ getToolCallContent,
+ isToolCallError,
+} from "@/app/(prowler)/lighthouse/_lib/tool-calls";
+import type { LighthouseV2Part } from "@/app/(prowler)/lighthouse/_types";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/shadcn/collapsible";
+import {
+ QUERY_EDITOR_LANGUAGE,
+ QueryCodeEditor,
+} from "@/components/shared/query-code-editor";
+
+// Groups a finished message's tool calls under one collapsed disclosure so the
+// "work" is hidden behind a single chevron by default, mirroring the streaming
+// chain-of-thought. The answer text renders separately, always visible.
+export function ToolCalls({ parts }: { parts: LighthouseV2Part[] }) {
+ if (parts.length === 0) {
+ return null;
+ }
+ const label = `Used ${parts.length} ${parts.length === 1 ? "tool" : "tools"}`;
+
+ return (
+
+
+ {label}
+
+
+ {parts.map((part, index) => (
+
+ ))}
+
+
+ );
+}
+
+// One tool call as a light, collapsed row: status + humanized name, expanding to
+// the arguments the agent sent and the result it got back.
+function ToolCallPart({ part }: { part: LighthouseV2Part }) {
+ const toolCall = getToolCallContent(part.content);
+ if (!toolCall) {
+ return null;
+ }
+
+ // `content.outcome` is authoritative; fall back to the part-level column.
+ const outcome = toolCall.outcome ?? part.toolCallOutcome;
+ const isError = isToolCallError(outcome);
+
+ return (
+
+
+ {isError ? (
+
+ ) : (
+
+ )}
+
+
+ {formatToolName(toolCall.toolName)}
+
+ {isError && outcome && (
+ {outcome}
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function ToolCallSection({ label, value }: { label: string; value: unknown }) {
+ if (value === null || value === undefined || value === "") {
+ return null;
+ }
+ // String results (often markdown) render as plain text so real newlines show
+ // instead of escaped "\n"; structured values render as highlighted JSON.
+ const isText = typeof value === "string";
+ const text = isText ? value : JSON.stringify(value, null, 2);
+ if (!text) {
+ return null;
+ }
+
+ return (
+ {}}
+ />
+ );
+}
diff --git a/ui/app/(prowler)/lighthouse/_lib/tool-calls.test.ts b/ui/app/(prowler)/lighthouse/_lib/tool-calls.test.ts
new file mode 100644
index 0000000000..570e7cc597
--- /dev/null
+++ b/ui/app/(prowler)/lighthouse/_lib/tool-calls.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ formatToolName,
+ getToolCallContent,
+ isToolCallError,
+} from "./tool-calls";
+
+describe("getToolCallContent", () => {
+ it("should normalize the snake_case backend blob to camelCase", () => {
+ // Given
+ const content = {
+ tool_call_id: "call_1",
+ tool_name: "prowler_app_search_security_findings",
+ arguments: { severity: "high" },
+ result: { count: 3 },
+ outcome: "success",
+ };
+
+ // When
+ const parsed = getToolCallContent(content);
+
+ // Then
+ expect(parsed).toEqual({
+ toolCallId: "call_1",
+ toolName: "prowler_app_search_security_findings",
+ arguments: { severity: "high" },
+ result: { count: 3 },
+ outcome: "success",
+ });
+ });
+
+ it("should default missing optional fields without throwing", () => {
+ // Given a blob with only the required tool_name
+ const parsed = getToolCallContent({ tool_name: "search_tools" });
+
+ // Then
+ expect(parsed).toEqual({
+ toolCallId: "",
+ toolName: "search_tools",
+ arguments: null,
+ result: null,
+ outcome: null,
+ });
+ });
+
+ it("should return null for non-tool-call content", () => {
+ expect(getToolCallContent(null)).toBeNull();
+ expect(getToolCallContent("text")).toBeNull();
+ expect(getToolCallContent({ text: "hi" })).toBeNull();
+ });
+});
+
+describe("formatToolName", () => {
+ it("should strip the prowler prefix and title-case", () => {
+ expect(formatToolName("prowler_app_search_security_findings")).toBe(
+ "Search security findings",
+ );
+ expect(formatToolName("prowler_hub_list_checks")).toBe("List checks");
+ });
+
+ it("should humanize prefix-less tools", () => {
+ expect(formatToolName("search_tools")).toBe("Search tools");
+ });
+});
+
+describe("isToolCallError", () => {
+ it("should treat success and absent outcomes as non-errors", () => {
+ expect(isToolCallError("success")).toBe(false);
+ expect(isToolCallError(null)).toBe(false);
+ });
+
+ it("should treat any other outcome as an error", () => {
+ expect(isToolCallError("timeout")).toBe(true);
+ expect(isToolCallError("mcp_tool_error")).toBe(true);
+ });
+});
diff --git a/ui/app/(prowler)/lighthouse/_lib/tool-calls.ts b/ui/app/(prowler)/lighthouse/_lib/tool-calls.ts
new file mode 100644
index 0000000000..46034102d5
--- /dev/null
+++ b/ui/app/(prowler)/lighthouse/_lib/tool-calls.ts
@@ -0,0 +1,50 @@
+import type { LighthouseV2ToolCallContent } from "@/app/(prowler)/lighthouse/_types";
+
+// Prefixes shared by the MCP-sourced tools; stripped for display so a name like
+// `prowler_app_search_security_findings` reads as "Search security findings".
+const TOOL_NAME_PREFIXES = [
+ "prowler_app_",
+ "prowler_hub_",
+ "prowler_docs_",
+] as const;
+
+// Reads the snake_case TOOL_CALL blob the backend persists and normalizes it to
+// the camelCase UI shape. Returns null when `content` isn't a tool-call object.
+export function getToolCallContent(
+ content: unknown,
+): LighthouseV2ToolCallContent | null {
+ if (typeof content !== "object" || content === null) {
+ return null;
+ }
+ const record = content as Record;
+ if (typeof record.tool_name !== "string") {
+ return null;
+ }
+ return {
+ toolCallId:
+ typeof record.tool_call_id === "string" ? record.tool_call_id : "",
+ toolName: record.tool_name,
+ arguments: record.arguments ?? null,
+ result: record.result ?? null,
+ outcome: typeof record.outcome === "string" ? record.outcome : null,
+ };
+}
+
+// Turns a raw tool name into a human label by dropping the known prefix and
+// title-casing, e.g. `prowler_hub_list_checks` -> "List checks". A humanizer
+// (not a hardcoded map) keeps this in sync as the MCP tool whitelist grows.
+export function formatToolName(toolName: string): string {
+ const prefix = TOOL_NAME_PREFIXES.find((value) => toolName.startsWith(value));
+ const stripped = prefix ? toolName.slice(prefix.length) : toolName;
+ const words = stripped.replace(/_/g, " ").trim();
+ if (!words) {
+ return toolName;
+ }
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+// A tool call succeeded when its outcome is absent or the literal "success";
+// any other outcome is an error surfaced to the user.
+export function isToolCallError(outcome: string | null): boolean {
+ return outcome !== null && outcome.toLowerCase() !== "success";
+}
diff --git a/ui/app/(prowler)/lighthouse/_types/sessions.ts b/ui/app/(prowler)/lighthouse/_types/sessions.ts
index b5f4ae89e6..600b3a65a6 100644
--- a/ui/app/(prowler)/lighthouse/_types/sessions.ts
+++ b/ui/app/(prowler)/lighthouse/_types/sessions.ts
@@ -35,6 +35,17 @@ export interface LighthouseV2Part {
updatedAt: string | null;
}
+// Normalized shape of a TOOL_CALL part's `content`. The backend persists this
+// blob in snake_case (tool_call_id, tool_name, ...); `getToolCallContent`
+// maps it to this camelCase form so the UI never touches the raw keys.
+export interface LighthouseV2ToolCallContent {
+ toolCallId: string;
+ toolName: string;
+ arguments: unknown;
+ result: unknown;
+ outcome: string | null;
+}
+
export interface LighthouseV2Message {
id: string;
role: LighthouseV2MessageRole;