test: add Workbench session rail L0 benchmark
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// SPEC: PJ2026-01060505 Workbench performance; PJ2026-010401080313 Workbench realtime authority.
|
||||
// Responsibility: Deterministic L0 timing for session tag switching, Kafka replay, and session rail refresh.
|
||||
|
||||
import type { ChatMessage, TraceEvent, WorkbenchSessionRecord } from "../src/types/index.ts";
|
||||
import { projectWorkbenchLiveKafkaMessage, projectWorkbenchLiveKafkaUserMessage, projectWorkbenchLiveKafkaUserSession } from "../src/stores/workbench-live-kafka-event.ts";
|
||||
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, type WorkbenchServerState } from "../src/stores/workbench-server-state.ts";
|
||||
import { mergeSessionIntoList, sessionToSessionTab, sortSessionTabs } from "../src/stores/workbench-session.ts";
|
||||
|
||||
interface SampleSummary {
|
||||
p50: number;
|
||||
p95: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
interface ReplayFixture {
|
||||
sessionId: string;
|
||||
traceId: string;
|
||||
events: TraceEvent[];
|
||||
}
|
||||
|
||||
const rounds = positiveInteger(process.env.HWLAB_L0_PERF_ROUNDS, 30);
|
||||
const warmupRounds = positiveInteger(process.env.HWLAB_L0_PERF_WARMUP, 8);
|
||||
const sessionScales = [40, 100, 500];
|
||||
const messagesPerSession = positiveInteger(process.env.HWLAB_L0_PERF_MESSAGES, 40);
|
||||
const replayEventCounts = [100, 500, 1_000];
|
||||
|
||||
const output: Record<string, unknown> = {
|
||||
contractVersion: "workbench-l0-session-performance-v1",
|
||||
runtime: `bun-${Bun.version}`,
|
||||
rounds,
|
||||
warmupRounds,
|
||||
messagesPerSession,
|
||||
units: "milliseconds",
|
||||
sessionScales: {},
|
||||
replayScales: {}
|
||||
};
|
||||
|
||||
for (const sessionCount of sessionScales) {
|
||||
const sessions = sessionFixtures(sessionCount, messagesPerSession);
|
||||
const state = reduceWorkbenchServerState(createWorkbenchServerState(), { type: "session.list", sessions });
|
||||
const activeIds = sessions.map((session) => session.sessionId);
|
||||
let switchIndex = 0;
|
||||
const activeSwitch = measure(() => {
|
||||
const activeSessionId = activeIds[switchIndex % activeIds.length] ?? null;
|
||||
switchIndex += 1;
|
||||
const activeSession = selectActiveSession(state, activeSessionId);
|
||||
const messages = selectActiveMessages(state, activeSessionId);
|
||||
const tabs = sortSessionTabs(selectSessionList(state), activeSessionId, state.sessionStatusById);
|
||||
consume(activeSession?.sessionId, messages.length, tabs[0]?.key);
|
||||
});
|
||||
const projection = measure(() => {
|
||||
const tabs = sessions.map((session) => sessionToSessionTab(session, sessions[0]?.sessionId ?? null));
|
||||
consume(tabs[0]?.label, tabs.at(-1)?.updatedAt);
|
||||
});
|
||||
const sort = measure(() => {
|
||||
const tabs = sortSessionTabs(sessions, sessions[0]?.sessionId ?? null);
|
||||
consume(tabs[0]?.key, tabs.at(-1)?.key);
|
||||
});
|
||||
const railRefresh = measure(() => {
|
||||
const refreshed = refreshRailWithUserEvent(state, sessionCount - 1);
|
||||
consume(refreshed.state.sessionOrder.length, refreshed.tabs[0]?.label);
|
||||
});
|
||||
(output.sessionScales as Record<string, unknown>)[String(sessionCount)] = {
|
||||
projectTabs: projection,
|
||||
projectAndSortTabs: sort,
|
||||
activeTagSwitch: activeSwitch,
|
||||
kafkaUserEventAndRailRefresh: railRefresh
|
||||
};
|
||||
}
|
||||
|
||||
for (const eventCount of replayEventCounts) {
|
||||
const fixture = replayFixture(eventCount);
|
||||
const result = measure(() => {
|
||||
const replayed = replayKafkaEvents(fixture);
|
||||
consume(replayed.state.sessionOrder.length, replayed.message?.runnerTrace?.events?.length, replayed.tabs[0]?.status);
|
||||
}, Math.min(rounds, eventCount >= 1_000 ? 12 : rounds), Math.min(warmupRounds, 4));
|
||||
(output.replayScales as Record<string, unknown>)[String(eventCount)] = result;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
|
||||
function refreshRailWithUserEvent(initial: WorkbenchServerState, targetIndex: number): { state: WorkbenchServerState; tabs: ReturnType<typeof sortSessionTabs> } {
|
||||
const sessionId = initial.sessionOrder[targetIndex] ?? initial.sessionOrder[0] ?? "ses_perf_0";
|
||||
const traceId = `trc_refresh_${targetIndex}`;
|
||||
const event: TraceEvent = {
|
||||
type: "user",
|
||||
eventType: "user",
|
||||
messageId: `msg_refresh_${targetIndex}`,
|
||||
userMessageId: `msg_refresh_${targetIndex}`,
|
||||
text: "refresh rail title",
|
||||
createdAt: "2026-07-20T12:00:00.000Z",
|
||||
sessionId,
|
||||
projectedSeq: 1
|
||||
};
|
||||
const previousMessages = initial.messagesBySessionId[sessionId] ?? [];
|
||||
const userMessage = projectWorkbenchLiveKafkaUserMessage({ previous: null, traceId, sessionId, event, receivedAt: "2026-07-20T12:00:00.010Z" });
|
||||
if (!userMessage) throw new Error("user replay fixture did not project");
|
||||
let state = reduceWorkbenchServerState(initial, { type: "message.upsert", sessionId, message: userMessage });
|
||||
const sessions = selectSessionList(state);
|
||||
const existing = sessions.find((session) => session.sessionId === sessionId) ?? null;
|
||||
const projectedSession = projectWorkbenchLiveKafkaUserSession({ previous: existing, message: userMessage, sessionId });
|
||||
if (!projectedSession) throw new Error("user session fixture did not project");
|
||||
state = reduceWorkbenchServerState(state, { type: "session.list", sessions: mergeSessionIntoList(sessions, { ...projectedSession, messages: [...previousMessages, userMessage] }) });
|
||||
return { state, tabs: sortSessionTabs(selectSessionList(state), sessionId, state.sessionStatusById) };
|
||||
}
|
||||
|
||||
function replayKafkaEvents(fixture: ReplayFixture): { state: WorkbenchServerState; message: ChatMessage | null; tabs: ReturnType<typeof sortSessionTabs> } {
|
||||
let state = createWorkbenchServerState();
|
||||
state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId: fixture.sessionId, messages: [] } });
|
||||
let agentMessage: ChatMessage | null = null;
|
||||
for (const event of fixture.events) {
|
||||
if (event.type === "user") {
|
||||
const userMessage = projectWorkbenchLiveKafkaUserMessage({ previous: null, traceId: fixture.traceId, sessionId: fixture.sessionId, event, receivedAt: String(event.createdAt) });
|
||||
if (!userMessage) throw new Error("replay user fixture did not project");
|
||||
state = reduceWorkbenchServerState(state, { type: "message.upsert", sessionId: fixture.sessionId, message: userMessage });
|
||||
const session = projectWorkbenchLiveKafkaUserSession({ previous: selectActiveSession(state, fixture.sessionId), message: userMessage, sessionId: fixture.sessionId });
|
||||
if (session) state = reduceWorkbenchServerState(state, { type: "session.list", sessions: mergeSessionIntoList(selectSessionList(state), session) });
|
||||
continue;
|
||||
}
|
||||
agentMessage = projectWorkbenchLiveKafkaMessage({ previous: agentMessage, traceId: fixture.traceId, sessionId: fixture.sessionId, event, receivedAt: String(event.createdAt), turnStartedAt: String(fixture.events[0]?.createdAt) });
|
||||
state = reduceWorkbenchServerState(state, { type: "message.upsert", sessionId: fixture.sessionId, message: agentMessage });
|
||||
}
|
||||
return { state, message: agentMessage, tabs: sortSessionTabs(selectSessionList(state), fixture.sessionId, state.sessionStatusById) };
|
||||
}
|
||||
|
||||
function replayFixture(eventCount: number): ReplayFixture {
|
||||
const sessionId = `ses_replay_${eventCount}`;
|
||||
const traceId = `trc_replay_${eventCount}`;
|
||||
const events: TraceEvent[] = [{
|
||||
type: "user",
|
||||
eventType: "user",
|
||||
messageId: `msg_replay_${eventCount}_user`,
|
||||
userMessageId: `msg_replay_${eventCount}_user`,
|
||||
text: "measure retention replay",
|
||||
createdAt: timestamp(0),
|
||||
sessionId,
|
||||
projectedSeq: 1
|
||||
}];
|
||||
for (let index = 1; index < eventCount; index += 1) {
|
||||
const terminal = index === eventCount - 1;
|
||||
events.push({
|
||||
type: terminal ? "result" : index % 5 === 0 ? "assistant_progress" : "event",
|
||||
eventType: terminal ? "terminal" : index % 5 === 0 ? "assistant_progress" : "backend",
|
||||
text: index % 5 === 0 ? `progress ${index}` : undefined,
|
||||
finalResponse: terminal ? { text: "completed replay" } : undefined,
|
||||
status: terminal ? "completed" : "running",
|
||||
terminal,
|
||||
createdAt: timestamp(index),
|
||||
projectedSeq: index + 1,
|
||||
sourceSeq: index + 1,
|
||||
source: "agentrun.event.v1",
|
||||
sessionId,
|
||||
traceId
|
||||
});
|
||||
}
|
||||
return { sessionId, traceId, events };
|
||||
}
|
||||
|
||||
function sessionFixtures(sessionCount: number, messageCount: number): WorkbenchSessionRecord[] {
|
||||
return Array.from({ length: sessionCount }, (_, sessionIndex) => {
|
||||
const sessionId = `ses_perf_${sessionIndex}`;
|
||||
const messages = Array.from({ length: messageCount }, (_, messageIndex) => messageFixture(sessionId, sessionIndex, messageIndex));
|
||||
return {
|
||||
sessionId,
|
||||
threadId: `thr_perf_${sessionIndex}`,
|
||||
status: sessionIndex % 11 === 0 ? "running" : "completed",
|
||||
lastTraceId: `trc_perf_${sessionIndex}_${messageCount - 1}`,
|
||||
messageCount,
|
||||
messages,
|
||||
firstUserMessagePreview: `session ${sessionIndex} title`,
|
||||
lastUserMessageAt: timestamp(sessionIndex * 100 + messageCount - 2),
|
||||
updatedAt: timestamp(sessionIndex * 100 + messageCount - 1)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function messageFixture(sessionId: string, sessionIndex: number, messageIndex: number): ChatMessage {
|
||||
const role = messageIndex % 2 === 0 ? "user" : "agent";
|
||||
const traceId = `trc_perf_${sessionIndex}_${Math.floor(messageIndex / 2)}`;
|
||||
return {
|
||||
id: `msg_perf_${sessionIndex}_${messageIndex}`,
|
||||
messageId: `msg_perf_${sessionIndex}_${messageIndex}`,
|
||||
role,
|
||||
title: role === "user" ? "用户" : "Code Agent",
|
||||
text: role === "user" ? `session ${sessionIndex} prompt ${messageIndex}` : `response ${messageIndex}`,
|
||||
status: role === "user" ? "sent" : messageIndex === messagesPerSession - 1 && sessionIndex % 11 === 0 ? "running" : "completed",
|
||||
traceId,
|
||||
sessionId,
|
||||
createdAt: timestamp(sessionIndex * 100 + messageIndex)
|
||||
};
|
||||
}
|
||||
|
||||
function measure(operation: () => void, sampleRounds = rounds, warmups = warmupRounds): SampleSummary {
|
||||
for (let index = 0; index < warmups; index += 1) operation();
|
||||
const samples: number[] = [];
|
||||
for (let index = 0; index < sampleRounds; index += 1) {
|
||||
const startedAt = Bun.nanoseconds();
|
||||
operation();
|
||||
samples.push((Bun.nanoseconds() - startedAt) / 1_000_000);
|
||||
}
|
||||
samples.sort((left, right) => left - right);
|
||||
return {
|
||||
p50: rounded(percentile(samples, 0.5)),
|
||||
p95: rounded(percentile(samples, 0.95)),
|
||||
max: rounded(samples.at(-1) ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(samples: number[], ratio: number): number {
|
||||
return samples[Math.min(samples.length - 1, Math.max(0, Math.ceil(samples.length * ratio) - 1))] ?? 0;
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function timestamp(offsetSeconds: number): string {
|
||||
return new Date(Date.UTC(2026, 6, 20, 0, 0, offsetSeconds)).toISOString();
|
||||
}
|
||||
|
||||
function rounded(value: number): number {
|
||||
return Math.round(value * 1_000) / 1_000;
|
||||
}
|
||||
|
||||
function consume(...values: unknown[]): void {
|
||||
if (values.length === Number.MIN_SAFE_INTEGER) console.log(values);
|
||||
}
|
||||
Reference in New Issue
Block a user