Files
PaperClipAI/ui/src/lib/live-log-buffer.test.ts
T
DottaandClaude Opus 4.8 f49a3f9924 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>
2026-07-14 12:43:05 -05:00

36 lines
1.2 KiB
TypeScript

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