diff --git a/litellm-rust/crates/traces-clickhouse/tests/reads.rs b/litellm-rust/crates/traces-clickhouse/tests/reads.rs index ab2fb34c48b..01bad8f5322 100644 --- a/litellm-rust/crates/traces-clickhouse/tests/reads.rs +++ b/litellm-rust/crates/traces-clickhouse/tests/reads.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use litellm_http::Client; use litellm_traces::query::named::ReadAccessParams; -use litellm_traces_cache::{ReadError, TraceReader}; +use litellm_traces_cache::{ReadError, TraceReader, TraceStore}; use litellm_traces_clickhouse::{ ClickHouseTraces, Connection, InsertTable, QueryScope, insert_rows, }; 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 c950b3a53d7..3f5a76b62bb 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 @@ -276,6 +276,30 @@ describe("TraceConversation", () => { ).toBeVisible(); }); + it.each(["not_recorded", "conflicting"])( + "preserves a recorded tool error with %s result evidence", + async (status) => { + vi.mocked(agentTraceSpanCall).mockResolvedValue({ + ...toolDetail, + output: "", + attributes: { + "lens.content.execution_status": "not_recorded", + "lens.content.output_status": status, + "lens.content.tool_result_is_error": "0", + }, + }); + const failedTool = { ...tool, status: "error", error: "Permission denied" }; + renderWithProviders( + , + ); + expect(await screen.findByRole("button", { name: "Collapse read_file tool call" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(screen.getByText("Permission denied")).toBeVisible(); + }, + ); + it("loads twenty span details initially and pages conversation entries on demand", async () => { const user = userEvent.setup(); const spans = [ @@ -641,6 +665,36 @@ describe("TraceConversation", () => { expect(screen.getByText("The release is ready")).toBeVisible(); }); + it.each([false, true])( + "retains loaded details when an extended batch fails (new span count: %s)", + async (changedCount) => { + const user = userEvent.setup(); + const later = { ...tool, span_id: "later", name: "later tool", start_offset_ms: 2 }; + vi.mocked(agentTraceSpansCall) + .mockResolvedValueOnce([rootDetail, toolDetail]) + .mockRejectedValueOnce(new Error("next page unavailable")) + .mockResolvedValueOnce([rootDetail, toolDetail, { ...toolDetail, span_id: "later", output: "Later result" }]); + const { rerender } = renderWithProviders( + , + ); + expect(await screen.findByText("Read the release notes")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Expand read_file tool call" })); + const extended: Trace = { + ...trace, + spans: [...trace.spans, later], + summary: { ...trace.summary, span_count: trace.summary.span_count + Number(changedCount) }, + }; + rerender(); + expect(screen.getByText("All checks passed")).toBeVisible(); + expect(await screen.findByRole("alert")).toHaveTextContent("Retry this batch"); + expect(screen.getByText("Read the release notes")).toBeVisible(); + expect(screen.getByText("All checks passed")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Retry batch" })); + await user.click(await screen.findByRole("button", { name: "Expand later tool tool call" })); + expect(screen.getByText("Later result")).toBeVisible(); + }, + ); + it.each(["input", "output"])("distinguishes conflicting %s content from absent evidence", async (field) => { vi.mocked(agentTraceSpanCall).mockResolvedValue({ ...toolDetail, 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 880289c717c..a8fe8707df4 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 @@ -302,7 +302,7 @@ function ConversationStep({ } function ConversationTool({ item, onOpenStep }: { item: ConversationItem; onOpenStep: (id: string) => void }) { - const failed = item.toolAttempt?.isError ?? item.span.status === "error"; + const failed = item.toolAttempt?.isError || item.span.status === "error"; const executionStatus = failed ? "Failed" : "Completed"; const [open, setOpen] = useState(failed); const name = item.toolCall?.name ?? item.span.name; 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 d66d9e33faa..74dc1532484 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 @@ -33,6 +33,14 @@ export function useConversationDetails(trace: Trace, accessToken: string) { const steps = useMemo(() => conversationSteps(trace.spans), [trace.spans]); const visible = steps.slice(0, limit); const { trace_id: traceId, trace_ref: traceRef, span_count: spanCount } = trace.summary; + const cachedDetails = useCallback( + (ids: string[]) => + ids.flatMap((id) => { + const detail = queryClient.getQueryData(["agentTraceSpan", traceId, traceRef, id, accessToken]); + return detail ? [detail] : []; + }), + [traceId, traceRef, accessToken, queryClient], + ); const batchQuery = useCallback( (batch: Span[]) => { const ids = batch.map((span) => span.span_id); @@ -45,7 +53,10 @@ export function useConversationDetails(trace: Trace, accessToken: string) { const result = await readBatch(traces, traceId, pending, traceRef); const details = [ ...new Map( - [...(previous?.details ?? []), ...result.details].map((detail) => [detail.span_id, detail]), + [...(previous?.details ?? []), ...cachedDetails(ids), ...result.details].map((detail) => [ + detail.span_id, + detail, + ]), ).values(), ]; for (const detail of result.details) { @@ -58,7 +69,7 @@ export function useConversationDetails(trace: Trace, accessToken: string) { retryOnMount: false, }; }, - [traceId, traceRef, spanCount, accessToken, traces, queryClient], + [traceId, traceRef, spanCount, accessToken, traces, queryClient, cachedDetails], ); const batches = Array.from({ length: Math.ceil(visible.length / CONVERSATION_PAGE_SIZE) }, (_, index) => visible.slice(index * CONVERSATION_PAGE_SIZE, (index + 1) * CONVERSATION_PAGE_SIZE), @@ -66,7 +77,10 @@ export function useConversationDetails(trace: Trace, accessToken: string) { const queries = useQueries({ queries: batches.map(batchQuery) }); const complete = queries.every((query) => query.isSuccess && query.data.failedIds.length === 0); const details = new Map( - queries.flatMap((query) => (query.data?.details ?? []).map((detail) => [detail.span_id, detail] as const)), + [ + ...cachedDetails(visible.map((span) => span.span_id)), + ...queries.flatMap((query) => query.data?.details ?? []), + ].map((detail) => [detail.span_id, detail]), ); const loadMore = useCallback( async (signal: AbortSignal) => {