mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(lens): refresh open traces without claiming session completion (#44900)
* fix(lens): refresh open traces without claiming session completion * fix(lens): cancel paused refreshes and distinguish refresh failures * style(lens): format live refresh regressions * fix(lens): preserve manual reads when pausing live updates * fix(tracing): preserve optional provider evidence in fixture replay * fix(tracing): keep copied provider identities consistent * fix(lens): refresh resumed native sessions and retain paging * fix(lens): serialize conversation paging with refresh * fix(lens): refresh recorded content with trace details * fix(lens): refresh content using resolved trace references * fix(lens): preserve content through refresh failures * fix(lens): serialize conversation paging with content refresh
This commit is contained in:
parent
191967c207
commit
d181bc7b80
7 changed files with 458 additions and 25 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,31 @@ describe("TraceConversation", () => {
|
|||
.mockImplementation(async (_token, _trace, id) => (id === "root" ? rootDetail : { ...toolDetail, span_id: id }));
|
||||
});
|
||||
|
||||
it.each([false, true])("refreshes unchanged spans without hiding loaded content on failure (%s)", async (failed) => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(agentTraceCall).mockResolvedValue({
|
||||
...trace,
|
||||
summary: { ...trace.summary, trace_ref: "resolved-reference" },
|
||||
});
|
||||
renderWithProviders(
|
||||
<RoutedRunView traceId={trace.summary.trace_id} accessToken="test" onBack={vi.fn()} embedded />,
|
||||
);
|
||||
await user.click(await screen.findByRole("tab", { name: "Conversation" }));
|
||||
expect(await screen.findByText("The release is ready")).toBeVisible();
|
||||
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) => {
|
||||
if (failed) throw new Error("content refresh failed");
|
||||
return id === "root" ? { ...rootDetail, output: "Updated final answer" } : toolDetail;
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Refresh run" }));
|
||||
if (failed) {
|
||||
expect(await screen.findAllByRole("button", { name: "Retry step" })).toHaveLength(2);
|
||||
expect(screen.getByText("The release is ready")).toBeVisible();
|
||||
} else {
|
||||
expect(await screen.findByText("Updated final answer")).toBeVisible();
|
||||
expect(screen.queryByText("The release is ready")).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("switches to a readable transcript and opens the exact tool step from it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -70,6 +95,89 @@ 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<Trace>();
|
||||
vi.mocked(agentTraceCall)
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockReturnValueOnce(pending.promise)
|
||||
.mockResolvedValue(second);
|
||||
renderWithProviders(
|
||||
<RoutedRunView traceId={trace.summary.trace_id} accessToken="test" onBack={vi.fn()} embedded />,
|
||||
);
|
||||
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 without invalidating loaded content", 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(
|
||||
<RoutedRunView traceId={trace.summary.trace_id} accessToken="test" onBack={vi.fn()} embedded />,
|
||||
);
|
||||
await user.click(await screen.findByRole("tab", { name: "Conversation" }));
|
||||
expect(await screen.findByText("Read the release notes")).toBeVisible();
|
||||
const contentReads = vi.mocked(agentTraceSpanCall).mock.calls.length;
|
||||
vi.mocked(agentTraceSpanCall).mockRejectedValue(new Error("content unavailable"));
|
||||
await user.click(screen.getByRole("button", { name: "Refresh run" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Previously received steps are still shown");
|
||||
expect(agentTraceSpanCall).toHaveBeenCalledTimes(contentReads);
|
||||
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) =>
|
||||
id === "root" ? rootDetail : toolDetail,
|
||||
);
|
||||
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("keeps refresh busy until content finishes when live updates pause", async () => {
|
||||
const user = userEvent.setup();
|
||||
const pending = Promise.withResolvers<SpanDetail>();
|
||||
vi.mocked(agentTraceCall).mockResolvedValue({ ...trace, next_cursor: "next-page" });
|
||||
renderWithProviders(
|
||||
<RoutedRunView traceId={trace.summary.trace_id} accessToken="test" onBack={vi.fn()} embedded />,
|
||||
);
|
||||
await user.click(await screen.findByRole("tab", { name: "Conversation" }));
|
||||
expect(await screen.findByText("The release is ready")).toBeVisible();
|
||||
vi.mocked(agentTraceSpanCall).mockImplementation(async (_token, _trace, id) =>
|
||||
id === "root" ? pending.promise : toolDetail,
|
||||
);
|
||||
const refresh = screen.getByRole("button", { name: "Refresh run" });
|
||||
await user.click(refresh);
|
||||
await waitFor(() => expect(testQueryClient.isFetching({ queryKey: ["agentTrace"] })).toBe(0));
|
||||
expect(refresh).toBeDisabled();
|
||||
const more = screen.getByRole("button", { name: "Load next 20 entries" });
|
||||
expect(more).toBeDisabled();
|
||||
await user.click(refresh);
|
||||
await user.click(screen.getByRole("button", { name: "Live updates" }));
|
||||
await act(async () => pending.resolve({ ...rootDetail, output: "Updated final answer" }));
|
||||
expect(await screen.findByText("Updated final answer")).toBeVisible();
|
||||
await waitFor(() => expect(refresh).toBeEnabled());
|
||||
expect(more).toBeEnabled();
|
||||
expect(agentTraceCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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'";
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function useConversationDetails(trace: Trace, accessToken: string) {
|
|||
[traceId, traceRef, accessToken, traces],
|
||||
);
|
||||
const queries = useQueries({ queries: visible.map(spanQuery) });
|
||||
const unresolvedIndex = queries.findIndex((query) => !query.isSuccess);
|
||||
const unresolvedIndex = queries.findIndex((query) => query.data === undefined);
|
||||
const loadedCount = unresolvedIndex < 0 ? queries.length : unresolvedIndex;
|
||||
const details = new Map(
|
||||
queries.slice(0, loadedCount).map((query, index) => [visible[index].span_id, query.data!] as const),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span
|
||||
title="Status of received spans. More spans may still arrive."
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium",
|
||||
failed ? "bg-destructive/10 text-destructive" : "bg-trace-ok text-trace-ok-glyph",
|
||||
)}
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", failed ? "bg-destructive" : "bg-trace-ok-glyph")} />
|
||||
{failed ? "Failed" : "Completed"}
|
||||
{failed ? "Errors recorded" : "Recorded"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
const cost = runCost(summary);
|
||||
|
|
@ -113,6 +129,20 @@ export function RunHeader({ trace, handoff, onBack, embedded }: RunHeaderProps)
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
aria-pressed={live}
|
||||
disabled={!canLive}
|
||||
onClick={onLiveChange}
|
||||
aria-label="Live updates"
|
||||
>
|
||||
Live
|
||||
</Button>
|
||||
<Button variant="outline" size="xs" disabled={refreshing} onClick={onRefresh} aria-label="Refresh run">
|
||||
<RefreshCw className={cn("size-3", refreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
<CopyButton
|
||||
label="Copy link"
|
||||
icon={Link}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { RunView } from "./RunView";
|
|||
import { initialRunSelection } from "./useRunTree";
|
||||
import { tickLabel, timeTicks } from "../tree/timeline";
|
||||
import { traceShareUrl, useOpenTraceRouting } from "../../routing";
|
||||
import { agentHandoffText } from "../../api";
|
||||
import { agentHandoffText, liveTracesApi, TracesApiContext } from "../../api";
|
||||
import type { Span } from "../../types";
|
||||
import type { Trace } from "../../types";
|
||||
import { traceDisplayName } from "../../utils";
|
||||
|
|
@ -80,7 +80,8 @@ describe("RunView", () => {
|
|||
);
|
||||
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,200 @@ 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(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
|
||||
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(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
|
||||
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(
|
||||
<TracesApiContext.Provider value={{ ...liveTracesApi("sk-test"), live: false }}>
|
||||
<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />
|
||||
</TracesApiContext.Provider>,
|
||||
);
|
||||
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("cancels an in-flight refresh and stops interval requests when live updates pause", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
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<Trace>((resolve) => {
|
||||
setTimeout(() => resolve(updated), 1_000);
|
||||
}),
|
||||
);
|
||||
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
|
||||
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 () => {
|
||||
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.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<Trace>((resolve) => {
|
||||
setTimeout(() => resolve(updated), 1_000);
|
||||
}),
|
||||
);
|
||||
renderWithProviders(
|
||||
<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />,
|
||||
);
|
||||
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)
|
||||
.mockResolvedValueOnce(research)
|
||||
.mockRejectedValueOnce(new Error("refresh unavailable"))
|
||||
.mockResolvedValue(research);
|
||||
renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
|
||||
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(<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} />);
|
||||
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();
|
||||
|
|
@ -457,7 +648,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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,8 @@ function LoadedRun({
|
|||
}: RunViewProps & { switching: boolean }) {
|
||||
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,
|
||||
|
|
@ -98,15 +100,37 @@ 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,
|
||||
};
|
||||
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 readManually = (read: () => Promise<unknown>) => {
|
||||
if (manualRead) return;
|
||||
setManualRead(true);
|
||||
void read().finally(() => setManualRead(false));
|
||||
};
|
||||
const refreshRun = () =>
|
||||
readManually(async () => {
|
||||
const refreshed = await traceQuery.refetch();
|
||||
if (refreshed.isError) return;
|
||||
const contentRef = refreshed.data?.pages[0].summary.trace_ref ?? traceRef;
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["agentTraceSpan", traceId, contentRef],
|
||||
predicate: (query) => query.queryKey.at(-1) === accessToken,
|
||||
});
|
||||
});
|
||||
const toggleLive = () => {
|
||||
if (live && !manualRead && !traceQuery.isFetchingNextPage) {
|
||||
void queryClient.cancelQueries({ queryKey, exact: true });
|
||||
}
|
||||
setLive((enabled) => !enabled);
|
||||
};
|
||||
const trace = useMemo(() => {
|
||||
const pages = traceQuery.data.pages;
|
||||
return {
|
||||
|
|
@ -116,11 +140,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 (
|
||||
<Tabs
|
||||
|
|
@ -140,15 +164,28 @@ function LoadedRun({
|
|||
handoff={traces.handoff(trace.summary.trace_id, null, trace.summary.trace_ref)}
|
||||
onBack={onBack}
|
||||
embedded={embedded}
|
||||
refreshing={manualRead || traceQuery.isFetching}
|
||||
onRefresh={refreshRun}
|
||||
live={live}
|
||||
canLive={traces.live}
|
||||
onLiveChange={toggleLive}
|
||||
/>
|
||||
{traceQuery.isRefetchError && (
|
||||
<div role="alert" className="flex items-center gap-3 border-b p-3 text-xs text-muted-foreground">
|
||||
Could not refresh this run. Previously received steps are still shown.
|
||||
<Button variant="outline" size="sm" disabled={manualRead || traceQuery.isFetching} onClick={refreshRun}>
|
||||
Retry refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(traceQuery.hasNextPage || failure) && (
|
||||
<PagingBanner
|
||||
loaded={trace.spans.length}
|
||||
total={trace.summary.span_count}
|
||||
failure={failure}
|
||||
busy={traceQuery.isFetching}
|
||||
onLoadMore={() => void traceQuery.fetchNextPage()}
|
||||
onRefresh={() => void refreshTrace()}
|
||||
busy={manualRead || traceQuery.isFetching}
|
||||
onLoadMore={() => readManually(() => traceQuery.fetchNextPage())}
|
||||
onRefresh={() => readManually(refreshTrace)}
|
||||
/>
|
||||
)}
|
||||
<RunBody
|
||||
|
|
@ -159,8 +196,8 @@ function LoadedRun({
|
|||
embedded={embedded}
|
||||
stale={switching}
|
||||
conversationPaging={{
|
||||
loading: isFetching,
|
||||
failed: isError,
|
||||
loading: manualRead || traceQuery.isFetching,
|
||||
failed: isFetchNextPageError,
|
||||
loadMore: fetchNextPage,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue