From 8859f37fa2d4572ace65afc6409826b80dbe7b87 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 30 Sep 2026 07:40:28 -0500 Subject: [PATCH] fix: preserve accurate history boundaries in run views Co-Authored-By: Paperclip --- .../__tests__/agent-live-run-routes.test.ts | 2 +- .../heartbeat-run-event-paging.test.ts | 2 ++ .../services/heartbeat-run-event-paging.ts | 2 +- tests/e2e/bounded-native-history.spec.ts | 1 + .../pages/AgentDetail.log-visibility.test.tsx | 36 ++++++++++++++++++- ui/src/pages/AgentDetail.production.tsx | 27 +++++++------- ui/src/pages/AgentDetail.tsx | 27 +++++++------- 7 files changed, 68 insertions(+), 29 deletions(-) diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index b7efcfbed0e..1f943bf0753 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -484,7 +484,7 @@ describe("agent live run routes", () => { beforeSeq: undefined, limit: 200, }); - expect(forward.body).toEqual([{ seq: 11, eventType: "stdout", payload: null, historyAfter: false }]); + expect(forward.body).toEqual([{ seq: 11, eventType: "stdout", payload: null, historyBefore: false, historyAfter: false }]); for (const query of ["afterSeq=-1", "afterSeq=1.5", "afterSeq=tail&beforeSeq=-1", "limit=1001"]) { const invalid = await requestApp(app, (url) => diff --git a/server/src/services/heartbeat-run-event-paging.test.ts b/server/src/services/heartbeat-run-event-paging.test.ts index b47b2ec12de..efb5d32b06c 100644 --- a/server/src/services/heartbeat-run-event-paging.test.ts +++ b/server/src/services/heartbeat-run-event-paging.test.ts @@ -48,5 +48,7 @@ describe("bounded heartbeat run event pages", () => { }); expect(page).toHaveLength(1); expect(page[0].seq).toBe(1); + expect(page[0].historyBefore).toBe(false); + expect(page[0].historyAfter).toBe(false); }); }); diff --git a/server/src/services/heartbeat-run-event-paging.ts b/server/src/services/heartbeat-run-event-paging.ts index 51c79801568..f607bf36575 100644 --- a/server/src/services/heartbeat-run-event-paging.ts +++ b/server/src/services/heartbeat-run-event-paging.ts @@ -33,7 +33,7 @@ export function boundHeartbeatRunEventPage(input: { const after = baseAfter || (byteCut && input.direction === "forward"); return events.map((event, index) => ({ ...event, - ...(before && index === 0 ? { historyBefore: true as const } : {}), + ...(index === 0 ? { historyBefore: before } : {}), ...(index === events.length - 1 ? { historyAfter: after } : {}), })); }; diff --git a/tests/e2e/bounded-native-history.spec.ts b/tests/e2e/bounded-native-history.spec.ts index c17a18640dc..5bfefcdf2ef 100644 --- a/tests/e2e/bounded-native-history.spec.ts +++ b/tests/e2e/bounded-native-history.spec.ts @@ -64,6 +64,7 @@ test("long native history opens at the latest activity and older events remain b await page.getByRole("button", { name: /Earlier activity/ }).last().click(); await page.getByRole("link", { name: "View run", exact: true }).last().click(); await expect(page).toHaveURL(new RegExp(`/runs/${run.id}$`)); + await expect(page.getByTestId("run-event-history-notice")).toBeVisible(); await page.getByRole("button", { name: /Inspect/ }).first().click(); await page.getByRole("tab", { name: "Pipeline", exact: true }).click(); await expect(page.getByText("Recent activity ยท 1,000 events")).toBeVisible(); diff --git a/ui/src/pages/AgentDetail.log-visibility.test.tsx b/ui/src/pages/AgentDetail.log-visibility.test.tsx index 7775ca7e907..71a9c4bf26b 100644 --- a/ui/src/pages/AgentDetail.log-visibility.test.tsx +++ b/ui/src/pages/AgentDetail.log-visibility.test.tsx @@ -39,7 +39,8 @@ it.each([LogViewer, ProductionLogViewer])("starts from the recent event tail and message: `event-${seq}`, payload: null, createdAt: new Date("2026-09-10T12:00:00Z"), - ...(seq === 1_004 ? { historyAfter: true } : {}), + ...(seq === 0 ? { historyBefore: true } : {}), + ...(seq === 1_004 ? { historyAfter: false } : {}), })); events.mockReturnValue(eventRows as never[]); const run = { id: "run-1", companyId: "company-1", agentId: "agent-1", status: "succeeded", logRef: null } as HeartbeatRun; @@ -64,6 +65,39 @@ it.each([LogViewer, ProductionLogViewer])("starts from the recent event tail and } }); +it.each([LogViewer, ProductionLogViewer])("shows the omission notice from the tail page's historyBefore marker (%#)", async (Viewer) => { + const eventRow = { + id: 10, + companyId: "company-1", + runId: "run-1", + agentId: "agent-1", + seq: 10, + eventType: "event", + stream: "system" as const, + level: "info" as const, + color: null, + message: "recent event", + payload: null, + createdAt: new Date("2026-09-10T12:00:00Z"), + historyBefore: true as const, + historyAfter: false, + }; + events.mockReturnValue([eventRow] as never[]); + const run = { id: "run-1", companyId: "company-1", agentId: "agent-1", status: "succeeded", logRef: null } as HeartbeatRun; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => root.render()); + expect(events).toHaveBeenCalledWith("run-1", "tail", 200); + expect(container.textContent).toContain("Events (1)"); + expect(container.querySelector('[data-testid="run-event-history-notice"]')?.textContent).toContain("browse older activity"); + } finally { + await act(async () => root.unmount()); + container.remove(); + } +}); + it.each([LogViewer, ProductionLogViewer])("retains legacy history and reads only the next offset on visibility recovery (%#)", async (Viewer) => { const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); const row = (seq: number, chunk: string) => JSON.stringify({ seq, ts: `2026-09-10T12:00:0${seq}Z`, stream: "stdout", chunk }) + "\n"; diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx index b765ada9c83..403f929dfa3 100644 --- a/ui/src/pages/AgentDetail.production.tsx +++ b/ui/src/pages/AgentDetail.production.tsx @@ -3738,8 +3738,8 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } export function LogViewer({ run, adapterType, onOpenInspector }: { run: HeartbeatRun; adapterType: string; onOpenInspector?: () => void }) { const { visible } = usePageVisibility(); - const [events, setEvents] = useState([]); - const [eventHistoryOmitted, setEventHistoryOmitted] = useState(false); + const [eventState, setEventState] = useState({ events: [] as HeartbeatRunEvent[], historyOmitted: false }); + const { events, historyOmitted: eventHistoryOmitted } = eventState; const [logLines, setLogLines] = useState([]); const [loading, setLoading] = useState(true); const [logLoading, setLogLoading] = useState(!!run.logRef); @@ -3824,23 +3824,20 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea }); useEffect(() => { - setEvents([]); - setEventHistoryOmitted(false); + setEventState({ events: [], historyOmitted: false }); }, [run.id]); useEffect(() => { if (initialEvents) { const retained = retainEventTail(initialEvents); - setEvents(retained.events); - setEventHistoryOmitted(retained.collapsed || initialEvents.at(-1)?.historyAfter === true); + setEventState({ + events: retained.events, + historyOmitted: retained.collapsed || initialEvents[0]?.historyBefore === true, + }); setLoading(false); } }, [initialEvents]); - useEffect(() => { - if (retainEventTail(events).collapsed) setEventHistoryOmitted(true); - }, [events]); - const getScrollContainer = useCallback((): ScrollContainer => { if (scrollContainerRef.current) return scrollContainerRef.current; const container = findScrollContainer(logEndRef.current); @@ -4004,7 +4001,10 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100); if (cancelled) return; if (newEvents.length > 0) { - setEvents((prev) => retainEventTail(mergeRunEvents(prev, newEvents)).events); + setEventState((prev) => { + const retained = retainEventTail(mergeRunEvents(prev.events, newEvents)); + return { events: retained.events, historyOmitted: prev.historyOmitted || retained.collapsed }; + }); } } catch { // ignore polling errors @@ -4145,8 +4145,9 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea createdAt: new Date(event.createdAt), }; - setEvents((prev) => { - return retainEventTail(mergeRunEvents(prev, [liveEvent])).events; + setEventState((prev) => { + const retained = retainEventTail(mergeRunEvents(prev.events, [liveEvent])); + return { events: retained.events, historyOmitted: prev.historyOmitted || retained.collapsed }; }); }; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 573b3163b25..143f1a3204c 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -3983,8 +3983,8 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } export function LogViewer({ run, adapterType, onOpenInspector }: { run: HeartbeatRun; adapterType: string; onOpenInspector?: () => void }) { const { visible } = usePageVisibility(); - const [events, setEvents] = useState([]); - const [eventHistoryOmitted, setEventHistoryOmitted] = useState(false); + const [eventState, setEventState] = useState({ events: [] as HeartbeatRunEvent[], historyOmitted: false }); + const { events, historyOmitted: eventHistoryOmitted } = eventState; const [logLines, setLogLines] = useState([]); const [loading, setLoading] = useState(true); const [logLoading, setLogLoading] = useState(!!run.logRef); @@ -4069,23 +4069,20 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea }); useEffect(() => { - setEvents([]); - setEventHistoryOmitted(false); + setEventState({ events: [], historyOmitted: false }); }, [run.id]); useEffect(() => { if (initialEvents) { const retained = retainEventTail(initialEvents); - setEvents(retained.events); - setEventHistoryOmitted(retained.collapsed || initialEvents.at(-1)?.historyAfter === true); + setEventState({ + events: retained.events, + historyOmitted: retained.collapsed || initialEvents[0]?.historyBefore === true, + }); setLoading(false); } }, [initialEvents]); - useEffect(() => { - if (retainEventTail(events).collapsed) setEventHistoryOmitted(true); - }, [events]); - const getScrollContainer = useCallback((): ScrollContainer => { if (scrollContainerRef.current) return scrollContainerRef.current; const container = findScrollContainer(logEndRef.current); @@ -4249,7 +4246,10 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100); if (cancelled) return; if (newEvents.length > 0) { - setEvents((prev) => retainEventTail(mergeRunEvents(prev, newEvents)).events); + setEventState((prev) => { + const retained = retainEventTail(mergeRunEvents(prev.events, newEvents)); + return { events: retained.events, historyOmitted: prev.historyOmitted || retained.collapsed }; + }); } } catch { // ignore polling errors @@ -4390,8 +4390,9 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea createdAt: new Date(event.createdAt), }; - setEvents((prev) => { - return retainEventTail(mergeRunEvents(prev, [liveEvent])).events; + setEventState((prev) => { + const retained = retainEventTail(mergeRunEvents(prev.events, [liveEvent])); + return { events: retained.events, historyOmitted: prev.historyOmitted || retained.collapsed }; }); };