From 05a3186e9b67b68028961a3d2da664b000139723 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Tue, 6 Oct 2026 11:31:36 -0700 Subject: [PATCH 1/6] fix(lens): refresh open traces without claiming session completion --- .../lens/traces/detail/run/RunHeader.tsx | 36 ++++++- .../lens/traces/detail/run/RunView.test.tsx | 93 ++++++++++++++++++- .../lens/traces/detail/run/RunView.tsx | 15 ++- 3 files changed, 132 insertions(+), 12 deletions(-) diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx index 16afa455be6..2378381fbc8 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunHeader.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowLeft, Check, Copy, Link } from "lucide-react"; +import { ArrowLeft, Check, Copy, Link, RefreshCw } from "lucide-react"; import { useState } from "react"; import { useTimeout } from "usehooks-ts"; @@ -52,13 +52,14 @@ function Stat({ label, value, error = false }: { label: string; value: string; e function StatusPill({ failed }: { failed: boolean }) { return ( - {failed ? "Failed" : "Completed"} + {failed ? "Errors recorded" : "Recorded"} ); } @@ -83,10 +84,25 @@ interface RunHeaderProps { handoff: TraceHandoff; onBack: () => void; embedded: boolean; + refreshing: boolean; + onRefresh: () => void; + live: boolean; + canLive: boolean; + onLiveChange: () => void; } /** Run identity, view switch and totals in two tight rows. */ -export function RunHeader({ trace, handoff, onBack, embedded }: RunHeaderProps) { +export function RunHeader({ + trace, + handoff, + onBack, + embedded, + refreshing, + onRefresh, + live, + canLive, + onLiveChange, +}: RunHeaderProps) { const { summary } = trace; const failed = summary.status === "error"; return ( @@ -112,6 +128,20 @@ export function RunHeader({ trace, handoff, onBack, embedded }: RunHeaderProps)
+ + { ); expect(header).toHaveTextContent("Duration 40.20s"); expect(header).toHaveTextContent(`Steps ${research.summary.span_count}`); - expect(header).not.toHaveTextContent("failed"); + expect(header).toHaveTextContent("Recorded"); + expect(header).not.toHaveTextContent("Completed"); }); it("shows the agent name with the SDK logo in the run header instead of the generic agent icon", async () => { @@ -361,7 +362,7 @@ describe("RunView", () => { expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2); }); - it("keeps a loaded snapshot on focus and reconnect", async () => { + it("refreshes a stale run on focus and reconnect without losing its selected step", async () => { testQueryClient.setQueryDefaults(["agentTrace"], { refetchOnWindowFocus: true, refetchOnReconnect: true }); vi.mocked(agentTraceCall).mockReset(); renderRun(research); @@ -374,10 +375,92 @@ describe("RunView", () => { onlineManager.setOnline(true); }); await waitFor(() => expect(testQueryClient.isFetching()).toBe(0)); - expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(1); + expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research)); expect(screen.getByRole("tree", { name: "Spans in time order" })).toHaveTextContent(research.spans[0].name); }); + it("refreshes new spans from the header while preserving the selected step", async () => { + vi.mocked(agentTraceCall).mockReset(); + vi.mocked(agentTraceCall) + .mockResolvedValueOnce(research) + .mockResolvedValue({ + ...research, + summary: { ...research.summary, span_count: research.summary.span_count + 1 }, + spans: [ + ...research.spans, + { + ...research.spans[0], + span_id: "new-step", + name: "newly received step", + parent_span_id: rootSpanId(research), + type: "tool", + }, + ], + }); + renderWithProviders(); + expect(await screen.findByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research)); + fireEvent.click(screen.getByRole("button", { name: "Refresh run" })); + expect(await screen.findByText("newly received step")).toBeVisible(); + expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count + 1}`); + expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research)); + }); + + it("receives a growing run on the polling interval", async () => { + vi.useFakeTimers(); + try { + vi.mocked(agentTraceCall).mockReset(); + vi.mocked(agentTraceCall) + .mockResolvedValueOnce(research) + .mockResolvedValue({ + ...research, + summary: { ...research.summary, span_count: research.summary.span_count + 7 }, + }); + renderWithProviders(); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count}`); + await act(async () => { + await vi.advanceTimersByTimeAsync(30_100); + }); + expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count + 7}`); + expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", rootSpanId(research)); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps fixed snapshot sources out of live mode", async () => { + vi.mocked(agentTraceCall).mockResolvedValue(research); + renderWithProviders( + + + , + ); + expect(await screen.findByRole("button", { name: "Live updates" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Live updates" })).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByRole("button", { name: "Refresh run" })).toBeEnabled(); + }); + + it("can pause live updates without disabling manual refresh", async () => { + vi.mocked(agentTraceCall).mockReset(); + renderRun(research); + await screen.findByTestId("detail-pane"); + fireEvent.click(screen.getByRole("button", { name: "Live updates" })); + expect(screen.getByRole("button", { name: "Live updates" })).toHaveAttribute("aria-pressed", "false"); + await testQueryClient.invalidateQueries({ queryKey: ["agentTrace"], refetchType: "none" }); + await act(async () => { + focusManager.setFocused(false); + onlineManager.setOnline(false); + focusManager.setFocused(true); + onlineManager.setOnline(true); + }); + expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Refresh run" })); + await waitFor(() => expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2)); + }); + it("keeps a way back to the runs table when a run fails to load", async () => { const user = userEvent.setup(); const onBack = vi.fn(); @@ -457,7 +540,7 @@ describe("RunView", () => { it("distinguishes a completed run with recovered step errors from a failed run", async () => { renderRun({ ...research, summary: { ...research.summary, status: "ok", error_count: 2 } }); const header = await screen.findByRole("banner"); - expect(header).toHaveTextContent("Completed"); + expect(header).toHaveTextContent("Recorded"); expect(header).toHaveTextContent("Step errors 2"); expect(header).not.toHaveTextContent("Failed"); }); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx index 4d78b136c17..1a1ca381e16 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx @@ -2,7 +2,7 @@ import { QueryErrorResetBoundary, useQueryClient, useSuspenseInfiniteQuery } from "@tanstack/react-query"; import { ArrowLeft } from "lucide-react"; -import { Suspense, useDeferredValue, useEffect, useMemo } from "react"; +import { Suspense, useDeferredValue, useEffect, useMemo, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { LoadingState } from "@/components/shared/LoadingState"; @@ -91,6 +91,7 @@ function LoadedRun({ }: RunViewProps & { switching: boolean }) { const traces = useTracesApi(accessToken); const queryClient = useQueryClient(); + const [live, setLive] = useState(traces.live); const queryKey = ["agentTrace", traceId, traceRef, accessToken]; const traceQueryOptions = { queryKey, @@ -98,9 +99,10 @@ function LoadedRun({ initialPageParam: null as string | null, getNextPageParam: (lastPage: Trace) => lastPage.next_cursor ?? undefined, staleTime: 30_000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, + refetchOnWindowFocus: live, + refetchOnReconnect: live, + refetchOnMount: true, + refetchInterval: live ? 30_000 : (false as const), retry: traceReadRetry, retryDelay: traceReadRetryDelay, }; @@ -140,6 +142,11 @@ function LoadedRun({ handoff={traces.handoff(trace.summary.trace_id, null, trace.summary.trace_ref)} onBack={onBack} embedded={embedded} + refreshing={traceQuery.isFetching} + onRefresh={() => void traceQuery.refetch()} + live={live} + canLive={traces.live} + onLiveChange={() => setLive((enabled) => !enabled)} /> {(traceQuery.hasNextPage || failure) && ( Date: Tue, 6 Oct 2026 11:52:25 -0700 Subject: [PATCH 2/6] fix(lens): cancel paused refreshes and distinguish refresh failures --- .../lens/traces/detail/run/RunView.test.tsx | 54 +++++++++++++++++++ .../lens/traces/detail/run/RunView.tsx | 16 +++++- 2 files changed, 68 insertions(+), 2 deletions(-) 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 9b6827a80c4..141d1ba78be 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 @@ -461,6 +461,60 @@ describe("RunView", () => { await waitFor(() => expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2)); }); + it("cancels an in-flight refresh and stops interval requests when live updates pause", async () => { + vi.useFakeTimers(); + try { + let completeRefresh!: (trace: Trace) => void; + vi.mocked(agentTraceCall).mockReset(); + vi.mocked(agentTraceCall).mockResolvedValueOnce(research).mockImplementationOnce( + () => new Promise((resolve) => { completeRefresh = resolve; }), + ); + renderWithProviders(); + await act(async () => { await vi.advanceTimersByTimeAsync(30_100); }); + expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2); + fireEvent.click(screen.getByRole("button", { name: "Live updates" })); + await act(async () => { + completeRefresh({ ...research, summary: { ...research.summary, span_count: research.summary.span_count + 7 } }); + await vi.advanceTimersByTimeAsync(60_100); + }); + expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count}`); + expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("retries a failed refresh without calling the next-page operation", async () => { + vi.mocked(agentTraceCall).mockReset(); + vi.mocked(agentTraceCall).mockResolvedValueOnce(research).mockRejectedValueOnce(new Error("refresh unavailable")).mockResolvedValue(research); + renderWithProviders(); + await screen.findByTestId("detail-pane"); + fireEvent.click(screen.getByRole("button", { name: "Refresh run" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Previously received steps are still shown"); + expect(screen.queryByText(/could not load more/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry refresh" })); + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + expect(vi.mocked(agentTraceCall).mock.calls.map((call) => call[3])).toEqual([null, null, null]); + }); + + it("refreshes paginated runs using the newly returned cursor", async () => { + const first = { ...research, spans: research.spans.slice(0, 1), next_cursor: "old-page" }; + const second = { ...research, spans: research.spans.slice(1), next_cursor: null }; + vi.mocked(agentTraceCall).mockReset(); + vi.mocked(agentTraceCall).mockResolvedValueOnce(first).mockResolvedValueOnce(second) + .mockResolvedValueOnce({ ...first, next_cursor: "new-page" }).mockImplementationOnce(async (_token, _id, _ref, cursor) => { + if (cursor !== "new-page") throw new Error("stale cursor"); + return second; + }); + renderWithProviders(); + fireEvent.click(await screen.findByRole("button", { name: "Load more steps" })); + await waitFor(() => expect(screen.queryByRole("button", { name: "Load more steps" })).not.toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "Refresh run" })); + await waitFor(() => expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(4)); + expect(vi.mocked(agentTraceCall).mock.calls.map((call) => call[3])).toEqual([null, "old-page", null, "new-page"]); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("keeps a way back to the runs table when a run fails to load", async () => { const user = userEvent.setup(); const onBack = vi.fn(); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx index 1a1ca381e16..a6337e21ea3 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx @@ -108,7 +108,11 @@ function LoadedRun({ }; const traceQuery = useSuspenseInfiniteQuery(traceQueryOptions); const refreshTrace = () => queryClient.resetQueries({ queryKey, exact: true }); - const failure = traceQuery.error ? classifyTraceReadFailure(traceQuery.error) : null; + const failure = traceQuery.isFetchNextPageError ? classifyTraceReadFailure(traceQuery.error) : null; + const toggleLive = () => { + if (live) void queryClient.cancelQueries({ queryKey, exact: true }); + setLive((enabled) => !enabled); + }; const trace = useMemo(() => { const pages = traceQuery.data.pages; return { @@ -146,8 +150,16 @@ function LoadedRun({ onRefresh={() => void traceQuery.refetch()} live={live} canLive={traces.live} - onLiveChange={() => setLive((enabled) => !enabled)} + onLiveChange={toggleLive} /> + {traceQuery.isRefetchError && ( +
+ Could not refresh this run. Previously received steps are still shown. + +
+ )} {(traceQuery.hasNextPage || failure) && ( Date: Tue, 6 Oct 2026 11:55:00 -0700 Subject: [PATCH 3/6] style(lens): format live refresh regressions --- .../lens/traces/detail/run/RunView.test.tsx | 27 ++++++++++++++----- .../lens/traces/detail/run/RunView.tsx | 7 ++++- 2 files changed, 26 insertions(+), 8 deletions(-) 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 141d1ba78be..59d7fcf20e6 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 @@ -466,11 +466,18 @@ describe("RunView", () => { try { let completeRefresh!: (trace: Trace) => void; vi.mocked(agentTraceCall).mockReset(); - vi.mocked(agentTraceCall).mockResolvedValueOnce(research).mockImplementationOnce( - () => new Promise((resolve) => { completeRefresh = resolve; }), - ); + vi.mocked(agentTraceCall) + .mockResolvedValueOnce(research) + .mockImplementationOnce( + () => + new Promise((resolve) => { + completeRefresh = resolve; + }), + ); renderWithProviders(); - await act(async () => { await vi.advanceTimersByTimeAsync(30_100); }); + await act(async () => { + await vi.advanceTimersByTimeAsync(30_100); + }); expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2); fireEvent.click(screen.getByRole("button", { name: "Live updates" })); await act(async () => { @@ -486,7 +493,10 @@ describe("RunView", () => { it("retries a failed refresh without calling the next-page operation", async () => { vi.mocked(agentTraceCall).mockReset(); - vi.mocked(agentTraceCall).mockResolvedValueOnce(research).mockRejectedValueOnce(new Error("refresh unavailable")).mockResolvedValue(research); + vi.mocked(agentTraceCall) + .mockResolvedValueOnce(research) + .mockRejectedValueOnce(new Error("refresh unavailable")) + .mockResolvedValue(research); renderWithProviders(); await screen.findByTestId("detail-pane"); fireEvent.click(screen.getByRole("button", { name: "Refresh run" })); @@ -501,8 +511,11 @@ describe("RunView", () => { const first = { ...research, spans: research.spans.slice(0, 1), next_cursor: "old-page" }; const second = { ...research, spans: research.spans.slice(1), next_cursor: null }; vi.mocked(agentTraceCall).mockReset(); - vi.mocked(agentTraceCall).mockResolvedValueOnce(first).mockResolvedValueOnce(second) - .mockResolvedValueOnce({ ...first, next_cursor: "new-page" }).mockImplementationOnce(async (_token, _id, _ref, cursor) => { + vi.mocked(agentTraceCall) + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second) + .mockResolvedValueOnce({ ...first, next_cursor: "new-page" }) + .mockImplementationOnce(async (_token, _id, _ref, cursor) => { if (cursor !== "new-page") throw new Error("stale cursor"); return second; }); diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx index a6337e21ea3..624380a006c 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx @@ -155,7 +155,12 @@ function LoadedRun({ {traceQuery.isRefetchError && (
Could not refresh this run. Previously received steps are still shown. -
From c05c634035ce9d333a837f4899741c9880fc9b06 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Tue, 6 Oct 2026 12:12:12 -0700 Subject: [PATCH 4/6] fix(lens): preserve manual reads when pausing live updates --- .../lens/traces/detail/run/RunView.test.tsx | 47 +++++++++++++++++-- .../lens/traces/detail/run/RunView.tsx | 23 +++++---- 2 files changed, 57 insertions(+), 13 deletions(-) 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 59d7fcf20e6..d4a57958526 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 @@ -464,14 +464,14 @@ describe("RunView", () => { it("cancels an in-flight refresh and stops interval requests when live updates pause", async () => { vi.useFakeTimers(); try { - let completeRefresh!: (trace: Trace) => void; + const updated = { ...research, summary: { ...research.summary, span_count: research.summary.span_count + 7 } }; vi.mocked(agentTraceCall).mockReset(); vi.mocked(agentTraceCall) .mockResolvedValueOnce(research) .mockImplementationOnce( () => new Promise((resolve) => { - completeRefresh = resolve; + setTimeout(() => resolve(updated), 1_000); }), ); renderWithProviders(); @@ -481,7 +481,6 @@ describe("RunView", () => { expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2); fireEvent.click(screen.getByRole("button", { name: "Live updates" })); await act(async () => { - completeRefresh({ ...research, summary: { ...research.summary, span_count: research.summary.span_count + 7 } }); await vi.advanceTimersByTimeAsync(60_100); }); expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${research.summary.span_count}`); @@ -491,6 +490,48 @@ describe("RunView", () => { } }); + it.each(["Refresh run", "Load more steps"])( + "keeps an in-flight manual %s when live updates pause", + async (action) => { + vi.useFakeTimers(); + try { + const first: Trace = { ...research, next_cursor: "next-page" }; + const updated: Trace = { + ...research, + summary: { ...research.summary, span_count: research.summary.span_count + 7 }, + next_cursor: null, + }; + vi.mocked(agentTraceCall).mockReset(); + vi.mocked(agentTraceCall) + .mockResolvedValueOnce(first) + .mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout(() => resolve(updated), 1_000); + }), + ); + renderWithProviders( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + fireEvent.click(screen.getByRole("button", { name: action })); + fireEvent.click(screen.getByRole("button", { name: "Live updates" })); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_100); + }); + expect(screen.queryByRole("button", { name: "Load more steps" })).not.toBeInTheDocument(); + expect(vi.mocked(agentTraceCall)).toHaveBeenCalledTimes(2); + if (action === "Refresh run") { + expect(screen.getByRole("banner")).toHaveTextContent(`Steps ${updated.summary.span_count}`); + } + } finally { + vi.useRealTimers(); + } + }, + ); + it("retries a failed refresh without calling the next-page operation", async () => { vi.mocked(agentTraceCall).mockReset(); vi.mocked(agentTraceCall) diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx index 624380a006c..e3e939e3c69 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx @@ -92,6 +92,7 @@ function LoadedRun({ const traces = useTracesApi(accessToken); const queryClient = useQueryClient(); const [live, setLive] = useState(traces.live); + const [manualRead, setManualRead] = useState(false); const queryKey = ["agentTrace", traceId, traceRef, accessToken]; const traceQueryOptions = { queryKey, @@ -109,8 +110,15 @@ function LoadedRun({ const traceQuery = useSuspenseInfiniteQuery(traceQueryOptions); const refreshTrace = () => queryClient.resetQueries({ queryKey, exact: true }); const failure = traceQuery.isFetchNextPageError ? classifyTraceReadFailure(traceQuery.error) : null; + const readManually = (read: () => Promise) => { + setManualRead(true); + void read().finally(() => setManualRead(false)); + }; + const refreshRun = () => readManually(() => traceQuery.refetch()); const toggleLive = () => { - if (live) void queryClient.cancelQueries({ queryKey, exact: true }); + if (live && !manualRead && !traceQuery.isFetchingNextPage) { + void queryClient.cancelQueries({ queryKey, exact: true }); + } setLive((enabled) => !enabled); }; const trace = useMemo(() => { @@ -147,7 +155,7 @@ function LoadedRun({ onBack={onBack} embedded={embedded} refreshing={traceQuery.isFetching} - onRefresh={() => void traceQuery.refetch()} + onRefresh={refreshRun} live={live} canLive={traces.live} onLiveChange={toggleLive} @@ -155,12 +163,7 @@ function LoadedRun({ {traceQuery.isRefetchError && (
Could not refresh this run. Previously received steps are still shown. -
@@ -171,8 +174,8 @@ function LoadedRun({ total={trace.summary.span_count} failure={failure} busy={traceQuery.isFetching} - onLoadMore={() => void traceQuery.fetchNextPage()} - onRefresh={() => void refreshTrace()} + onLoadMore={() => readManually(() => traceQuery.fetchNextPage())} + onRefresh={() => readManually(refreshTrace)} /> )} Date: Tue, 6 Oct 2026 13:47:18 -0700 Subject: [PATCH 5/6] fix(lens): refresh resumed native sessions and retain paging --- litellm-rust/crates/traces-cache/src/cache.rs | 25 ++++++++-- .../crates/traces-cache/tests/read.rs | 48 +++++++++++++++++++ .../TraceConversation.integration.test.tsx | 23 +++++++++ .../lens/traces/detail/run/RunView.tsx | 10 ++-- 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/litellm-rust/crates/traces-cache/src/cache.rs b/litellm-rust/crates/traces-cache/src/cache.rs index a5526a39963..f37db2c1c90 100644 --- a/litellm-rust/crates/traces-cache/src/cache.rs +++ b/litellm-rust/crates/traces-cache/src/cache.rs @@ -58,9 +58,8 @@ impl SnapshotKey { } } -/// How long a read result stays reusable: traces still receiving spans, or read with spend -/// unavailable, are re-read after `LIVE_TTL`; traces quiet for `SETTLED_AFTER_MS` are kept for -/// `SETTLED_TTL`. +/// Native sessions can resume without a terminal record, so their reads retain `LIVE_TTL`. +/// Other traces with known spend settle after `SETTLED_AFTER_MS` of inactivity. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Freshness { Live, @@ -69,6 +68,12 @@ pub enum Freshness { impl Freshness { pub fn of(rows: &[TraceSpansRow], spend_known: bool, snapshot_ms: u64) -> Self { + if rows + .iter() + .any(|row| matches!(row.framework.as_str(), "claude-code" | "claude-agent-sdk")) + { + return Self::Live; + } let last_end_ms = rows .iter() .map(|row| row.start_ns.saturating_add_unsigned(row.duration_ns) / 1_000_000) @@ -393,4 +398,18 @@ mod tests { expected ); } + + #[rstest] + #[case::claude_code("claude-code")] + #[case::claude_agent_sdk("claude-agent-sdk")] + fn idle_native_sessions_remain_live(#[case] framework: &str) { + let native = TraceSpansRow { + framework: framework.into(), + ..row("native") + }; + assert_eq!( + Freshness::of(&[row("root"), native], true, LAST_END_MS + SETTLED_AFTER_MS), + Freshness::Live + ); + } } diff --git a/litellm-rust/crates/traces-cache/tests/read.rs b/litellm-rust/crates/traces-cache/tests/read.rs index 3f76d559a2b..18a83329107 100644 --- a/litellm-rust/crates/traces-cache/tests/read.rs +++ b/litellm-rust/crates/traces-cache/tests/read.rs @@ -706,6 +706,54 @@ async fn failed_reads_are_not_cached() { assert_eq!(store.calls(Operation::TraceSpans), 2); } +#[rstest] +#[case::claude_code("claude-code")] +#[case::claude_agent_sdk("claude-agent-sdk")] +#[tokio::test] +async fn resumed_native_sessions_refresh_after_live_ttl(#[case] framework: &str) { + let original = TraceSpansRow { + framework: framework.into(), + ..span(0) + }; + let store = FakeStore::with_spans("ref", vec![original.clone()]); + let reader = TraceReader::new(usize::MAX); + let access = access(); + let first = reader + .get_trace(&store, &access, "trace", "ref") + .await + .unwrap() + .unwrap(); + assert_eq!(first.spans.len(), 1); + + store.state.lock().unwrap().trace_spans.insert( + "ref".into(), + vec![ + original, + TraceSpansRow { + start_ns: now_ns(), + ..span(1) + }, + ], + ); + let cached = reader + .get_trace(&store, &access, "trace", "ref") + .await + .unwrap() + .unwrap(); + assert_eq!(cached.spans.len(), 1); + assert_eq!(store.calls(Operation::TraceSpans), 1); + + tokio::time::sleep(LIVE_TTL + Duration::from_millis(200)).await; + let resumed = reader + .get_trace(&store, &access, "trace", "ref") + .await + .unwrap() + .unwrap(); + assert_eq!(resumed.spans.len(), 2); + assert_eq!(store.calls(Operation::TraceSpans), 2); + assert_eq!(first.spans.len(), 1); +} + #[rstest] #[tokio::test] async fn listed_runs_are_read_once_until_a_live_run_expires() { 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 d39c486025d..a552f5b1285 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 @@ -70,6 +70,29 @@ describe("TraceConversation", () => { expect(screen.getByRole("heading", { name: "read_file" })).toBeVisible(); }); + it("can load the next conversation page after a refresh fails", async () => { + const user = userEvent.setup(); + const first = { ...trace, spans: [root], next_cursor: "next-page" }; + const second = { ...trace, spans: [tool], next_cursor: null }; + vi.mocked(agentTraceCall) + .mockResolvedValueOnce(first) + .mockRejectedValueOnce(new Error("refresh unavailable")) + .mockResolvedValue(second); + renderWithProviders( + , + ); + await user.click(await screen.findByRole("tab", { name: "Conversation" })); + expect(await screen.findByText("Read the release notes")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Refresh run" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Previously received steps are still shown"); + const more = screen.getByRole("button", { name: "Load next 20 entries" }); + expect(more).toBeEnabled(); + await user.click(more); + expect(await screen.findByRole("button", { name: "Expand read_file tool call" })).toBeVisible(); + expect(vi.mocked(agentTraceCall).mock.calls.map((call) => call[3])).toEqual([null, null, "next-page"]); + expect(screen.queryByText(/Could not load more conversation entries/)).not.toBeInTheDocument(); + }); + it("renders a failed shell exchange in both views and preserves its raw result", async () => { const user = userEvent.setup(); const command = "npm test -- checkout\nprintf 'finished\\n'"; diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx index 9f08ef0132b..cd4c90dbf1e 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx @@ -130,11 +130,11 @@ function LoadedRun({ }; }, [traceQuery.data]); const seekingSpan = !switching && selectedSpanMissing(trace, selection.spanId); - const { hasNextPage, isFetching, isError, fetchNextPage } = traceQuery; + const { hasNextPage, isFetching, isFetchNextPageError, fetchNextPage } = traceQuery; const canSeek = seekingSpan && hasNextPage; useEffect(() => { - if (canSeek && !isFetching && !isError) void fetchNextPage(); - }, [canSeek, isFetching, isError, fetchNextPage]); + if (canSeek && !isFetching && !isFetchNextPageError) void fetchNextPage(); + }, [canSeek, isFetching, isFetchNextPageError, fetchNextPage]); return ( From 4a679ecde77343630435afd64e7f46271adbf595 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Tue, 6 Oct 2026 14:09:19 -0700 Subject: [PATCH 6/6] fix(lens): serialize conversation paging with refresh --- .../TraceConversation.integration.test.tsx | 27 +++++++++++++++++++ .../lens/traces/detail/run/RunView.tsx | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) 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 a552f5b1285..a77b443ac9e 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 @@ -70,6 +70,33 @@ describe("TraceConversation", () => { expect(screen.getByRole("heading", { name: "read_file" })).toBeVisible(); }); + it("waits for an in-flight refresh before requesting another conversation page", async () => { + const user = userEvent.setup(); + const first = { ...trace, spans: [root], next_cursor: "old-page" }; + const refreshed = { ...first, next_cursor: "fresh-page" }; + const second = { ...trace, spans: [tool], next_cursor: null }; + const pending = Promise.withResolvers(); + vi.mocked(agentTraceCall) + .mockResolvedValueOnce(first) + .mockReturnValueOnce(pending.promise) + .mockResolvedValue(second); + renderWithProviders( + , + ); + await user.click(await screen.findByRole("tab", { name: "Conversation" })); + expect(await screen.findByText("Read the release notes")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Refresh run" })); + const more = screen.getByRole("button", { name: "Load next 20 entries" }); + expect(more).toBeDisabled(); + await user.click(more); + expect(agentTraceCall).toHaveBeenCalledTimes(2); + await act(async () => pending.resolve(refreshed)); + await waitFor(() => expect(more).toBeEnabled()); + await user.click(more); + expect(await screen.findByRole("button", { name: "Expand read_file tool call" })).toBeVisible(); + expect(vi.mocked(agentTraceCall).mock.calls.map((call) => call[3])).toEqual([null, null, "fresh-page"]); + }); + it("can load the next conversation page after a refresh fails", async () => { const user = userEvent.setup(); const first = { ...trace, spans: [root], next_cursor: "next-page" }; diff --git a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx index cd4c90dbf1e..feaa9ba2c8c 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/detail/run/RunView.tsx @@ -186,7 +186,7 @@ function LoadedRun({ embedded={embedded} stale={switching} conversationPaging={{ - loading: traceQuery.isFetchingNextPage, + loading: traceQuery.isFetching, failed: isFetchNextPageError, loadMore: fetchNextPage, }}