249 lines
9.6 KiB
TypeScript
249 lines
9.6 KiB
TypeScript
import { act, render } from '@testing-library/react';
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { createServer } from 'node:http';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { Profiler } from 'react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type { HostApiContext } from '../../electron/api/context';
|
|
import { handleCodingConversationRoutes } from '../../electron/api/routes/coding-conversations';
|
|
import { CodingProjectService } from '../../electron/coding-projects/project-service';
|
|
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
|
|
import {
|
|
createCodingProjectStore,
|
|
createLocalCodingProject,
|
|
createMemoryCodingProjectStorage,
|
|
} from '../../electron/coding-projects/project-store';
|
|
import { CodingConversationService } from '../../electron/coding-runtime/conversation-service';
|
|
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
|
|
import type {
|
|
CodingConversationPatchBatchEvent,
|
|
CodingConversationSnapshotEvent,
|
|
ConversationPatch,
|
|
ConversationPatchEnvelope,
|
|
} from '../../electron/coding-runtime/contracts';
|
|
import { CodingConversationTimeline } from '../../src/pages/Chat/CodingConversationTimeline';
|
|
import { codingConversationStore } from '../../src/stores/coding-conversations';
|
|
|
|
const roots: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
function percentile95(values: number[]): number {
|
|
const ordered = [...values].sort((left, right) => left - right);
|
|
return ordered[Math.ceil(ordered.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
function summarize(values: number[]): { samples: number; p50: number; p95: number; max: number } {
|
|
const ordered = [...values].sort((left, right) => left - right);
|
|
return {
|
|
samples: ordered.length,
|
|
p50: ordered[Math.ceil(ordered.length * 0.5) - 1] ?? Number.POSITIVE_INFINITY,
|
|
p95: percentile95(ordered),
|
|
max: ordered.at(-1) ?? Number.POSITIVE_INFINITY,
|
|
};
|
|
}
|
|
|
|
function pressurePatch(seq: number): ConversationPatch {
|
|
if (seq === 1) {
|
|
return {
|
|
op: 'message.upsert',
|
|
node: {
|
|
kind: 'message',
|
|
id: 'assistant-pressure',
|
|
role: 'assistant',
|
|
status: 'streaming',
|
|
blocks: [
|
|
{ kind: 'text', id: 'answer-pressure', text: 'Mixed answer', status: 'complete' },
|
|
{ kind: 'thinking', id: 'thinking-pressure', text: '', status: 'streaming' },
|
|
],
|
|
},
|
|
};
|
|
}
|
|
if (seq === 2) {
|
|
return {
|
|
op: 'tool.upsert',
|
|
node: {
|
|
kind: 'tool',
|
|
id: 'tool-pressure',
|
|
toolCallId: 'call-pressure',
|
|
toolName: 'read',
|
|
title: 'Pressure fixture tool output',
|
|
inputText: 'fixture.txt',
|
|
status: 'complete',
|
|
output: [{
|
|
kind: 'text',
|
|
id: 'tool-pressure-output',
|
|
text: 'T'.repeat(4 * 1024),
|
|
status: 'complete',
|
|
}],
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
op: 'message.block-delta',
|
|
messageId: 'assistant-pressure',
|
|
blockId: 'thinking-pressure',
|
|
delta: `${seq}:`.padEnd(1_100, 'x'),
|
|
};
|
|
}
|
|
|
|
describe('REN-008 coding timeline pressure', () => {
|
|
it('batches 100 KB of mixed output and keeps Main-to-React p95 within 50 ms', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-pressure-'));
|
|
roots.push(root);
|
|
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
|
createId: () => 'project-pressure',
|
|
now: () => '2026-08-24T00:00:00.000Z',
|
|
});
|
|
await createLocalCodingProject({
|
|
projectPath: root,
|
|
now: '2026-08-24T00:00:00.000Z',
|
|
}, projectStore);
|
|
await createCodingProjectAgent(root, {
|
|
id: 'builder',
|
|
avatarId: 'avatar-01',
|
|
roleName: '实现者',
|
|
name: 'Builder',
|
|
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
|
modelResolution: 'resolved',
|
|
responsibility: {
|
|
mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [],
|
|
},
|
|
}, { now: '2026-08-24T00:00:00.000Z' });
|
|
const runtime = new InMemoryConversationRuntime();
|
|
const projects = new CodingProjectService(projectStore);
|
|
const conversations = new CodingConversationService(projects, runtime, {
|
|
deliveryBatchWindowMs: 24,
|
|
});
|
|
const conversation = await conversations.createConversation({
|
|
agentId: 'builder',
|
|
title: 'Pressure',
|
|
});
|
|
let publish: ((event: ConversationPatchEnvelope) => void) | undefined;
|
|
vi.spyOn(runtime, 'subscribe').mockImplementation((listener) => {
|
|
publish = listener;
|
|
return () => undefined;
|
|
});
|
|
const context = {
|
|
codingProducts: { projects, conversations, runtime },
|
|
} as unknown as HostApiContext;
|
|
const server = createServer((request, response) => {
|
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
void handleCodingConversationRoutes(request, response, url, context);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') throw new Error('Pressure server did not bind');
|
|
const controller = new AbortController();
|
|
const response = await fetch(
|
|
`http://127.0.0.1:${address.port}/api/coding/events?conversationId=${conversation.id}`,
|
|
{ signal: controller.signal },
|
|
);
|
|
const reader = response.body?.getReader();
|
|
if (!reader) throw new Error('Pressure SSE response has no body');
|
|
const decoder = new TextDecoder();
|
|
const encoder = new TextEncoder();
|
|
let buffered = '';
|
|
let wireBytes = 0;
|
|
let sseFrames = 0;
|
|
const nextFrame = async (): Promise<{ event: string; data: unknown }> => {
|
|
while (!buffered.includes('\n\n')) {
|
|
const next = await reader.read();
|
|
if (next.done) throw new Error('Pressure SSE ended early');
|
|
buffered += decoder.decode(next.value, { stream: true });
|
|
}
|
|
const boundary = buffered.indexOf('\n\n');
|
|
const frame = buffered.slice(0, boundary);
|
|
buffered = buffered.slice(boundary + 2);
|
|
wireBytes += encoder.encode(`${frame}\n\n`).byteLength;
|
|
sseFrames += 1;
|
|
const event = frame.split('\n').find((line) => line.startsWith('event: '))?.slice(7) ?? '';
|
|
const data = frame.split('\n').find((line) => line.startsWith('data: '))?.slice(6) ?? 'null';
|
|
return { event, data: JSON.parse(data) as unknown };
|
|
};
|
|
|
|
try {
|
|
const initial = await nextFrame();
|
|
expect(initial.event).toBe('snapshot');
|
|
codingConversationStore.getState().applySnapshotEvent(
|
|
initial.data as CodingConversationSnapshotEvent,
|
|
);
|
|
let rendererTransactions = 0;
|
|
const unsubscribe = codingConversationStore.subscribe(() => {
|
|
rendererTransactions += 1;
|
|
});
|
|
let reactCommits = 0;
|
|
const view = render(
|
|
<Profiler id="pressure-timeline" onRender={() => { reactCommits += 1; }}>
|
|
<CodingConversationTimeline conversationId={conversation.id} />
|
|
</Profiler>,
|
|
);
|
|
const initialCommits = reactCommits;
|
|
if (!publish) throw new Error('Pressure runtime subscriber was not installed');
|
|
const latencies: number[] = [];
|
|
let runtimePatchItems = 0;
|
|
let patchBatches = 0;
|
|
for (let burst = 0; burst < 20; burst += 1) {
|
|
const startedAt = performance.now();
|
|
for (let offset = 0; offset < 5; offset += 1) {
|
|
const seq = burst * 5 + offset + 1;
|
|
runtimePatchItems += 1;
|
|
publish({
|
|
conversationId: conversation.id,
|
|
workerGeneration: 0,
|
|
seq,
|
|
at: Date.now(),
|
|
patch: pressurePatch(seq),
|
|
});
|
|
}
|
|
const frame = await nextFrame();
|
|
expect(frame.event).toBe('patch-batch');
|
|
const batch = frame.data as CodingConversationPatchBatchEvent;
|
|
patchBatches += 1;
|
|
expect(batch.items).toHaveLength(5);
|
|
await act(async () => {
|
|
codingConversationStore.getState().applyPatchBatchEvent(batch);
|
|
});
|
|
latencies.push(performance.now() - startedAt);
|
|
}
|
|
const measuredReactCommits = reactCommits - initialCommits;
|
|
const mainToReactMs = summarize(latencies);
|
|
const metrics = {
|
|
runtimePatchItems,
|
|
patchBatches,
|
|
sseFrames,
|
|
rendererTransactions,
|
|
reactCommits: measuredReactCommits,
|
|
wireBytes,
|
|
mainToReactMs,
|
|
mainToReactP95Ms: mainToReactMs.p95,
|
|
};
|
|
console.info('REN-008 metrics', metrics);
|
|
expect(metrics.runtimePatchItems).toBe(100);
|
|
expect(metrics.wireBytes).toBeGreaterThan(100 * 1024);
|
|
expect(metrics.patchBatches).toBeLessThan(metrics.runtimePatchItems);
|
|
expect(metrics.rendererTransactions).toBe(metrics.patchBatches);
|
|
expect(metrics.reactCommits).toBeLessThan(metrics.runtimePatchItems);
|
|
expect(metrics.sseFrames).toBe(metrics.patchBatches + 1);
|
|
expect(metrics.mainToReactP95Ms).toBeLessThanOrEqual(50);
|
|
expect(codingConversationStore.getState()
|
|
.entriesByConversationId[conversation.id]?.reducer.snapshot?.cursor.seq).toBe(100);
|
|
view.unmount();
|
|
unsubscribe();
|
|
const reportPath = process.env.MAKELORE_PI_PERF_PRESSURE_FRAGMENT;
|
|
if (reportPath) await writeFile(reportPath, `${JSON.stringify(metrics, null, 2)}\n`);
|
|
} finally {
|
|
controller.abort();
|
|
await reader.cancel().catch(() => undefined);
|
|
server.closeAllConnections();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|