fix: close PI core chat review gaps
This commit is contained in:
235
tests/unit/coding-chat-pressure.test.tsx
Normal file
235
tests/unit/coding-chat-pressure.test.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { mkdtemp, rm } 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 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 p95Ms = percentile95(latencies);
|
||||
const metrics = {
|
||||
runtimePatchItems,
|
||||
patchBatches,
|
||||
sseFrames,
|
||||
rendererTransactions,
|
||||
reactCommits: measuredReactCommits,
|
||||
wireBytes,
|
||||
mainToReactP95Ms: p95Ms,
|
||||
};
|
||||
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();
|
||||
} finally {
|
||||
controller.abort();
|
||||
await reader.cancel().catch(() => undefined);
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user