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) => {