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).
68 lines
2.1 KiB
TypeScript
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);
|
|
});
|
|
});
|