diff --git a/ui/src/lib/live-log-buffer.test.ts b/ui/src/lib/live-log-buffer.test.ts new file mode 100644 index 0000000000..bf18c5440f --- /dev/null +++ b/ui/src/lib/live-log-buffer.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { appendCapped } from "./live-log-buffer"; + +describe("appendCapped", () => { + it("returns the same array reference when there is nothing to add", () => { + const prev = [1, 2, 3]; + expect(appendCapped(prev, [], 10)).toBe(prev); + }); + + it("appends without trimming while under the cap", () => { + expect(appendCapped([1, 2], [3, 4], 10)).toEqual([1, 2, 3, 4]); + }); + + it("keeps exactly the newest `max` entries when the result overflows", () => { + expect(appendCapped([1, 2, 3], [4, 5], 4)).toEqual([2, 3, 4, 5]); + }); + + it("trims correctly when a single append batch is larger than the cap", () => { + expect(appendCapped([1], [2, 3, 4, 5, 6], 3)).toEqual([4, 5, 6]); + }); + + it("returns exactly `max` entries when the result lands on the cap", () => { + const result = appendCapped([1, 2], [3], 3); + expect(result).toEqual([1, 2, 3]); + expect(result.length).toBe(3); + }); + + it("does not mutate its inputs", () => { + const prev = [1, 2, 3]; + const additions = [4, 5]; + appendCapped(prev, additions, 3); + expect(prev).toEqual([1, 2, 3]); + expect(additions).toEqual([4, 5]); + }); +}); diff --git a/ui/src/lib/live-log-buffer.ts b/ui/src/lib/live-log-buffer.ts new file mode 100644 index 0000000000..f1d83539d5 --- /dev/null +++ b/ui/src/lib/live-log-buffer.ts @@ -0,0 +1,34 @@ +/** + * Live agent-run transcript viewers stream stdout/stderr and structured events + * for the entire lifetime of a run — which can be hours. The viewer keeps every + * streamed line/event in React state and renders each into a rich DOM block, so + * an unbounded buffer becomes an unbounded live DOM tree: the tab's memory + * footprint climbs into the multi-GB range even while the JS heap stays small + * (the cost is in retained render objects and GPU layers, not JS objects). + * + * These caps bound the *live* tail retained in memory. Older output is not lost: + * it stays in the persisted run log on the server and remains reachable for + * terminated runs through the "Load more log" pagination, which is not subject + * to these caps. + */ +export const MAX_LIVE_LOG_LINES = 5_000; +export const MAX_LIVE_EVENTS = 2_000; + +/** + * Number of transcript blocks the live "nice" view mounts. Terminated runs + * render in full so explicitly paginated history stays visible; live runs tail + * the stream, so mounting only the most recent blocks keeps the DOM (and its + * off-heap render memory) bounded no matter how long the run streams. + */ +export const LIVE_TRANSCRIPT_RENDER_LIMIT = 1_500; + +/** + * Append `additions` to `prev`, dropping the oldest entries so the result never + * exceeds `max`. Returns `prev` unchanged when there is nothing to add, so React + * state setters can bail out of a re-render, and never mutates its inputs. + */ +export function appendCapped(prev: T[], additions: readonly T[], max: number): T[] { + if (additions.length === 0) return prev; + const next = [...prev, ...additions]; + return next.length > max ? next.slice(next.length - max) : next; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index ab60cae756..b0564d73ca 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -87,6 +87,12 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { Input } from "@/components/ui/input"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView"; +import { + appendCapped, + LIVE_TRANSCRIPT_RENDER_LIMIT, + MAX_LIVE_EVENTS, + MAX_LIVE_LOG_LINES, +} from "../lib/live-log-buffer"; import { isUuidLike, type Agent, @@ -3492,7 +3498,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin } if (parsed.length > 0) { - setLogLines((prev) => [...prev, ...parsed]); + // Live runs stream forever, so cap the retained tail. Terminated runs are + // paginated by the user via "Load more log" and keep their full history. + setLogLines((prev) => + isLive ? appendCapped(prev, parsed, MAX_LIVE_LOG_LINES) : [...prev, ...parsed], + ); } } @@ -3661,7 +3671,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin try { const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100); if (newEvents.length > 0) { - setEvents((prev) => [...prev, ...newEvents]); + setEvents((prev) => appendCapped(prev, newEvents, MAX_LIVE_EVENTS)); } } catch { // ignore polling errors @@ -3738,7 +3748,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const streamRaw = asNonEmptyString(payload.stream); const stream = streamRaw === "stderr" || streamRaw === "system" ? streamRaw : "stdout"; const ts = asNonEmptyString((payload as Record).ts) ?? event.createdAt; - setLogLines((prev) => [...prev, { ts, stream, chunk }]); + setLogLines((prev) => appendCapped(prev, [{ ts, stream, chunk }], MAX_LIVE_LOG_LINES)); return; } @@ -3748,7 +3758,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const key = heartbeatProgressLogLineKey(line); if (seenProgressLogLineKeysRef.current.has(key)) return; seenProgressLogLineKeysRef.current.add(key); - setLogLines((prev) => [...prev, line]); + setLogLines((prev) => appendCapped(prev, [line], MAX_LIVE_LOG_LINES)); return; } @@ -3785,7 +3795,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin setEvents((prev) => { if (prev.some((existing) => existing.seq === seq)) return prev; - return [...prev, liveEvent]; + return appendCapped(prev, [liveEvent], MAX_LIVE_EVENTS); }); }; @@ -3928,6 +3938,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin entries={transcript} mode={transcriptMode} streaming={isLive} + limit={isLive ? LIVE_TRANSCRIPT_RENDER_LIMIT : undefined} emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."} /> {hasMoreLog && (