fix(lens): retain successful split content reads

This commit is contained in:
moe-berri 2026-10-06 13:45:32 -07:00
parent 477828ac49
commit d5202e59ea
4 changed files with 99 additions and 21 deletions

View file

@ -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(
<TraceConversation
trace={{ ...trace, spans: [root, tool, oversized, later] } as Trace}
accessToken="test"
onOpenStep={vi.fn()}
/>,
);
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(<TraceConversation trace={trace} accessToken="test" onOpenStep={vi.fn()} />);
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"));

View file

@ -138,11 +138,11 @@ function ConversationFeedback({
return (
<>
{entries.map(
({ query, span }) =>
query.isError && (
({ failed, retry, span }) =>
failed && (
<div key={span.span_id} role="alert" className="rounded-md border p-3 text-sm">
Could not load conversation content starting at {span.name}. Retry this batch to continue.
<Button variant="outline" size="sm" className="mt-2" onClick={() => query.refetch()}>
<Button variant="outline" size="sm" className="mt-2" onClick={() => retry()}>
Retry batch
</Button>
</div>
@ -345,6 +345,7 @@ function ConversationTool({ item, onOpenStep }: { item: ConversationItem; onOpen
{open && (
<div className="min-w-0 space-y-3 border-t px-3 py-3">
{item.toolCall && <ToolArguments args={item.toolCall.args} />}
{item.inputWarning && <p className="text-sm text-muted-foreground">{item.inputWarning}</p>}
{item.span.error && item.span.error !== item.toolResult && (
<p className="text-sm text-destructive">{item.span.error}</p>
)}

View file

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

View file

@ -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<SpanDetail[]> {
interface BatchResult {
details: SpanDetail[];
failedIds: string[];
}
async function readBatch(traces: TracesApi, traceId: string, ids: string[], traceRef?: string): Promise<BatchResult> {
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<SpanDetail[]> => {
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<BatchResult> => {
const previous = queryClient.getQueryData<BatchResult>(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,
};