agent-ecosystem/test/main/services/analysis/sessionDetailPayload.test.ts
Mike 297bd8f533 fix(team): cap renderer IPC payloads to prevent OOM crashes
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).
2026-04-18 17:44:39 +05:00

68 lines
2.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { stripSessionDetailMessages } from '../../../../src/main/services/analysis/sessionDetailPayload';
import type { ParsedMessage, SessionDetail } from '../../../../src/main/types';
function createDetail(overrides: Partial<SessionDetail> = {}): SessionDetail {
return {
session: {
id: 'session-1',
projectId: 'project-1',
projectPath: '/tmp/project',
isOngoing: false,
hasSubagents: false,
messageCount: 0,
createdAt: 0,
},
messages: [],
chunks: [],
processes: [],
metrics: {
durationMs: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
messageCount: 0,
},
...overrides,
};
}
describe('stripSessionDetailMessages', () => {
it('returns the same reference when messages is already empty', () => {
const detail = createDetail();
const result = stripSessionDetailMessages(detail);
expect(result).toBe(detail);
});
it('drops the messages array when it is non-empty', () => {
const messages = [{ uuid: 'm-1' } as unknown as ParsedMessage];
const detail = createDetail({ messages });
const result = stripSessionDetailMessages(detail);
expect(result).not.toBe(detail);
expect(result.messages).toEqual([]);
});
it('preserves every other field (session, chunks, processes, metrics)', () => {
const messages = Array.from(
{ length: 3 },
(_, i) => ({ uuid: `m-${i}` }) as unknown as ParsedMessage
);
const detail = createDetail({ messages });
const result = stripSessionDetailMessages(detail);
expect(result.session).toBe(detail.session);
expect(result.chunks).toBe(detail.chunks);
expect(result.processes).toBe(detail.processes);
expect(result.metrics).toBe(detail.metrics);
});
it('does not mutate the input detail', () => {
const messages = [{ uuid: 'm-1' } as unknown as ParsedMessage];
const detail = createDetail({ messages });
stripSessionDetailMessages(detail);
expect(detail.messages).toBe(messages);
expect(detail.messages).toHaveLength(1);
});
});