fix(lens): retain paged content and recorded tool errors

This commit is contained in:
moe-berri 2026-10-06 14:29:22 -07:00
parent a3ad12b259
commit dae9bf0435
4 changed files with 73 additions and 5 deletions

View file

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

View file

@ -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(
<TraceConversation trace={{ ...trace, spans: [failedTool] }} accessToken="test" onOpenStep={vi.fn()} />,
);
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(
<TraceConversation trace={trace} accessToken="test" onOpenStep={vi.fn()} />,
);
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(<TraceConversation trace={extended} accessToken="test" onOpenStep={vi.fn()} />);
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,

View file

@ -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;

View file

@ -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<SpanDetail>(["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) => {