diff --git a/ui/litellm-dashboard/src/components/lens/traces/api.test.ts b/ui/litellm-dashboard/src/components/lens/traces/api.test.ts new file mode 100644 index 00000000000..8f29a105526 --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/api.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; + +import { agentHandoffText } from "./api"; +import { getProxyBaseUrl } from "../../networking"; + +vi.mock("../../networking", () => ({ getProxyBaseUrl: vi.fn(() => "https://proxy.test/root/") })); + +describe("agent handoff", () => { + it("requests bounded trace pages and explains how to retrieve captured content", () => { + const text = agentHandoffText("trace-one", null, "source-one"); + expect(text).toContain("'https://proxy.test/root/v1/traces/trace-one?trace_ref=source-one&page_size=200'"); + expect(text).toContain("Follow next_cursor"); + expect(text).toContain("/v1/traces/trace-one/spans/{span_id}"); + expect(text).toContain("using the same trace_ref"); + }); + + it("selects one span and keeps reserved characters inside their original parameter", () => { + const text = agentHandoffText("trace/one", "step?two", "source&cursor=other"); + expect(text).toContain( + "'https://proxy.test/root/v1/traces/trace%2Fone/spans/step%3Ftwo?trace_ref=source%26cursor%3Dother'", + ); + expect(text).toContain("this step's captured input, output, and attributes"); + }); + + it("quotes the proxy URL as shell data", () => { + vi.mocked(getProxyBaseUrl).mockReturnValueOnce("https://proxy.test/a'b/$(printf injected)"); + expect(agentHandoffText("trace-one", "span-one")).toContain( + "'https://proxy.test/a'\"'\"'b/$(printf injected)/v1/traces/trace-one/spans/span-one'", + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/traces/api.ts b/ui/litellm-dashboard/src/components/lens/traces/api.ts index 16503773dec..c4da02ebf70 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/api.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/api.ts @@ -11,6 +11,8 @@ import { } from "../../networking"; import type { SpanDetail, + SpanQuery, + TraceDetailQuery, SpanErrorPage, Trace, TraceListQuery, @@ -48,9 +50,23 @@ export interface TracesApi { /** A one-liner Claude Code / Codex can run to read the trace. */ export const agentHandoffText = (traceId: string, spanId?: string | null, traceRef?: string): string => { - const url = `${getProxyBaseUrl().replace(/\/$/, "")}/v1/traces/${traceId}?format=md${spanId ? `&span_id=${spanId}` : ""}${traceRef ? `&trace_ref=${traceRef}` : ""}`; + const base = `${getProxyBaseUrl().replace(/\/$/, "")}/v1/traces/${encodeURIComponent(traceId)}`; + const path = spanId ? `${base}/spans/${encodeURIComponent(spanId)}` : base; + const query = spanId + ? ({ trace_ref: traceRef } satisfies SpanQuery) + : ({ trace_ref: traceRef, page_size: 200 } satisfies TraceDetailQuery); + const params = new URLSearchParams( + Object.entries(query) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, String(value)]), + ).toString(); + const url = params ? `${path}?${params}` : path; + const quotedUrl = "'" + url.replaceAll("'", "'\"'\"'") + "'"; const what = spanId ? "this step of a LiteLLM agent trace" : "this LiteLLM agent trace"; - return `Read ${what}, explain what happened, and investigate any issues:\ncurl -s -H "Authorization: Bearer $LITELLM_API_KEY" "${url}"`; + const guidance = spanId + ? "The JSON response contains this step's captured input, output, and attributes. Report missing content and capture warnings explicitly." + : `The JSON response contains span summaries. Follow next_cursor by adding cursor to this URL until it is null, preserving trace_ref and page_size. Fetch captured content at ${base}/spans/{span_id}, using the same trace_ref. Report missing content and capture warnings explicitly.`; + return `Read ${what}, explain what happened, and investigate any issues:\ncurl --fail-with-body -sS -H "Authorization: Bearer $LITELLM_API_KEY" ${quotedUrl}\n${guidance}`; }; export function liveTracesApi(accessToken: string): TracesApi { diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx index d44b0c42621..5536a9cb2c2 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.test.tsx @@ -492,8 +492,8 @@ describe("RunView", () => { await user.click(await screen.findByRole("button", { name: /copy for agent/i })); expect(copyToClipboard).toHaveBeenCalledWith(agentHandoffText(research.summary.trace_id), "Command copied"); - expect(agentHandoffText("t1")).toContain('"http://proxy.test/v1/traces/t1?format=md"'); - expect(agentHandoffText("t1", "s1")).toContain("&span_id=s1"); + expect(agentHandoffText("t1")).toContain("http://proxy.test/v1/traces/t1?page_size=200"); + expect(agentHandoffText("t1", "s1")).toContain("/v1/traces/t1/spans/s1"); }); it("copies a link that reopens just this run", async () => { 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 cf2c32ff5c7..984cabedea6 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 @@ -345,14 +345,14 @@ describe("DetailPane", () => { expect(pane).toHaveTextContent("TimeoutError('slow')"); }); - it("'Copy step' copies a curl for just this span as Markdown", async () => { + it("'Copy step' copies a curl for this span's captured content", async () => { const user = userEvent.setup(); const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); renderPane(spanRow(llm)); await user.click(screen.getByRole("button", { name: "Copy step" })); await waitFor(() => expect(writeText).toHaveBeenCalled()); - expect(writeText.mock.calls[0][0]).toContain("http://proxy.test/v1/traces/t1?format=md&span_id=llm1"); + expect(writeText.mock.calls[0][0]).toContain("http://proxy.test/v1/traces/t1/spans/llm1"); }); it("renders the assistant tool call as a card and expands a long argument on click", async () => {