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,
};