Users with long-running teams (37+ tasks, 10+ agents for an hour) were hitting constant renderer crashes (issue #36). Two hot paths were serializing unbounded histories across IPC on every tick: - Provisioning progress: emitLogsProgress and updateProgress both joined the full provisioningOutputParts array (~20 event-driven call sites) plus the full CLI log tail, then fanned that out to the renderer. After an hour, each tick shipped multi-megabyte payloads and Zustand OOM'd on the immutable state clone. - Session detail cache: SessionDetail.messages (the raw parsed JSONL) was being cached and returned over IPC/HTTP even though the renderer only reads session/chunks/processes/metrics. This roughly doubled the per-entry cache footprint on large sessions. Fixes: - Add progressPayload helpers that cap the log tail to 200 lines and assistant output to the last 20 parts; empty/whitespace joins collapse to undefined so the noop guard is explicit rather than coincidental. - Apply the cap inside emitLogsProgress, updateProgress, and the two inline emission paths (stall warning, retry error). Throttle the log-progress tick 300ms -> 1000ms so Zustand can keep up. - Add stripSessionDetailMessages and call it at every SessionDetail production site that crosses IPC/HTTP (both sessions.ts routes, both cache stores). - Raise MAX_CACHE_SESSIONS 5 -> 20 now that the per-entry SessionDetail footprint is bounded. Previously 5 forced constant re-parsing on every session switch. Tests: 15 new unit tests covering the helpers (tail slicing, empty parts, whitespace-only parts, non-mutation of inputs).
77 lines
3 KiB
TypeScript
77 lines
3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
PROGRESS_LOG_TAIL_LINES,
|
|
PROGRESS_OUTPUT_TAIL_PARTS,
|
|
buildProgressAssistantOutput,
|
|
buildProgressLogsTail,
|
|
} from '../../../../src/main/services/team/progressPayload';
|
|
|
|
describe('buildProgressLogsTail', () => {
|
|
it('returns undefined for an empty buffer', () => {
|
|
expect(buildProgressLogsTail([])).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when all lines are whitespace', () => {
|
|
expect(buildProgressLogsTail(['', ' ', '\t'])).toBeUndefined();
|
|
});
|
|
|
|
it('returns the full buffer joined when below the limit', () => {
|
|
const lines = ['alpha', 'beta', 'gamma'];
|
|
expect(buildProgressLogsTail(lines, 10)).toBe('alpha\nbeta\ngamma');
|
|
});
|
|
|
|
it('caps the payload to the last N lines once the limit is exceeded', () => {
|
|
const lines = Array.from({ length: 1_000 }, (_, i) => `line-${i}`);
|
|
const result = buildProgressLogsTail(lines, 50);
|
|
expect(result).toBeDefined();
|
|
const parts = result!.split('\n');
|
|
expect(parts).toHaveLength(50);
|
|
expect(parts[0]).toBe('line-950');
|
|
expect(parts[parts.length - 1]).toBe('line-999');
|
|
});
|
|
|
|
it('uses the default tail size when the caller does not override it', () => {
|
|
const lines = Array.from({ length: PROGRESS_LOG_TAIL_LINES + 250 }, (_, i) => `l${i}`);
|
|
const result = buildProgressLogsTail(lines);
|
|
expect(result).toBeDefined();
|
|
expect(result!.split('\n')).toHaveLength(PROGRESS_LOG_TAIL_LINES);
|
|
});
|
|
|
|
it('keeps payload size bounded for pathological inputs (50k lines)', () => {
|
|
const lines = Array.from({ length: 50_000 }, (_, i) => `line-${i}`);
|
|
const result = buildProgressLogsTail(lines);
|
|
expect(result).toBeDefined();
|
|
// Regression guard: a full-buffer join of 50k synthetic lines would exceed
|
|
// 400k chars. The tail must stay well below that.
|
|
expect(result!.length).toBeLessThan(50_000);
|
|
});
|
|
|
|
it('coerces non-positive limits to at least one line', () => {
|
|
expect(buildProgressLogsTail(['a', 'b', 'c'], 0)).toBe('c');
|
|
expect(buildProgressLogsTail(['a', 'b', 'c'], -5)).toBe('c');
|
|
});
|
|
});
|
|
|
|
describe('buildProgressAssistantOutput', () => {
|
|
it('returns undefined when there are no parts', () => {
|
|
expect(buildProgressAssistantOutput([])).toBeUndefined();
|
|
});
|
|
|
|
it('joins parts with a blank-line separator when below the limit', () => {
|
|
expect(buildProgressAssistantOutput(['first', 'second'], 10)).toBe('first\n\nsecond');
|
|
});
|
|
|
|
it('caps to the last N parts once the limit is exceeded', () => {
|
|
const parts = Array.from({ length: 200 }, (_, i) => `p${i}`);
|
|
const result = buildProgressAssistantOutput(parts, 5);
|
|
expect(result).toBe('p195\n\np196\n\np197\n\np198\n\np199');
|
|
});
|
|
|
|
it('uses the default tail size when the caller does not override it', () => {
|
|
const parts = Array.from({ length: PROGRESS_OUTPUT_TAIL_PARTS + 10 }, (_, i) => `p${i}`);
|
|
const result = buildProgressAssistantOutput(parts);
|
|
expect(result).toBeDefined();
|
|
expect(result!.split('\n\n')).toHaveLength(PROGRESS_OUTPUT_TAIL_PARTS);
|
|
});
|
|
});
|