From d5202e59ea075bd191d09281560697362a84bd93 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Tue, 6 Oct 2026 13:45:32 -0700 Subject: [PATCH] fix(lens): retain successful split content reads --- .../TraceConversation.integration.test.tsx | 57 +++++++++++++++++++ .../detail/conversation/TraceConversation.tsx | 7 ++- .../detail/conversation/conversation.ts | 7 ++- .../conversation/useConversationDetails.ts | 49 ++++++++++------ 4 files changed, 99 insertions(+), 21 deletions(-) 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 2c21dbb61a6..ea1e1b649d0 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 @@ -477,6 +477,63 @@ describe("TraceConversation", () => { expect(screen.queryByText("End of conversation")).not.toBeInTheDocument(); }); + it("retains readable split results and retries only the oversized span", async () => { + const user = userEvent.setup(); + const oversized = { ...tool, span_id: "oversized", name: "Large result", start_offset_ms: 2 }; + const later = { ...tool, span_id: "later", name: "Later result", start_offset_ms: 3 }; + const readLarge = vi + .fn() + .mockRejectedValueOnce(new ApiError("Content too large", 413, {})) + .mockRejectedValueOnce(new ApiError("Content too large", 413, {})) + .mockResolvedValue([{ ...toolDetail, span_id: "oversized" }]); + vi.mocked(agentTraceSpansCall).mockImplementation(async (_token, _trace, ids) => { + if (ids.length > 1) throw new ApiError("Content too large", 413, {}); + if (ids[0] === "oversized") return readLarge(); + return [ids[0] === "root" ? rootDetail : { ...toolDetail, span_id: ids[0] }]; + }); + renderWithProviders( + , + ); + expect(await screen.findByRole("alert")).toHaveTextContent("starting at Large result"); + expect(screen.getByText("Read the release notes")).toBeVisible(); + expect(screen.getByRole("button", { name: "Expand read_file tool call" })).toBeVisible(); + expect(screen.queryByText("The release is ready")).not.toBeInTheDocument(); + const firstRequests = vi.mocked(agentTraceSpansCall).mock.calls.length; + + await user.click(screen.getByRole("button", { name: "Retry batch" })); + await waitFor(() => expect(readLarge).toHaveBeenCalledTimes(2)); + expect(screen.getByRole("alert")).toHaveTextContent("starting at Large result"); + expect(screen.getByRole("button", { name: "Expand read_file tool call" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Retry batch" })); + expect(await screen.findByText("The release is ready")).toBeVisible(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect( + vi + .mocked(agentTraceSpansCall) + .mock.calls.slice(firstRequests) + .map((call) => call[2]), + ).toEqual([["oversized"], ["oversized"]]); + }); + + it("shows conflicting argument evidence beside arguments even with a recorded output", async () => { + const user = userEvent.setup(); + vi.mocked(agentTraceSpansCall).mockResolvedValue([ + rootDetail, + { ...toolDetail, attributes: { "lens.content.input_status": "conflicting" } }, + ]); + renderWithProviders(); + await user.click(await screen.findByRole("button", { name: "Expand read_file tool call" })); + expect( + screen.getByText("Conflicting tool arguments were recorded. Inspect the capture source spans."), + ).toBeVisible(); + expect(screen.getByText("CHANGELOG.md")).toBeVisible(); + expect(screen.getByText("All checks passed")).toBeVisible(); + }); + it("shows a missing step explicitly and lets the user retry it", async () => { const user = userEvent.setup(); vi.mocked(agentTraceSpanCall).mockRejectedValueOnce(new Error("temporarily unavailable")); 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 daf695451c1..c44d54b557b 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 @@ -138,11 +138,11 @@ function ConversationFeedback({ return ( <> {entries.map( - ({ query, span }) => - query.isError && ( + ({ failed, retry, span }) => + failed && (
Could not load conversation content starting at {span.name}. Retry this batch to continue. -
@@ -345,6 +345,7 @@ function ConversationTool({ item, onOpenStep }: { item: ConversationItem; onOpen {open && (
{item.toolCall && } + {item.inputWarning &&

{item.inputWarning}

} {item.span.error && item.span.error !== item.toolResult && (

{item.span.error}

)} 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 35aa18d5740..ecc6df31a02 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 @@ -122,6 +122,7 @@ export interface ConversationItem { toolCall?: TraceToolCall; toolResult?: string; toolAttempt?: { isError: boolean }; + inputWarning?: string; contentWarning?: string; agentId?: string; agentName?: string; @@ -159,12 +160,16 @@ function toolItem( ? detail.output_ui.text : prettyPayload(detail.output) || contentText(detail.output, detail.output_ui); const status = detail.attributes["lens.content.output_status"]; + const inputWarning = + detail.attributes["lens.content.input_status"] === "conflicting" + ? "Conflicting tool arguments were recorded. Inspect the capture source spans." + : undefined; const contentWarning = (() => { if (status === "conflicting") return "Conflicting tool outputs were recorded. Inspect the capture source spans."; if (status === "recorded" && !result) return "The recorded result is empty."; return undefined; })(); - return { id: span.span_id, span, messages: [], toolCall: call, toolResult: result, contentWarning }; + return { id: span.span_id, span, messages: [], toolCall: call, toolResult: result, inputWarning, contentWarning }; } interface ConversationEvent { diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/useConversationDetails.ts b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/useConversationDetails.ts index 843169218e5..ae2312e94c7 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/useConversationDetails.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/conversation/useConversationDetails.ts @@ -5,14 +5,24 @@ import { useTracesApi, type TracesApi } from "../../api"; import type { Span, SpanDetail, Trace } from "../../types"; import { CONVERSATION_PAGE_SIZE, conversationSteps } from "./conversation"; -async function readBatch(traces: TracesApi, traceId: string, ids: string[], traceRef?: string): Promise { +interface BatchResult { + details: SpanDetail[]; + failedIds: string[]; +} + +async function readBatch(traces: TracesApi, traceId: string, ids: string[], traceRef?: string): Promise { try { - return await traces.spans(traceId, ids, traceRef); + const details = await traces.spans(traceId, ids, traceRef); + const returned = new Set(details.map((detail) => detail.span_id)); + return { details, failedIds: ids.filter((id) => !returned.has(id)) }; } catch (error) { - if (!(error instanceof ApiError) || error.status !== 413 || ids.length <= 1) throw error; + if (!(error instanceof ApiError) || error.status !== 413 || ids.length <= 1) { + return { details: [], failedIds: ids }; + } const middle = Math.ceil(ids.length / 2); const first = await readBatch(traces, traceId, ids.slice(0, middle), traceRef); - return [...first, ...(await readBatch(traces, traceId, ids.slice(middle), traceRef))]; + const second = await readBatch(traces, traceId, ids.slice(middle), traceRef); + return { details: [...first.details, ...second.details], failedIds: [...first.failedIds, ...second.failedIds] }; } } @@ -26,16 +36,18 @@ export function useConversationDetails(trace: Trace, accessToken: string) { const batchQuery = useCallback( (batch: Span[]) => { const ids = batch.map((span) => span.span_id); + const queryKey = ["agentTraceContents", traceId, traceRef, ids, spanCount, accessToken]; return { - queryKey: ["agentTraceContents", traceId, traceRef, ids, spanCount, accessToken], - queryFn: async (): Promise => { - const details = await readBatch(traces, traceId, ids, traceRef); - const returned = new Set(details.map((detail) => detail.span_id)); - if (ids.some((id) => !returned.has(id))) throw new Error("Some requested spans are unavailable"); - for (const detail of details) { + queryKey, + queryFn: async (): Promise => { + const previous = queryClient.getQueryData(queryKey); + const retained = previous?.failedIds.length ? previous.details : []; + const pending = previous?.failedIds.length ? previous.failedIds : ids; + const result = await readBatch(traces, traceId, pending, traceRef); + for (const detail of result.details) { queryClient.setQueryData(["agentTraceSpan", traceId, traceRef, detail.span_id, accessToken], detail); } - return details; + return { details: [...retained, ...result.details], failedIds: result.failedIds }; }, staleTime: 30_000, retry: false as const, @@ -48,10 +60,9 @@ export function useConversationDetails(trace: Trace, accessToken: string) { visible.slice(index * CONVERSATION_PAGE_SIZE, (index + 1) * CONVERSATION_PAGE_SIZE), ); const queries = useQueries({ queries: batches.map(batchQuery) }); - const unresolvedIndex = queries.findIndex((query) => !query.isSuccess); - const loadedCount = unresolvedIndex < 0 ? queries.length : unresolvedIndex; + const complete = queries.every((query) => query.isSuccess && query.data.failedIds.length === 0); const details = new Map( - queries.slice(0, loadedCount).flatMap((query) => query.data!.map((detail) => [detail.span_id, detail] as const)), + queries.flatMap((query) => (query.data?.details ?? []).map((detail) => [detail.span_id, detail] as const)), ); const loadMore = useCallback( async (signal: AbortSignal) => { @@ -64,10 +75,14 @@ export function useConversationDetails(trace: Trace, accessToken: string) { ); return { details, - entries: batches.map((batch, index) => ({ span: batch[0], query: queries[index] })), - complete: loadedCount === batches.length && visible.length === steps.length, + entries: batches.map((batch, index) => ({ + span: batch.find((span) => queries[index].data?.failedIds.includes(span.span_id)) ?? batch[0], + failed: queries[index].isError || Boolean(queries[index].data?.failedIds.length), + retry: queries[index].refetch, + })), + complete: complete && visible.length === steps.length, loading: queries.some((query) => query.isPending), - failed: queries.some((query) => query.isError), + failed: queries.some((query) => query.isError || Boolean(query.data?.failedIds.length)), hasMore: visible.length < steps.length, loadMore, };