fix(ui): cap live agent-run transcript buffers to bound tab memory (#9569)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents stream their run output live into the web UI, viewed per-run
in the `AgentDetail` transcript viewer
> - Browser tabs holding these views were sitting at 8–16 GB of memory
footprint while their JS heap stayed at ~256 MB — a 60×+ gap, meaning
the cost is in retained DOM / render objects, not JS objects
> - The `LogViewer` in `AgentDetail.tsx` kept every streamed
stdout/stderr line and structured event in unbounded React state and
rendered each into a rich DOM block (the default "nice" mode has no
virtualization), so a run streaming for hours grew an unbounded live DOM
tree
> - The sibling `useLiveRunTranscripts` hook (used by `IssueChatThread`)
already bounds its buffers and virtualizes; `LogViewer` bypassed it and
managed its own uncapped state — that inconsistency is the bug
> - This pull request caps the live buffers and bounds the live DOM
render, aligning `LogViewer` with the already-bounded path
> - The benefit is that long-lived streaming tabs no longer grow without
bound, cutting multi-GB tabs back to a bounded footprint

## Linked Issues or Issue Description

No public GitHub issue exists; describing inline per CONTRIBUTING.md →
"Link Issues or Describe Them In-PR", following the bug report template.

**What happened?**

Chrome's Task Manager showed multiple long-lived Paperclip tabs
(agent-run / task views) each consuming 8–16 GB of memory footprint,
while each tab's JS heap stayed at only ~150–256 MB. Memory grew
monotonically the longer a run streamed.

**Expected behavior**

A tab viewing a live agent run should hold a bounded amount of memory
regardless of how long the run streams.

**Steps to reproduce**

Open an agent run with a long-running / high-volume stream in
`AgentDetail`, leave the tab open while output streams for an extended
period, and watch the tab's memory footprint climb without bound in
Chrome's Task Manager.

**Paperclip version or commit**

`3991a19a` (branch `fix/agent-run-transcript-memory`, off `master`).

**Deployment mode**

Local dev (`pnpm dev`), web UI. Not adapter-specific — core UI bug in
the shared transcript viewer.

## What Changed

- Add `ui/src/lib/live-log-buffer.ts`: a pure `appendCapped(prev,
additions, max)` helper plus caps `MAX_LIVE_LOG_LINES=5000`,
`MAX_LIVE_EVENTS=2000`, and `LIVE_TRANSCRIPT_RENDER_LIMIT=1500`, with
rationale documented in the module.
- Add `ui/src/lib/live-log-buffer.test.ts`: 6 unit tests (append,
trim-to-cap, oversized batch, exact-cap, no-mutation, referential
bail-out).
- `ui/src/pages/AgentDetail.tsx` (`LogViewer`): route all four
live-append sites (WebSocket log / progress / event, plus the poll
fallback) through `appendCapped`, and pass
`limit={LIVE_TRANSCRIPT_RENDER_LIMIT}` to `RunTranscriptView` for live
runs so the "nice" view mounts only the most recent blocks.
- The terminated-run "Load more log" pagination is deliberately left
**uncapped** (guarded by `isLive`), so no historical output is lost —
older output remains on the server and reachable there.

## Verification

- `vitest run src/lib/live-log-buffer.test.ts` → 6/6 pass.
- Existing suites `RunTranscriptView.test.tsx`,
`AgentDetail.instructions.test.tsx`, `useLiveRunTranscripts.test.tsx` →
22/22 pass.
- `tsc -b` (UI) → clean.
- Manual/behavioral: live runs tail the last ~1500 blocks; terminated
runs still render full history via "Load more log". Follow-up planned to
profile before/after with the Chrome DevTools MCP.

## Risks

Low risk. Changes only bound **in-memory state for live runs**; the
terminated-run paginated path is untouched (still uncapped, guarded by
`isLive`). No API, schema, or persistence changes. Worst case for a live
run is that only the most recent 5000 lines / 1500 rendered blocks are
visible in the tab — which is the intended "tail" behavior, and full
history remains on the server.

## Model Used

- **Provider:** Anthropic, via the Claude Code CLI.
- **Model:** Claude Opus 4.8 (`claude-opus-4-8`).
- **Reasoning mode:** Extended thinking enabled.
- **Capabilities used:** tool use (shell execution, file editing),
sub-agent fan-out for the codebase memory sweep, and the Chrome DevTools
MCP for the diagnosis phase.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work (the ROADMAP "Memory" item is about company/agent
knowledge, unrelated to this browser-tab memory fix)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (none found among open PRs)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have considered and documented any risks above
- [ ] I have updated relevant documentation to reflect my changes (N/A —
no user-facing docs affected; rationale is documented inline in
`live-log-buffer.ts`)
- [ ] All Paperclip CI gates are green (in progress at time of writing)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(the only open P2 was this missing template, which this update resolves;
awaiting re-review)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-07-14 12:43:05 -05:00 committed by GitHub
parent 2617bee422
commit f49a3f9924
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 85 additions and 5 deletions

View File

@ -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]);
});
});

View File

@ -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<T>(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;
}

View File

@ -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<string, unknown>).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 && (