diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 9aa5764548c..950da963437 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -24,6 +24,7 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", + "es-toolkit": "1.49.0", "jwt-decode": "4.0.0", "lucide-react": "0.513.0", "moment": "2.31.0", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 81b89337325..a6ec2a9a65e 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -41,6 +41,7 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", + "es-toolkit": "1.49.0", "jwt-decode": "4.0.0", "lucide-react": "0.513.0", "moment": "2.31.0", diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/ContentTab.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/ContentTab.tsx index 16cebc1c712..b535d80adfe 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/content/ContentTab.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/ContentTab.tsx @@ -6,7 +6,8 @@ import { useTracesApi } from "../../api"; import type { Span, SpanDetail, UIContent } from "../../types"; import { parseMessages, prettyPayload } from "../../utils"; import { Payload, TextBody } from "./PayloadBody"; -import { payloadView } from "./payload"; +import { ToolArguments } from "./ToolContent"; +import { payloadView, toolInput } from "./payload"; import { Section } from "./Section"; import { ErrorBlock, StoredDiagnostic } from "./SpanError"; @@ -41,7 +42,12 @@ function PayloadSection({ span: Span; role: "input" | "output"; }) { - const view = payloadView(raw, content, span.type === "tool" && role === "output"); + const view = payloadView( + raw, + content, + span.type === "tool" && role === "output", + span.type !== "tool" && role === "output", + ); const count = role === "input" ? messageCount(raw, content) : undefined; return (
- {(mode) => - mode === "Raw" ? ( - - ) : ( - - ) - } + {(mode) => { + if (mode === "Raw") return ; + if (span.type === "tool" && role === "input") return ; + return ; + }}
); } diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.integration.test.tsx similarity index 60% rename from ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.test.tsx rename to ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.integration.test.tsx index 03ea1ad2046..72427b4a6af 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.integration.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; @@ -10,20 +10,22 @@ vi.mock("../../ui/spanProvider", () => ({ useSpanProvider: () => null })); const LONG_QUERY = "Find every invoice for the customer that was billed twice. ".repeat(3).trim(); describe("ToolResultCard", () => { - it("expands a multiline result to its full text and keeps short results on one line", async () => { + it("shows multiline output immediately and expands long results without losing text", async () => { const user = userEvent.setup(); - const result = "line one\nline two\nTraceback: boom"; - const { unmount } = render(); - const toggle = screen.getByRole("button", { name: "Expand read_file result" }); - expect(toggle).toHaveAttribute("aria-expanded", "false"); - await user.click(toggle); - expect(screen.getByRole("button", { name: "Collapse read_file result" })).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByText((_, el) => el?.tagName === "PRE" && el.textContent === result)).toBeVisible(); - - unmount(); - render(); - expect(screen.getByText("No files found")).toBeVisible(); - expect(screen.queryByRole("button", { name: /^(Expand|Collapse) ls result$/ })).not.toBeInTheDocument(); + const result = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n"); + render( + , + ); + expect(screen.getByText(/line 1\s+line 2/, { selector: "pre" })).toBeVisible(); + expect(screen.queryByText(/line 30/, { selector: "pre" })).not.toBeInTheDocument(); + const failedResult = screen.getByRole("group", { name: "Failed tool result" }); + expect(failedResult).toHaveClass("text-destructive"); + expect(within(failedResult).getByText("exit_code")).toBeVisible(); + expect(within(failedResult).getByText("1", { exact: true })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Expand result" })); + expect(screen.getByText(/line 30/, { selector: "pre" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Collapse result" })); + expect(screen.queryByText(/line 30/, { selector: "pre" })).not.toBeInTheDocument(); }); }); @@ -38,22 +40,17 @@ describe("MessageCard", () => { expect(screen.getByText("Why was I billed twice?")).toBeVisible(); }); - it("lists tool call arguments once and expands only the long value in place", async () => { - const user = userEvent.setup(); + it("shows a tool's primary query immediately alongside its other arguments", () => { render( , ); - expect(screen.getByText("acme-404")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Expand customer_id" })).not.toBeInTheDocument(); - expect(screen.queryByText(LONG_QUERY, { selector: "pre" })).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Expand query" })); - expect(screen.getByRole("button", { name: "Collapse query" })).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("test-404")).toBeVisible(); expect(screen.getByText(LONG_QUERY, { selector: "pre" })).toBeVisible(); }); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.tsx index 765e7af5bf8..144aecc44bd 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/Messages.tsx @@ -1,17 +1,14 @@ "use client"; import { Bot, Settings2, User, Wrench } from "lucide-react"; -import { useState } from "react"; import CopyButton from "@/components/shared/CopyButton"; import { cn } from "@/lib/cva.config"; -import { FoldChevron } from "../../ui/Collapse"; import type { TraceMessage, TraceToolCall } from "../../types"; import { Block, BlockBadge, type BadgeTone } from "./Block"; -import { FieldTree } from "./FieldTree"; import { Markdown } from "./Markdown"; -import { fieldNode, type FieldEntry } from "./payload"; +import { ToolArguments, ToolOutput } from "./ToolContent"; const ROLE_LABEL: Record = { user: "User", system: "System", assistant: "Assistant", tool: "Tool" }; const ROLE_TONE: Record = { user: "user", assistant: "assistant", tool: "tool", system: "neutral" }; @@ -24,12 +21,6 @@ const ROLE_ICON: Record = { const argsText = (args: unknown): string => (typeof args === "string" ? args : JSON.stringify(args)); -function argEntries(args: unknown): readonly FieldEntry[] { - const node = fieldNode(args); - if (node.kind === "object" && node.entries.length > 0) return node.entries; - return [["arguments", node]]; -} - export function ToolCallBlock({ call }: { call: TraceToolCall }) { return (
@@ -46,8 +37,12 @@ export function ToolCallBlock({ call }: { call: TraceToolCall }) { className="ml-auto size-6 text-muted-foreground" />
-
- +
+ {call.args === undefined ? ( +

Arguments not recorded

+ ) : ( + + )}
); @@ -90,41 +85,19 @@ export function MessageList({ messages }: { messages: readonly TraceMessage[] }) ); } -const LONG_RESULT_CHARS = 120; - -/** A tool's result: one line by default, long or multiline results expand in place. Red when the tool failed. */ export function ToolResultCard({ name, result, failed = false }: { name: string; result: string; failed?: boolean }) { - const [open, setOpen] = useState(false); - const expandable = result.length > LONG_RESULT_CHARS || result.includes("\n"); - const tone = failed ? "text-destructive" : "text-foreground"; return (
-
+
- - {name} - - {expandable ? ( - - ) : ( - {result || "No output"} - )} + {name}
- {open && ( -
-          {result}
-        
- )} +
+ +
); } diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/ToolContent.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/content/ToolContent.tsx new file mode 100644 index 00000000000..c3b3ad0d2df --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/ToolContent.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/cva.config"; +import { FieldTree } from "./FieldTree"; +import { MessageList } from "./Messages"; +import { fieldNode, toolAction, toolResult, type FieldNode } from "./payload"; + +export function ToolText({ text, label }: { text: string; label: string }) { + const [expanded, setExpanded] = useState(false); + const preview = text.split("\n").slice(0, 8).join("\n").slice(0, 1200); + const truncated = preview.length < text.length; + return ( +
+
+        {expanded ? text : preview || "No output recorded"}
+        {!expanded && truncated && "\n…"}
+      
+ {truncated && ( + + )} +
+ ); +} + +function ToolValue({ node, label }: { node: FieldNode; label: string }) { + if (node.kind === "messages") return ; + if (node.kind === "text" || node.kind === "scalar") return ; + const entries = node.kind === "object" ? node.entries : node.items.map((item, i) => [String(i), item] as const); + return entries.length ? ( + + ) : ( + + ); +} + +export function ToolArguments({ args }: { args: unknown }) { + const node = fieldNode(args); + const action = toolAction(args); + const rest = node.kind === "object" ? node.entries.filter(([key]) => key !== action?.key) : []; + if (!action) return ; + return ( +
+
+
{action.key}
+ +
+ {rest.length > 0 && } +
+ ); +} + +export function ToolOutput({ result, failed = false }: { result: string; failed?: boolean }) { + const { body, metadata } = toolResult(result); + return ( +
+ + {metadata.length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.test.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.test.ts index cee2742d1f6..04140794329 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.test.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { fieldNode, payloadView, textFormat } from "./payload"; +import { fieldNode, payloadView, textFormat, toolInput, toolSummary, toolResult } from "./payload"; describe("textFormat", () => { it.each([ @@ -153,3 +153,63 @@ describe("payloadView", () => { }); }); }); + +describe("tool payloads", () => { + it("decodes transport escapes once while preserving literal escapes inside a command", () => { + const args = { command: "printf 'first\\nsecond'\nls src", workdir: "/workspace" }; + expect(toolInput(JSON.stringify(args))).toEqual(args); + expect(toolSummary(JSON.stringify(args))).toBe("printf 'first\\nsecond' ls src"); + }); + + it("extracts a useful action from a truncated tree preview without showing JSON scaffolding", () => { + expect(toolSummary('{"command":"npm test\\n-- --run')).toBe("npm test -- --run"); + expect(toolSummary('{"file_path":"/workspace/src/page.tsx","offset":10}')).toBe("/workspace/src/page.tsx"); + expect(toolSummary('{"unknown":42}')).toBe(""); + expect(toolSummary({ command: "[ -f package.json ] && npm test" })).toBe("[ -f package.json ] && npm test"); + expect(toolSummary(JSON.stringify({ command: "{ npm test; }" }))).toBe("{ npm test; }"); + }); + + it("preserves command failure metadata and renders structured results as fields", () => { + expect(toolResult(JSON.stringify({ output: "first\nsecond", exit_code: 1, error: null }))).toEqual({ + body: { kind: "text", text: "first\nsecond", format: "plain" }, + metadata: [ + ["exit_code", { kind: "scalar", text: "1" }], + ["error", { kind: "scalar", text: "null" }], + ], + }); + expect(toolResult('{"result":{"number":42,"state":"open"}}').body).toEqual( + fieldNode({ result: { number: 42, state: "open" } }), + ); + }); + + it("unwraps MCP text while preserving unknown blocks, annotations and error flags", () => { + expect(toolResult('{"content":[{"type":"text","text":"Permission denied"}],"isError":true}')).toEqual({ + body: fieldNode("Permission denied"), + metadata: [["isError", { kind: "scalar", text: "true" }]], + }); + const unknown = { + content: [ + { type: "image", data: "sample" }, + { type: "text", text: "caption", annotations: { audience: ["user"] } }, + ], + }; + expect(toolResult(JSON.stringify(unknown)).body).toEqual(fieldNode(unknown)); + expect(toolResult("{malformed output").body).toEqual(fieldNode("{malformed output")); + }); + + it("recognizes message arrays inside normalized text without rewriting ordinary agent content", () => { + const raw = JSON.stringify([{ role: "user", content: "SAVED TASK RESUMED: Continue this task" }]); + expect(payloadView(raw, { kind: "text", text: raw }, false)).toEqual({ + kind: "messages", + messages: [{ role: "user", content: "SAVED TASK RESUMED: Continue this task" }], + }); + const summary = JSON.stringify([{ content: "Checking files", tool_names: ["read_file"] }]); + expect(payloadView(summary, { kind: "text", text: summary }, false, true)).toMatchObject({ + kind: "messages", + messages: [ + { role: "assistant", content: "Checking files", tool_calls: [{ name: "read_file", args: undefined }] }, + ], + }); + expect(payloadView(summary, { kind: "text", text: summary }, false).kind).toBe("text"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.ts index 0cfda77dba1..c235b30c6d4 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/content/payload.ts @@ -1,5 +1,5 @@ import type { TraceMessage, UIContent, UIMessage } from "../../types"; -import { parseJson, parseMessages } from "../../utils"; +import { parseAssistantSummary, parseJson, parseMessages } from "../../utils"; export type TextFormat = "markdown" | "code" | "plain"; @@ -63,6 +63,8 @@ const singleText = (messages: readonly UIMessage[]): string | null => messages.length === 1 && !messages[0].tool_calls?.length ? messages[0].content : null; function textView(text: string): PayloadView { + const messages = parseMessages(text); + if (messages?.length) return { kind: "messages", messages }; return { kind: "text", text, format: textFormat(text) }; } @@ -93,6 +95,75 @@ function rawView(raw: string, toolOutput: boolean): PayloadView { } /** How a span's input or output reads best: the standard UI shape when the store sent one, else the raw payload. */ -export function payloadView(raw: string, content: UIContent | undefined, toolOutput: boolean): PayloadView { +export function payloadView( + raw: string, + content: UIContent | undefined, + toolOutput: boolean, + assistantOutput = false, +): PayloadView { + if (assistantOutput && (!content || content.kind === "text")) { + const messages = parseAssistantSummary(content?.text ?? raw); + if (messages) return { kind: "messages", messages }; + } return content ? standardView(content, raw, toolOutput) : rawView(raw, toolOutput); } + +export function toolInput(raw: string, content?: UIContent): unknown { + const parsed = parseJson(raw); + if (parsed !== null) return parsed; + if (content?.kind === "fields") return Object.fromEntries(content.fields.map(({ key, value }) => [key, value])); + return content?.kind === "text" ? content.text : raw; +} + +const ACTION_KEYS = ["command", "cmd", "code", "patch", "file_path", "path", "file", "query", "pattern", "url"]; + +export function toolAction(value: unknown): { key: string; text: string } | null { + if (typeof value === "string") { + const parsed = parseJson(value); + if (parsed !== null && typeof parsed === "object") return toolAction(parsed); + return value ? { key: "arguments", text: value } : null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + for (const key of ACTION_KEYS) { + const text: unknown = Reflect.get(value, key); + if (typeof text === "string" && text) return { key, text }; + } + return null; +} + +export function toolSummary(raw: unknown): string { + const action = toolAction(raw); + if (action && (action.key !== "arguments" || !looksLikeJson(action.text))) return action.text.replace(/\s+/g, " "); + if (typeof raw !== "string") return ""; + const match = /"(?:command|cmd|code|patch|file_path|path|file|query|pattern|url)"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(raw); + if (!match) return ""; + const text = match[1].replace(/\\u[0-9a-fA-F]{0,3}$|\\$/, ""); + const decoded = parseJson(`"${text}"`); + return (typeof decoded === "string" ? decoded : text).replace(/\s+/g, " "); +} + +export function toolResult(result: string): { body: FieldNode; metadata: readonly FieldEntry[] } { + const parsed = parseJson(result); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const entries = Object.entries(parsed); + const output = entries.find(([key, value]) => key === "output" && typeof value === "string"); + if (output) + return { + body: fieldNode(output[1]), + metadata: entries.filter(([key]) => key !== "output").map(([key, value]) => [key, fieldNode(value)]), + }; + const content: unknown = Reflect.get(parsed, "content"); + const isTextBlock = (block: unknown): block is { type: "text"; text: string } => { + if (!block || typeof block !== "object") return false; + const hasText = Reflect.get(block, "type") === "text" && typeof Reflect.get(block, "text") === "string"; + return hasText && Object.keys(block).every((key) => key === "type" || key === "text"); + }; + if (Array.isArray(content) && content.length && content.every(isTextBlock)) { + return { + body: fieldNode(content.map((block) => block.text).join("\n\n")), + metadata: entries.filter(([key]) => key !== "content").map(([key, value]) => [key, fieldNode(value)]), + }; + } + } + return { body: fieldNode(typeof parsed === "string" ? parsed : result), metadata: [] }; +} diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx index ecd0bf13e06..8c62269de14 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.integration.test.tsx @@ -56,7 +56,10 @@ describe("TraceConversation", () => { expect(conversationTab).toHaveAttribute("aria-selected", "true"); const conversation = await screen.findByRole("region", { name: "Trace conversation" }); expect(await within(conversation).findByText("Read the release notes")).toBeVisible(); - await user.click(await within(conversation).findByRole("button", { name: "Expand read_file tool call" })); + expect(await within(conversation).findByRole("button", { name: "Expand read_file tool call" })).toHaveTextContent( + "CHANGELOG.md", + ); + await user.click(within(conversation).getByRole("button", { name: "Expand read_file tool call" })); expect(await within(conversation).findByText("CHANGELOG.md")).toBeVisible(); expect(await within(conversation).findByText("All checks passed")).toBeVisible(); expect(await within(conversation).findByText("The release is ready")).toBeVisible(); @@ -67,6 +70,56 @@ describe("TraceConversation", () => { expect(screen.getByRole("heading", { name: "read_file" })).toBeVisible(); }); + it("renders a failed shell exchange in both views and preserves its raw result", async () => { + const user = userEvent.setup(); + const command = "npm test -- checkout\nprintf 'finished\\n'"; + const output = JSON.stringify({ output: "PASS cart.test.ts\nFAIL checkout.test.ts", exit_code: 1, error: null }); + const failedTool = { ...tool, name: "terminal", status: "error", error: null }; + vi.mocked(agentTraceCall).mockResolvedValue({ ...trace, spans: [root, failedTool] } as Trace); + vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) => + id === "root" + ? rootDetail + : { + ...toolDetail, + input: JSON.stringify({ command, workdir: "/workspace" }), + output, + input_ui: { + kind: "fields", + fields: [ + { key: "command", value: command }, + { key: "workdir", value: "/workspace" }, + ], + }, + output_ui: { + kind: "fields", + fields: [ + { key: "output", value: "PASS cart.test.ts\nFAIL checkout.test.ts" }, + { key: "exit_code", value: "1" }, + ], + }, + }, + ); + renderWithProviders( + , + ); + await user.click(await screen.findByRole("tab", { name: "Conversation" })); + const conversation = await screen.findByRole("region", { name: "Trace conversation" }); + expect(await within(conversation).findByText(/npm test -- checkout/, { selector: "pre" })).toHaveTextContent( + "printf 'finished\\n'", + ); + expect(within(conversation).getByText(/PASS cart.test.ts/, { selector: "pre" })).toHaveTextContent( + "FAIL checkout.test.ts", + ); + expect(within(conversation).getByText("exit_code")).toBeVisible(); + await user.click(within(conversation).getByRole("button", { name: "Inspect step terminal" })); + const details = screen.getByRole("complementary", { name: "Span details" }); + expect(await within(details).findByText(/npm test -- checkout/, { selector: "pre" })).toBeVisible(); + const result = within(details).getByRole("region", { name: "Output", exact: true }); + expect(within(result).getByText("exit_code")).toBeVisible(); + await user.click(within(result).getByRole("radio", { name: "Raw" })); + expect(within(result).getByText(/"exit_code": 1/, { selector: "pre" })).toHaveTextContent('"error": null'); + }); + it("loads only twenty full steps at a time and fetches the remainder on demand", async () => { const user = userEvent.setup(); const spans = [ @@ -85,6 +138,29 @@ describe("TraceConversation", () => { expect(screen.queryByRole("button", { name: /Load next/ })).not.toBeInTheDocument(); }); + it("shows distinct agent invocation labels together with each step's time", async () => { + const first = { ...root, name: "reviewer", start_offset_ms: 1000 }; + const second = { ...first, span_id: "second", start_offset_ms: 2000 }; + vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) => ({ + ...rootDetail, + span_id: id, + input: id === "root" ? "Review code" : "Review tests", + output: "", + })); + renderWithProviders( + , + ); + expect(await screen.findByText("reviewer (1)")).toBeVisible(); + expect(screen.getByText("reviewer (2)")).toBeVisible(); + const steps = screen.getAllByRole("region", { name: "Conversation step reviewer" }); + expect(within(steps[0]).getByText("1.00s")).toBeVisible(); + expect(within(steps[1]).getByText("2.00s")).toBeVisible(); + }); + it.each([rootDetail.output, ""])( "keeps the root failure visible while loading, then places it in order (output: %s)", async (output) => { diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.tsx index 860cb091d18..1e8cab84514 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/TraceConversation.tsx @@ -5,12 +5,14 @@ import { useState } from "react"; import { ChevronRight, Wrench } from "lucide-react"; import { cn } from "@/lib/cva.config"; import CopyButton from "@/components/shared/CopyButton"; +import { ToolArguments, ToolOutput } from "../content/ToolContent"; +import { toolSummary } from "../content/payload"; import { useTracesApi } from "../../api"; import { Button } from "@/components/ui/button"; import { buildConversation, conversationSteps, CONVERSATION_PAGE_SIZE, type ConversationItem } from "./conversation"; import { ErrorBlock } from "../content/SpanError"; import { Markdown } from "../content/Markdown"; -import { ToolCallBlock } from "../content/Messages"; +import { ToolCallBlock, ToolResultCard } from "../content/Messages"; import type { SpanDetail, Trace, TraceMessage } from "../../types"; import { fmtMs } from "../../utils"; @@ -45,6 +47,7 @@ export function TraceConversation({ ); const complete = loadedCount === steps.length; const items = buildConversation(trace.spans, details, complete); + const multipleAgents = new Set(items.map((item) => item.agentId).filter(Boolean)).size > 1; const inlineErrorIds = new Set(items.filter((item) => item.showError).map((item) => item.span.span_id)); const rootErrors = trace.spans.filter((span) => { const failedRoot = span.parent_span_id === null && span.status === "error" && span.type !== "tool"; @@ -52,31 +55,40 @@ export function TraceConversation({ }); return (
-
+
{rootErrors.map((span) => ( ))} {items.map((item) => ( -
+
+ {(multipleAgents || item.toolResult === undefined) && ( +
+
+ {multipleAgents && ( + + {item.agentName} + + )} + {fmtMs(item.span.start_offset_ms)} +
+ {item.toolResult === undefined && ( + + )} +
+ )} {item.showError && } {item.messages.map((message, index) => ( ))} - {item.toolResult !== undefined && } - + {item.toolResult !== undefined && }
))} {queries.map( @@ -116,30 +128,49 @@ export function TraceConversation({ ); } -function ConversationTool({ item }: { item: ConversationItem }) { - const [open, setOpen] = useState(false); +function ConversationTool({ item, onOpenStep }: { item: ConversationItem; onOpenStep: (id: string) => void }) { + const [open, setOpen] = useState(item.span.status === "error"); const failed = item.span.status === "error"; + const summary = toolSummary(item.toolCall?.args); return ( -
- +
+
+ + +
{open && ( -
- {item.toolCall && } +
+ {item.toolCall && } {item.span.error && item.span.error !== item.toolResult && (

{item.span.error}

)} @@ -152,9 +183,7 @@ function ConversationTool({ item }: { item: ConversationItem }) { iconOnly />
-
-            {item.toolResult || "No output recorded"}
-          
+
)}
@@ -171,13 +200,7 @@ function ConversationMessage({ message }: { message: TraceMessage }) {
); - if (message.role === "tool") - return ( -
- {message.name || "Tool result"} -
{message.content}
-
- ); + if (message.role === "tool") return ; return (
{message.content && ( diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts index 2a42089a739..6063ae3661c 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.test.ts @@ -20,6 +20,40 @@ const detail = (span_id: string, input: unknown, output: unknown): SpanDetail => }); describe("trace conversation", () => { + it.each(["reviewer", "__proto__", "constructor"])("keeps repeated agent name %s stable as steps load", (name) => { + const first = { ...root, span_id: "first", name, start_offset_ms: 1 }; + const second = { ...first, span_id: "second", start_offset_ms: 2 }; + const spans = [second, first]; + const firstDetails = new Map([["first", detail("first", "Review code", "")]]); + const partial = buildConversation(spans, firstDetails, false); + const complete = buildConversation( + spans, + new Map([...firstDetails, ["second", detail("second", "Review tests", "")]]), + true, + ); + expect(partial.map(({ agentId, agentName }) => ({ agentId, agentName }))).toEqual([ + { agentId: "first", agentName: `${name} (1)` }, + ]); + expect(complete.map(({ agentId, agentName }) => ({ agentId, agentName }))).toEqual([ + { agentId: "first", agentName: `${name} (1)` }, + { agentId: "second", agentName: `${name} (2)` }, + ]); + }); + + it("breaks equal-time agent label ties by span ID without changing event order", () => { + const first = { ...root, span_id: "first", name: "reviewer", start_offset_ms: 1 }; + const second = { ...first, span_id: "second" }; + const details = new Map([ + ["first", detail("first", "Review code", "")], + ["second", detail("second", "Review tests", "")], + ]); + const items = buildConversation([second, first], details, true); + expect(items.map(({ agentId, agentName }) => ({ agentId, agentName }))).toEqual([ + { agentId: "second", agentName: "reviewer (2)" }, + { agentId: "first", agentName: "reviewer (1)" }, + ]); + }); + it("removes repeated prefixes and trimmed context, but preserves a genuinely repeated question", () => { expect(newConversationMessages([user, call, result], [user, call, result, answer])).toEqual([answer]); expect(newConversationMessages([user, call, result], [call, result, answer])).toEqual([answer]); @@ -252,3 +286,56 @@ describe("trace conversation", () => { expect(items.flatMap((item) => item.messages)).toEqual([answer, answer]); }); }); + +describe("recorded tool summaries", () => { + it("pairs repeated named calls within their own branch and preserves unmatched calls and custom text", () => { + const model = { ...root, span_id: "model", parent_span_id: "root", type: "llm", start_offset_ms: 1 } as Span; + const first = { ...model, span_id: "first", name: "terminal", type: "tool", start_offset_ms: 2 } as Span; + const second = { ...first, span_id: "second", start_offset_ms: 3 }; + const child = { ...root, span_id: "child", parent_span_id: "root", start_offset_ms: 4 }; + const childTool = { ...first, span_id: "child-tool", parent_span_id: "child", start_offset_ms: 5 }; + const saved = "SAVED TASK RESUMED: Continue the unfinished task exactly as recorded"; + const summary = JSON.stringify([{ content: "Checking", tool_names: ["terminal", "terminal", "read_file"] }]); + const details = new Map([ + ["root", detail("root", [{ role: "user", content: saved }], [])], + [ + "model", + { ...detail("model", [], []), output: summary, output_ui: { kind: "text", text: summary } } as SpanDetail, + ], + ["first", detail("first", { command: "pwd" }, { output: "/workspace", exit_code: 0 })], + ["second", detail("second", { command: "pwd" }, { output: "/workspace", exit_code: 0 })], + ["child", detail("child", [{ role: "user", content: saved }], [])], + ["child-tool", detail("child-tool", { path: "README.md" }, "content")], + ]); + const items = buildConversation([root, model, first, second, child, childTool], details, true); + expect(items.flatMap((item) => item.messages).filter((message) => message.content === saved)).toHaveLength(2); + expect(items.flatMap((item) => item.messages.flatMap((message) => message.tool_calls ?? []))).toEqual([ + { name: "read_file", args: undefined }, + ]); + expect(items.filter((item) => item.toolCall).map((item) => item.id)).toEqual(["first", "second", "child-tool"]); + expect(JSON.parse(items.find((item) => item.id === "first")!.toolResult!)).toEqual({ + output: "/workspace", + exit_code: 0, + }); + }); + + it("deduplicates native OpenAI function calls without requiring message text", () => { + const model = { ...root, span_id: "model", parent_span_id: "root", type: "llm", start_offset_ms: 1 } as Span; + const tool = { ...model, span_id: "tool", name: "read_file", type: "tool", start_offset_ms: 2 } as Span; + const output = [ + { + role: "assistant", + content: null, + tool_calls: [{ type: "function", function: { name: "read_file", arguments: '{"path":"README.md"}' } }], + }, + ]; + const details = new Map([ + ["root", detail("root", [], [])], + ["model", detail("model", [], output)], + ["tool", detail("tool", { path: "README.md" }, "file content")], + ]); + expect( + buildConversation([root, model, tool], details, true).filter((item) => item.toolCall || item.messages.length), + ).toHaveLength(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts index 1adbee35570..81cc4e17d0e 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/conversation.ts @@ -1,6 +1,6 @@ import type { Span, SpanDetail, TraceMessage, TraceToolCall, UIContent } from "../../types"; -import { isFrameworkSpan, parseJson, parseMessages, prettyPayload } from "../../utils"; -import { toTraceMessage } from "../content/payload"; +import { isFrameworkSpan, parseAssistantSummary, parseMessages, prettyPayload } from "../../utils"; +import { toTraceMessage, toolInput } from "../content/payload"; export const CONVERSATION_PAGE_SIZE = 20; @@ -24,7 +24,8 @@ function contentText(value: string, content?: UIContent): string { function messages(value: string, content: UIContent | undefined, role: string): TraceMessage[] { if (content?.kind === "messages") return content.messages.map(toTraceMessage); - const parsed = !content ? parseMessages(value) : null; + const source = content?.kind === "text" ? content.text : value; + const parsed = parseMessages(source) ?? (role === "assistant" ? parseAssistantSummary(source) : null); if (parsed) return parsed; const text = contentText(value, content); return text ? [{ role, content: text }] : []; @@ -69,6 +70,8 @@ export interface ConversationItem { messages: TraceMessage[]; toolCall?: TraceToolCall; toolResult?: string; + agentId?: string; + agentName?: string; showError?: boolean; } @@ -78,16 +81,13 @@ function toolItem( pending: TraceToolCall[], items: ConversationItem[], ): ConversationItem { - const args = - parseJson(detail.input) ?? - (detail.input_ui?.kind === "fields" - ? Object.fromEntries(detail.input_ui.fields.map((field) => [field.key, field.value])) - : detail.input); + const args = toolInput(detail.input, detail.input_ui); const call = { name: span.name, args }; const match = pending.findIndex( (candidate) => candidate.name === call.name && - JSON.stringify(stableValue(candidate.args)) === JSON.stringify(stableValue(call.args)), + (candidate.args === undefined || + JSON.stringify(stableValue(candidate.args)) === JSON.stringify(stableValue(call.args))), ); if (match >= 0) { const [matched] = pending.splice(match, 1); @@ -97,7 +97,10 @@ function toolItem( message.tool_calls = message.tool_calls.filter((call) => call !== matched); } } - const result = contentText(detail.output, detail.output_ui); + const result = + detail.output_ui?.kind === "text" + ? detail.output_ui.text + : prettyPayload(detail.output) || contentText(detail.output, detail.output_ui); return { id: span.span_id, span, messages: [], toolCall: call, toolResult: result }; } @@ -168,6 +171,17 @@ function withoutForwardedAnswers( ); } +function agentLabels(agents: readonly Span[]): ReadonlyMap { + const named = agents.map((agent) => ({ agent, name: agent.name || agent.agent || "Agent" })); + return new Map( + Object.values(groupBy(named, ({ name }) => JSON.stringify(name))).flatMap((group) => + orderBy(group, [({ agent }) => agent.start_offset_ms, ({ agent }) => agent.span_id], ["asc", "asc"]).map( + ({ agent, name }, index) => [agent.span_id, group.length > 1 ? `${name} (${index + 1})` : name] as const, + ), + ), + ); +} + export function buildConversation( spans: readonly Span[], details: ReadonlyMap, @@ -241,10 +255,18 @@ export function buildConversation( }; if (combined.length || item.showError) items.push(item); } + const agents = [...new Set(conversationSteps(spans).map(branch))].flatMap((id) => { + const span = byId.get(id); + return span ? [span] : []; + }); + const labels = agentLabels(agents); return items .map((item) => ({ ...item, + agentId: branch(item.span), + agentName: labels.get(branch(item.span)) || item.span.agent, messages: item.messages.filter((message) => Boolean(message.content) || Boolean(message.tool_calls?.length)), })) .filter((item) => item.messages.length || item.toolResult !== undefined || item.showError); } +import { groupBy, orderBy } from "es-toolkit"; diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx index a950d68aa78..fb9285d0777 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/span/DetailPane.integration.test.tsx @@ -341,12 +341,14 @@ describe("DetailPane", () => { expect(output).not.toHaveTextContent("raw output left unparsed"); }); - it("keeps the failed-tool styling when a tool's output arrives as a single message", async () => { + it("identifies and styles a failed tool result when its output arrives as a single message", async () => { vi.mocked(agentTraceSpanCall).mockResolvedValue(failedToolMessageDetail); renderPane(spanRow(failedTool)); const output = await screen.findByRole("region", { name: "Output" }); - const result = within(output).getByText("permission denied: /etc/shadow"); + const result = within(output).getByRole("group", { name: "Failed tool result" }); + expect(result).toBeVisible(); expect(result).toHaveClass("text-destructive"); + expect(result).toHaveTextContent("permission denied: /etc/shadow"); expect(output).not.toHaveTextContent("Assistant"); }); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx index 931b53a4259..ae4d249575c 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/tree/TreeRows.tsx @@ -10,6 +10,7 @@ import type { GroupRowData, LoadMoreRowData, SpanRowData } from "../../tree"; import type { SpanType } from "../../types"; import { fmtMs, previewText, type TreeGuide } from "../../utils"; import { groupFacts, SpanHoverCard, spanFacts } from "./SpanHoverCard"; +import { toolSummary } from "../content/payload"; import { BAR_TRACK, barGeometry } from "./timeline"; export type TreeLayout = "tree" | "waterfall"; @@ -146,6 +147,7 @@ const readablePreview = (preview: string): string => { const subtitle = (row: SpanRowData, filtering: boolean): string => { const { span } = row; + if (span.type === "tool") return toolSummary(span.input_preview); if (filtering) return readablePreview(span.input_preview) || span.agent; return span.type === "agent" && span.parent_span_id ? readablePreview(span.input_preview) : ""; }; diff --git a/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts b/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts index eb7cca4bcac..d074a5c56fe 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/utils.test.ts @@ -300,10 +300,42 @@ describe("payload helpers", () => { expect( parseMessages(JSON.stringify({ role: "assistant", content: [{ type: "text", text: "An execution record" }] })), ).toEqual([{ role: "assistant", content: "An execution record" }]); + expect(parseMessages(JSON.stringify({ role: "assistant", content: "hello", tool_calls: {} }))).toEqual([ + { role: "assistant", content: "hello", tool_calls: [{ name: "Tool call", args: {} }] }, + ]); + expect( + parseMessages( + JSON.stringify({ role: "assistant", content: "hello", tool_calls: [{ name: "lookup", args: "42" }] }), + ), + ).toEqual([{ role: "assistant", content: "hello", tool_calls: [{ name: "lookup", args: "42" }] }]); expect(parseMessages('[{"role":"assistant","tool_calls":[]}]')).toBeNull(); expect(parseMessages('[{"role":"user","content":42}]')).toBeNull(); }); + it("preserves conversation text and incomplete calls beside valid function calls", () => { + const incomplete = { id: "pending", function: { arguments: '{"path":"README.md"}' } }; + const tool_calls = [incomplete, { function: { name: "read_file", arguments: '{"path":"AGENTS.md"}' } }, null]; + expect( + parseMessages( + JSON.stringify([ + { role: "user", content: "Read the project instructions" }, + { role: "assistant", content: "Checking the files", tool_calls }, + ]), + ), + ).toEqual([ + { role: "user", content: "Read the project instructions" }, + { + role: "assistant", + content: "Checking the files", + tool_calls: [ + { name: "Tool call", args: incomplete }, + { name: "read_file", args: { path: "AGENTS.md" } }, + { name: "Tool call", args: null }, + ], + }, + ]); + }); + it("reads LangChain's serialized messages with their roles, names and tool calls", () => { const dumped = [ { type: "human", data: { content: "What is an agent trace?", name: null } }, diff --git a/ui/litellm-dashboard/src/components/lens/traces/utils.ts b/ui/litellm-dashboard/src/components/lens/traces/utils.ts index 99a2a1194ec..a71fdb329eb 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/utils.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/utils.ts @@ -323,6 +323,9 @@ const LANGCHAIN_ROLE: Readonly> = { tool: "tool", }; +const isMessageContent = (value: unknown): value is string | unknown[] => + typeof value === "string" || Array.isArray(value); + const langchainToolCalls = (data: object): TraceToolCall[] | undefined => { const calls: unknown = Reflect.get(data, "tool_calls"); if (!Array.isArray(calls) || calls.length === 0) return undefined; @@ -337,7 +340,7 @@ const parseLangchainMessage = (value: object): TraceMessage | null => { const data: unknown = Reflect.get(value, "data"); if (!role || typeof data !== "object" || data === null) return null; const content: unknown = Reflect.get(data, "content"); - if (typeof content !== "string" && !Array.isArray(content)) return null; + if (!isMessageContent(content)) return null; const name: unknown = Reflect.get(data, "name"); const toolCalls = langchainToolCalls(data); return { @@ -348,15 +351,59 @@ const parseLangchainMessage = (value: object): TraceMessage | null => { }; }; +const parseToolCall = (call: unknown): TraceToolCall => { + const unknownCall = { name: "Tool call", args: call }; + if (!call || typeof call !== "object") return unknownCall; + const fn: unknown = Reflect.get(call, "function"); + const source = fn && typeof fn === "object" ? fn : call; + const name: unknown = Reflect.get(source, "name"); + if (typeof name !== "string" || !name) return unknownCall; + if ("args" in source) return { name, args: source.args }; + const args: unknown = Reflect.get(source, "arguments"); + return { name, args: typeof args === "string" ? parseJson(args) ?? args : args }; +}; + const parseMessage = (value: unknown): TraceMessage | null => { if (typeof value !== "object" || value === null) return null; if (!("role" in value) && "data" in value) return parseLangchainMessage(value); const role: unknown = Reflect.get(value, "role"); const content: unknown = Reflect.get(value, "content") ?? Reflect.get(value, "parts"); - if (typeof role !== "string" || (typeof content !== "string" && !Array.isArray(content))) return null; - return { ...value, role, content: messageText(typeof content === "string" ? content : JSON.stringify(content)) }; + const rawCalls: unknown = Reflect.get(value, "tool_calls"); + const callEntries = Array.isArray(rawCalls) ? rawCalls : [rawCalls]; + const hasCalls = rawCalls != null && callEntries.length > 0; + const emptyToolMessage = content == null && hasCalls; + const hasContent = isMessageContent(content) || emptyToolMessage; + if (typeof role !== "string" || !hasContent) return null; + const calls = callEntries.map(parseToolCall); + const text = typeof content === "string" ? content : JSON.stringify(content ?? ""); + return { + ...value, + role, + content: content == null ? "" : messageText(text), + ...(rawCalls != null ? { tool_calls: calls } : {}), + }; }; +export function parseAssistantSummary(value: string): TraceMessage[] | null { + const parsed = parseJson(value); + if (!Array.isArray(parsed) || !parsed.length) return null; + const isSummary = (item: unknown): item is { content: string | null; tool_names: string[] } => { + if (!item || typeof item !== "object") return false; + const content: unknown = Reflect.get(item, "content"); + const tools: unknown = Reflect.get(item, "tool_names"); + const knownFields = Object.keys(item).every((key) => key === "content" || key === "tool_names"); + const validContent = content === null || typeof content === "string"; + const validTools = Array.isArray(tools) && tools.every((name: unknown) => typeof name === "string"); + return knownFields && validContent && validTools; + }; + if (!parsed.every(isSummary)) return null; + return parsed.map((item) => ({ + role: "assistant", + content: item.content ?? "", + tool_calls: item.tool_names.map((name: string) => ({ name, args: undefined })), + })); +} + /** An llm span's input (array of messages) or output (one message); null when it isn't one. */ export function parseMessages(value: string): TraceMessage[] | null { const parsed = parseJson(value);