项目文件夹

文件
2026-07-13 13:20:22 +08:00

881 行
36 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* ACP Agent Startup Latency Benchmark
*
* Launches the Electron app with ACP_PERF=1, then drives N consecutive
* new-chat sessions for each specified agent backend (claude, codex).
* After each session reaches session_active, it reads the [ACP-PERF] log
* lines from the electron-log file and records per-phase timings.
*
* Usage:
* bunx tsx scripts/benchmark-acp-startup.ts [--agents claude,codex] [--sessions 20]
*/
import { _electron as electron, type ElectronApplication, type Page } from 'playwright';
import path from 'path';
import fs from 'fs';
import os from 'os';
// ── CLI args ────────────────────────────────────────────────────────────────
function parseArgs() {
const args = process.argv.slice(2);
let agents = ['claude', 'codex'];
let sessions = 20;
let slow = false;
let models: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--agents' && args[i + 1]) {
agents = args[i + 1].split(',').map((s) => s.trim());
i++;
}
if (args[i] === '--sessions' && args[i + 1]) {
sessions = parseInt(args[i + 1], 10);
i++;
}
if (args[i] === '--models' && args[i + 1]) {
models = args[i + 1].split(',').map((s) => s.trim());
i++;
}
if (args[i] === '--slow') {
slow = true;
}
}
return { agents, sessions, slow, models };
}
// ── Types ───────────────────────────────────────────────────────────────────
type SessionTiming = {
agent: string;
model: string;
sessionIndex: number;
wallClockMs: number;
totalMs: number;
connectionMs: number;
authenticationMs: number;
sessionCreatedMs: number;
modelSetMs: number;
sessionModeSetMs: number;
firstChunkMs: number;
// Connection sub-phases
shellEnvMs: number;
envPreparedMs: number;
processSpawnMs: number;
cliStartupMs: number;
protocolInitMs: number;
connectTotalMs: number;
codexCacheLookupMs: number;
codexDiagnosticsMs: number;
failed: boolean;
timestamp: string;
};
// ── Selectors (mirrors tests/e2e/helpers/selectors.ts) ──────────────────────
const AGENT_PILL = '[data-agent-pill="true"]';
const GUID_INPUT = '.guid-input-card-shell textarea';
const AGENT_STATUS_MESSAGE = '.agent-status-message';
const NEW_CHAT_TRIGGER = 'div.newChatTrigger';
const MODEL_SELECTOR_BTN = 'button.sendbox-model-btn.guid-config-btn';
function agentPillByBackend(backend: string) {
return `${AGENT_PILL}[data-agent-backend="${backend}"]`;
}
// ── Log parsing ─────────────────────────────────────────────────────────────
function getLogFilePath(): string {
const today = new Date().toISOString().slice(0, 10);
// Dev mode uses "AionUi-Dev", production uses "AionUi"
const devPath = path.join(os.homedir(), 'Library', 'Logs', 'AionUi-Dev', `${today}.log`);
const prodPath = path.join(os.homedir(), 'Library', 'Logs', 'AionUi', `${today}.log`);
return fs.existsSync(devPath) ? devPath : prodPath;
}
function getLogFileSize(logPath: string): number {
try {
return fs.statSync(logPath).size;
} catch {
return 0;
}
}
function readNewLogLines(logPath: string, offset: number): string[] {
try {
const currentSize = fs.statSync(logPath).size;
if (currentSize <= offset) return [];
const fd = fs.openSync(logPath, 'r');
const buf = Buffer.alloc(currentSize - offset);
fs.readSync(fd, buf, 0, buf.length, offset);
fs.closeSync(fd);
return buf.toString('utf-8').split('\n');
} catch {
return [];
}
}
const PERF_START_REGEX = /\[ACP-PERF\] start: (.+?) (\d+)ms/;
const PERF_CONNECT_REGEX = /\[ACP-PERF\] connect: (.+?) (\d+)ms/;
const PERF_BACKEND_REGEX = /\[ACP-PERF\] \w+: (env prepared|process spawned)\D*?(\d+)ms/;
const PERF_FIRST_CHUNK_REGEX = /\[ACP-PERF\] stream: first chunk received (\d+)ms/;
function parsePerfLines(lines: string[]): Partial<SessionTiming> {
const result: Partial<SessionTiming> = {
totalMs: 0,
connectionMs: 0,
authenticationMs: 0,
sessionCreatedMs: 0,
modelSetMs: 0,
sessionModeSetMs: 0,
firstChunkMs: 0,
shellEnvMs: 0,
envPreparedMs: 0,
processSpawnMs: 0,
cliStartupMs: 0,
protocolInitMs: 0,
connectTotalMs: 0,
codexCacheLookupMs: 0,
codexDiagnosticsMs: 0,
failed: false,
};
for (const line of lines) {
// Parse first chunk latency
const chunkMatch = PERF_FIRST_CHUNK_REGEX.exec(line);
if (chunkMatch) {
result.firstChunkMs = parseInt(chunkMatch[1], 10);
continue;
}
// Parse connection sub-phases: [ACP-PERF] connect: <phase> <ms>ms
const connMatch = PERF_CONNECT_REGEX.exec(line);
if (connMatch) {
const [, phase, msStr] = connMatch;
const ms = parseInt(msStr, 10);
if (phase.startsWith('shell env loaded')) result.shellEnvMs = ms;
else if (phase.startsWith('cli startup')) result.cliStartupMs = ms;
else if (phase.startsWith('protocol initialized')) result.protocolInitMs = ms;
else if (phase === 'total') result.connectTotalMs = ms;
else if (phase.startsWith('codex cached binary lookup')) result.codexCacheLookupMs = ms;
else if (phase.startsWith('codex diagnostics')) result.codexDiagnosticsMs = ms;
continue;
}
// Parse backend-specific phases: [ACP-PERF] <backend>: env prepared|process spawned <ms>ms
const backendMatch = PERF_BACKEND_REGEX.exec(line);
if (backendMatch) {
const [, phase, msStr] = backendMatch;
const ms = parseInt(msStr, 10);
if (phase.startsWith('env prepared')) result.envPreparedMs = ms;
else if (phase.startsWith('process spawned')) result.processSpawnMs = ms;
continue;
}
// Parse start-level phases: [ACP-PERF] start: <phase> <ms>ms
const match = PERF_START_REGEX.exec(line);
if (!match) continue;
const [, phase, msStr] = match;
const ms = parseInt(msStr, 10);
if (phase === 'total') result.totalMs = ms;
else if (phase.startsWith('connection.connect()')) result.connectionMs = ms;
else if (phase.startsWith('authentication')) result.authenticationMs = ms;
else if (phase.startsWith('session created')) result.sessionCreatedMs = ms;
else if (phase.startsWith('model set')) result.modelSetMs = ms;
else if (phase.startsWith('session mode set')) result.sessionModeSetMs = ms;
else if (phase.startsWith('failed')) result.failed = true;
}
return result;
}
// ── App launch (mirrors tests/e2e/fixtures.ts) ─────────────────────────────
async function launchApp(): Promise<ElectronApplication> {
const projectRoot = path.resolve(__dirname, '..');
console.log(`[benchmark] Launching Electron app from: ${projectRoot}`);
const launchArgs = ['.'];
const electronApp = await electron.launch({
args: launchArgs,
cwd: projectRoot,
env: {
...process.env,
ACP_PERF: '1',
AIONUI_DISABLE_AUTO_UPDATE: '1',
AIONUI_E2E_TEST: '1',
AIONUI_DISABLE_DEVTOOLS: '1',
AIONUI_CDP_PORT: '0',
NODE_ENV: 'development',
},
timeout: 60_000,
});
return electronApp;
}
async function resolveMainWindow(electronApp: ElectronApplication): Promise<Page> {
const existing = electronApp.windows().find((w) => !w.url().startsWith('devtools://'));
if (existing) {
await existing.waitForLoadState('domcontentloaded');
return existing;
}
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const win = await electronApp.waitForEvent('window', { timeout: 1_000 }).catch(() => null);
if (win && !win.url().startsWith('devtools://')) {
await win.waitForLoadState('domcontentloaded');
return win;
}
}
throw new Error('Failed to resolve main window');
}
// ── Navigation helpers ──────────────────────────────────────────────────────
async function navigateTo(page: Page, hash: string): Promise<void> {
await page.evaluate((h) => window.location.assign(h), hash);
await page.waitForFunction((h) => window.location.hash === h, hash, { timeout: 10_000 }).catch(() => {});
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.waitForFunction(() => (document.body.textContent?.length ?? 0) > 50, { timeout: 10_000 }).catch(() => {});
}
async function goToGuid(page: Page): Promise<void> {
if (page.url().includes('#/guid')) return;
await navigateTo(page, '#/guid');
}
// ── Bridge helper (mirrors tests/e2e/helpers/bridge.ts) ─────────────────────
async function invokeBridge<T = unknown>(page: Page, key: string, data?: unknown, timeoutMs = 10_000): Promise<T> {
return page.evaluate(
async ({ requestKey, requestData, requestTimeoutMs }) => {
const api = (window as unknown as { electronAPI?: { emit?: Function; on?: Function } }).electronAPI;
if (!api?.emit || !api?.on) throw new Error('electronAPI bridge unavailable');
const id = `bench_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
const callbackEventName = `subscribe.callback-${requestKey}${id}`;
const requestEventName = `subscribe-${requestKey}`;
return new Promise<unknown>((resolve, reject) => {
let settled = false;
const off = api.on?.((payload: { value: unknown }) => {
try {
const raw = payload?.value;
const parsed = typeof raw === 'string' ? JSON.parse(raw) : (raw as { name?: string; data?: unknown });
if (parsed?.name !== callbackEventName) return;
if (settled) return;
settled = true;
off?.();
clearTimeout(timer);
resolve(parsed.data);
} catch (e) {
if (settled) return;
settled = true;
off?.();
clearTimeout(timer);
reject(e);
}
});
const timer = setTimeout(() => {
if (settled) return;
settled = true;
off?.();
reject(new Error(`Bridge timeout: ${requestKey}`));
}, requestTimeoutMs);
api.emit?.(requestEventName, { id, data: requestData });
});
},
{ requestKey: key, requestData: data, requestTimeoutMs: timeoutMs }
) as Promise<T>;
}
// ── Benchmark loop ──────────────────────────────────────────────────────────
/**
* Select a model from the ACP model dropdown on the guid page.
* @param modelLabel - The visible model label text (e.g. "Sonnet", "Opus", "Haiku").
* Matches case-insensitively, partial match supported.
*/
async function selectModel(page: Page, modelLabel: string, slow: boolean): Promise<boolean> {
const pause = (ms: number) => (slow ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve());
// Wait for model selector button to appear (models load asynchronously after agent probe)
const btn = page.locator(MODEL_SELECTOR_BTN);
try {
await btn.waitFor({ state: 'visible', timeout: 15_000 });
} catch {
console.log(` [model] model selector button not visible — skipping model switch`);
return false;
}
await pause(500);
await btn.click();
// Escape user-provided model label before embedding in RegExp
const escapedModelLabel = modelLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Wait for dropdown menu to render, then find the matching item by exact label text
const menuItem = page
.locator(`.arco-dropdown-menu-item span`)
.filter({ hasText: new RegExp(`^${escapedModelLabel}$`, 'i') })
.first();
try {
await menuItem.waitFor({ state: 'visible', timeout: 5_000 });
} catch {
// Close the dropdown before returning
await page.keyboard.press('Escape');
console.log(` [model] model "${modelLabel}" not found in dropdown`);
return false;
}
await pause(300);
await menuItem.click();
await pause(300);
return true;
}
async function runBenchmark(
page: Page,
electronApp: ElectronApplication,
agents: string[],
models: string[],
sessionsPerAgent: number,
slow = false
): Promise<SessionTiming[]> {
const pause = (ms: number) => (slow ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve());
const logPath = getLogFilePath();
const results: SessionTiming[] = [];
// Build test matrix: agent × model (if models specified) or agent × ['default']
const modelList = models.length > 0 ? models : ['default'];
for (const agent of agents) {
for (const model of modelList) {
const groupLabel = model === 'default' ? agent : `${agent}/${model}`;
console.log(`\n[benchmark] Starting ${sessionsPerAgent} sessions for "${groupLabel}"...\n`);
for (let i = 1; i <= sessionsPerAgent; i++) {
const label = `[${groupLabel} #${i}/${sessionsPerAgent}]`;
try {
// 1. Record log offset
const logOffset = getLogFileSize(logPath);
// 2. Navigate to guid
await goToGuid(page);
// 3. Wait for pills to render, select agent
await page.locator(AGENT_PILL).first().waitFor({ state: 'visible', timeout: 8_000 });
await pause(1_000);
const pill = page.locator(agentPillByBackend(agent));
const pillVisible = await pill.isVisible().catch(() => false);
if (!pillVisible) {
console.log(`${label} SKIP — agent pill not found`);
break;
}
await pill.click();
await page.waitForSelector(`${agentPillByBackend(agent)}[data-agent-selected="true"]`, {
timeout: 5_000,
});
await pause(1_000);
// 3b. Select model if specified
if (model !== 'default') {
const modelSelected = await selectModel(page, model, slow);
if (!modelSelected) {
console.log(`${label} SKIP — model "${model}" not available`);
break;
}
console.log(`${label} model "${model}" selected`);
}
// 4. Send message
const textarea = page.locator(GUID_INPUT);
await textarea.waitFor({ state: 'visible', timeout: 8_000 });
await pause(500);
await textarea.fill(`benchmark ${groupLabel} session ${i}`);
console.log(`${label} filled textarea, pressing Enter...`);
await pause(1_000);
const wallStart = Date.now();
await textarea.press('Enter');
// 5. Wait for conversation page
console.log(`${label} waiting for conversation page...`);
await page.waitForFunction(() => window.location.hash.includes('/conversation/'), {
timeout: 15_000,
});
const hash = new URL(page.url()).hash;
const conversationId = hash.split('/conversation/')[1];
console.log(`${label} conversation created: ${conversationId}`);
// 6. Wait for ACP startup + response to complete by polling the log file.
// - [ACP-PERF] start: total — marks startup complete
// - [ACP-PERF] send: sendPrompt completed — marks the full turn done
console.log(`${label} waiting for [ACP-PERF] logs...`);
const pollDeadline = Date.now() + 180_000;
let startupDetected = false;
let turnCompleted = false;
while (Date.now() < pollDeadline) {
const newLines = readNewLogLines(logPath, logOffset);
if (!startupDetected && newLines.some((line) => /\[ACP-PERF\] start: total/.test(line))) {
startupDetected = true;
console.log(`${label} startup detected, waiting for response to finish...`);
}
if (startupDetected && newLines.some((line) => /\[ACP-PERF\] send: sendPrompt completed/.test(line))) {
turnCompleted = true;
break;
}
await new Promise((r) => setTimeout(r, 500));
}
const wallEnd = Date.now();
const wallClockMs = wallEnd - wallStart;
if (!startupDetected) {
console.log(`${label} WARN: [ACP-PERF] start: total not found in log after 180s`);
} else if (!turnCompleted) {
console.log(`${label} WARN: startup detected but response did not complete within timeout`);
}
// 7. Parse log lines (re-read to get final state)
const finalLines = readNewLogLines(logPath, logOffset);
const perf = parsePerfLines(finalLines);
const timing: SessionTiming = {
agent,
model,
sessionIndex: i,
wallClockMs,
totalMs: perf.totalMs ?? 0,
connectionMs: perf.connectionMs ?? 0,
authenticationMs: perf.authenticationMs ?? 0,
sessionCreatedMs: perf.sessionCreatedMs ?? 0,
modelSetMs: perf.modelSetMs ?? 0,
sessionModeSetMs: perf.sessionModeSetMs ?? 0,
firstChunkMs: perf.firstChunkMs ?? 0,
shellEnvMs: perf.shellEnvMs ?? 0,
envPreparedMs: perf.envPreparedMs ?? 0,
processSpawnMs: perf.processSpawnMs ?? 0,
cliStartupMs: perf.cliStartupMs ?? 0,
protocolInitMs: perf.protocolInitMs ?? 0,
connectTotalMs: perf.connectTotalMs ?? 0,
codexCacheLookupMs: perf.codexCacheLookupMs ?? 0,
codexDiagnosticsMs: perf.codexDiagnosticsMs ?? 0,
failed: perf.failed ?? false,
timestamp: new Date().toISOString(),
};
results.push(timing);
console.log(
`${label} total=${timing.totalMs}ms firstChunk=${timing.firstChunkMs}ms wall=${wallClockMs}ms ` +
`(conn=${timing.connectionMs} auth=${timing.authenticationMs} ` +
`session=${timing.sessionCreatedMs} model=${timing.modelSetMs})` +
` [connect: shellEnv=${timing.shellEnvMs} envPrep=${timing.envPreparedMs}` +
` spawn=${timing.processSpawnMs} cli=${timing.cliStartupMs}` +
` proto=${timing.protocolInitMs} total=${timing.connectTotalMs}]`
);
// 8. Delete conversation to release resources (kill agent process + delete DB)
if (conversationId) {
console.log(`${label} deleting conversation ${conversationId}...`);
try {
const deleted = await page.evaluate(async (convId) => {
const api = (window as any).electronAPI;
if (!api?.emit || !api?.on) return false;
const reqId = `del_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
const callbackName = `subscribe.callback-remove-conversation${reqId}`;
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), 15_000);
const off = api.on((payload: any) => {
const parsed = typeof payload?.value === 'string' ? JSON.parse(payload.value) : payload?.value;
if (parsed?.name !== callbackName) return;
off?.();
clearTimeout(timer);
resolve(!!parsed.data);
});
api.emit('subscribe-remove-conversation', { id: reqId, data: { id: convId } });
});
}, conversationId);
console.log(`${label} delete result: ${deleted}`);
if (deleted) {
await page
.waitForFunction(() => !window.location.hash.includes('/conversation/'), { timeout: 5_000 })
.catch(() => {});
}
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
console.warn(`${label} delete failed: ${errMsg}`);
}
}
// 9. Cooldown — wait for ACP subprocess to fully exit
await new Promise((r) => setTimeout(r, 2_000));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`${label} FAILED: ${msg}`);
results.push({
agent,
model,
sessionIndex: i,
wallClockMs: -1,
totalMs: -1,
connectionMs: 0,
authenticationMs: 0,
sessionCreatedMs: 0,
modelSetMs: 0,
sessionModeSetMs: 0,
firstChunkMs: 0,
shellEnvMs: 0,
envPreparedMs: 0,
processSpawnMs: 0,
cliStartupMs: 0,
protocolInitMs: 0,
connectTotalMs: 0,
codexCacheLookupMs: 0,
codexDiagnosticsMs: 0,
failed: true,
timestamp: new Date().toISOString(),
});
// Try to recover: navigate back to guid
await goToGuid(page).catch(() => {});
await new Promise((r) => setTimeout(r, 2_000));
}
}
}
}
return results;
}
// ── Statistics ───────────────────────────────────────────────────────────────
function computeStats(values: number[]) {
const sorted = [...values].filter((v) => v > 0).sort((a, b) => a - b);
if (sorted.length === 0) return { mean: 0, median: 0, p95: 0, min: 0, max: 0, count: 0 };
const sum = sorted.reduce((a, b) => a + b, 0);
const mean = Math.round(sum / sorted.length);
const median = sorted[Math.floor(sorted.length / 2)];
const p95 = sorted[Math.floor(sorted.length * 0.95)];
return { mean, median, p95, min: sorted[0], max: sorted[sorted.length - 1], count: sorted.length };
}
// ── Grouping helper ────────────────────────────────────────────────────────
/** Derive unique group keys from results (e.g. "claude/sonnet-4", "codex/default"). */
function getGroups(results: SessionTiming[]): { key: string; agent: string; model: string }[] {
const seen = new Set<string>();
const groups: { key: string; agent: string; model: string }[] = [];
for (const r of results) {
const key = r.model === 'default' ? r.agent : `${r.agent}/${r.model}`;
if (!seen.has(key)) {
seen.add(key);
groups.push({ key, agent: r.agent, model: r.model });
}
}
return groups;
}
function filterGroup(results: SessionTiming[], agent: string, model: string): SessionTiming[] {
return results.filter((r) => r.agent === agent && r.model === model && !r.failed);
}
// ── Terminal report ─────────────────────────────────────────────────────────
function printTerminalReport(results: SessionTiming[]) {
const groups = getGroups(results);
console.log('\n' + '═'.repeat(80));
console.log(' ACP Startup Latency Benchmark — Summary');
console.log('═'.repeat(80));
for (const { key, agent, model } of groups) {
const gr = filterGroup(results, agent, model);
const totalStats = computeStats(gr.map((r) => r.totalMs));
const firstChunkStats = computeStats(gr.map((r) => r.firstChunkMs));
const wallStats = computeStats(gr.map((r) => r.wallClockMs));
const connStats = computeStats(gr.map((r) => r.connectionMs));
const authStats = computeStats(gr.map((r) => r.authenticationMs));
const sessStats = computeStats(gr.map((r) => r.sessionCreatedMs));
// Connection sub-phases
const shellEnvStats = computeStats(gr.map((r) => r.shellEnvMs));
const envPrepStats = computeStats(gr.map((r) => r.envPreparedMs));
const spawnStats = computeStats(gr.map((r) => r.processSpawnMs));
const cliStartupStats = computeStats(gr.map((r) => r.cliStartupMs));
const protoInitStats = computeStats(gr.map((r) => r.protocolInitMs));
const connTotalStats = computeStats(gr.map((r) => r.connectTotalMs));
const pad = 24;
console.log(`\n ${key.toUpperCase()} (${totalStats.count} successful sessions)`);
console.log(' ' + '─'.repeat(76));
console.log(
` ${'Phase'.padEnd(pad)} ${'Mean'.padStart(8)} ${'Median'.padStart(8)} ${'P95'.padStart(8)} ${'Min'.padStart(8)} ${'Max'.padStart(8)}`
);
console.log(' ' + '─'.repeat(76));
const rows: [string, ReturnType<typeof computeStats>][] = [
['Total (log)', totalStats],
['First chunk', firstChunkStats],
['Wall clock', wallStats],
['Connection', connStats],
[' Shell env', shellEnvStats],
[' Env prepared', envPrepStats],
[' Process spawn', spawnStats],
[' CLI startup', cliStartupStats],
[' Protocol init', protoInitStats],
[' Connect total', connTotalStats],
['Authentication', authStats],
['Session create', sessStats],
];
// Add codex-specific rows if any data exists
const codexCacheStats = computeStats(gr.map((r) => r.codexCacheLookupMs));
const codexDiagStats = computeStats(gr.map((r) => r.codexDiagnosticsMs));
if (codexCacheStats.count > 0 || codexDiagStats.count > 0) {
rows.push([' Codex cache lookup', codexCacheStats]);
rows.push([' Codex diagnostics', codexDiagStats]);
}
for (const [label, stats] of rows) {
console.log(
` ${label.padEnd(pad)} ${(stats.mean + 'ms').padStart(8)} ${(stats.median + 'ms').padStart(8)} ${(stats.p95 + 'ms').padStart(8)} ${(stats.min + 'ms').padStart(8)} ${(stats.max + 'ms').padStart(8)}`
);
}
}
console.log('\n' + '═'.repeat(80) + '\n');
}
// ── HTML report ─────────────────────────────────────────────────────────────
function generateHtmlReport(results: SessionTiming[]): string {
const now = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const outputDir = path.join(__dirname, 'benchmark-results');
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, `acp-startup-${now}.html`);
const groups = getGroups(results);
const dataJson = JSON.stringify(results, null, 2);
const groupsJson = JSON.stringify(groups);
const statsRows = groups
.map(({ key, agent, model }) => {
const gr = filterGroup(results, agent, model);
const metrics: [string, number[]][] = [
['Startup total', gr.map((r) => r.totalMs)],
['First chunk', gr.map((r) => r.firstChunkMs)],
['Connection', gr.map((r) => r.connectionMs)],
[' Shell env', gr.map((r) => r.shellEnvMs)],
[' Env prepared', gr.map((r) => r.envPreparedMs)],
[' Process spawn', gr.map((r) => r.processSpawnMs)],
[' CLI startup', gr.map((r) => r.cliStartupMs)],
[' Protocol init', gr.map((r) => r.protocolInitMs)],
['Authentication', gr.map((r) => r.authenticationMs)],
['Session create', gr.map((r) => r.sessionCreatedMs)],
];
return metrics
.map(([label, vals]) => {
const s = computeStats(vals);
return `<tr><td>${key}</td><td>${label}</td><td>${s.count}</td><td>${s.mean}ms</td><td>${s.median}ms</td><td>${s.p95}ms</td><td>${s.min}ms</td><td>${s.max}ms</td></tr>`;
})
.join('\n');
})
.join('\n');
const rawRows = results
.map((r) => {
const groupKey = r.model === 'default' ? r.agent : `${r.agent}/${r.model}`;
return (
`<tr class="${r.failed ? 'failed' : ''}"><td>${r.sessionIndex}</td><td>${groupKey}</td>` +
`<td>${r.totalMs}</td><td>${r.firstChunkMs}</td><td>${r.connectionMs}</td>` +
`<td>${r.shellEnvMs}</td><td>${r.envPreparedMs}</td><td>${r.processSpawnMs}</td>` +
`<td>${r.cliStartupMs}</td><td>${r.protocolInitMs}</td>` +
`<td>${r.authenticationMs}</td><td>${r.sessionCreatedMs}</td>` +
`<td>${r.wallClockMs}</td><td>${r.failed ? 'FAIL' : 'OK'}</td></tr>`
);
})
.join('\n');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ACP Startup Latency Benchmark</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 32px; background: #f8f9fa; color: #333; }
h1 { margin-bottom: 8px; }
.meta { color: #666; margin-bottom: 24px; }
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px; }
.chart-card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
canvas { width: 100% !important; }
table { border-collapse: collapse; width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.08); margin-bottom: 24px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
th { background: #f1f3f5; font-weight: 600; }
tr.failed td { background: #fff5f5; color: #c92a2a; }
@media (max-width: 900px) { .charts { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>ACP Startup Latency Benchmark</h1>
<p class="meta">Generated: ${new Date().toISOString()} &bull; Groups: ${groups.map((g) => g.key).join(', ')}</p>
<div class="charts">
<div class="chart-card"><canvas id="firstChunkChart"></canvas></div>
<div class="chart-card"><canvas id="breakdownChart"></canvas></div>
</div>
<h2 style="margin-bottom:12px;">Summary</h2>
<table>
<thead><tr><th>Group</th><th>Metric</th><th>Sessions</th><th>Mean</th><th>Median</th><th>P95</th><th>Min</th><th>Max</th></tr></thead>
<tbody>${statsRows}</tbody>
</table>
<h2 style="margin-bottom:12px;">Raw Data</h2>
<table>
<thead><tr><th>#</th><th>Group</th><th>Total</th><th>1st Chunk</th><th>Connect</th><th>Shell Env</th><th>Env Prep</th><th>Spawn</th><th>CLI Boot</th><th>Proto Init</th><th>Auth</th><th>Session</th><th>Wall</th><th>Status</th></tr></thead>
<tbody>${rawRows}</tbody>
</table>
<script>
const data = ${dataJson};
const groups = ${groupsJson};
const palette = ['#4A90D9','#50C878','#E6A23C','#F56C6C','#909399','#B37FEB','#36CFC9','#FF85C0'];
// Derive group key for each record
function gk(r) { return r.model === 'default' ? r.agent : r.agent + '/' + r.model; }
// Chart 1: First chunk latency comparison (key metric)
const fcCtx = document.getElementById('firstChunkChart').getContext('2d');
const maxSession = Math.max(...data.map(d => d.sessionIndex));
const sessionLabels = Array.from({ length: maxSession }, (_, i) => i + 1);
new Chart(fcCtx, {
type: 'line',
data: {
labels: sessionLabels,
datasets: groups.flatMap((g, gi) => {
const c = palette[gi % palette.length];
const gd = data.filter(d => d.agent === g.agent && d.model === g.model);
return [
{
label: g.key + ' — first chunk',
data: gd.map(d => d.failed || !d.firstChunkMs ? null : d.firstChunkMs),
borderColor: c,
backgroundColor: c + '20',
tension: 0.3,
pointRadius: 4,
spanGaps: true,
},
{
label: g.key + ' — startup total',
data: gd.map(d => d.failed ? null : d.totalMs),
borderColor: c,
backgroundColor: c + '20',
borderDash: [6, 3],
tension: 0.3,
pointRadius: 3,
spanGaps: true,
},
];
}),
},
options: {
responsive: true,
plugins: { title: { display: true, text: 'First Chunk & Startup Latency per Session' } },
scales: { x: { title: { display: true, text: 'Session #' } }, y: { title: { display: true, text: 'ms' }, beginAtZero: true } },
},
});
// Chart 2: Grouped bar — median metrics comparison across all groups
function median(arr) {
const s = arr.filter(v => v > 0).sort((a,b) => a - b);
return s.length ? s[Math.floor(s.length / 2)] : 0;
}
const breakdownCtx = document.getElementById('breakdownChart').getContext('2d');
const groupLabels = groups.map(g => g.key);
const groupData = groups.map(g => {
const gd = data.filter(d => d.agent === g.agent && d.model === g.model && !d.failed);
return {
total: median(gd.map(d => d.totalMs)),
firstChunk: median(gd.map(d => d.firstChunkMs)),
connection: median(gd.map(d => d.connectionMs)),
shellEnv: median(gd.map(d => d.shellEnvMs)),
envPrepared: median(gd.map(d => d.envPreparedMs)),
processSpawn: median(gd.map(d => d.processSpawnMs)),
cliStartup: median(gd.map(d => d.cliStartupMs)),
protoInit: median(gd.map(d => d.protocolInitMs)),
session: median(gd.map(d => d.sessionCreatedMs)),
};
});
new Chart(breakdownCtx, {
type: 'bar',
data: {
labels: groupLabels,
datasets: [
{ label: 'Startup Total', data: groupData.map(d => d.total), backgroundColor: '#4A90D9' },
{ label: 'First Chunk', data: groupData.map(d => d.firstChunk), backgroundColor: '#50C878' },
{ label: 'Shell Env', data: groupData.map(d => d.shellEnv), backgroundColor: '#FF85C0' },
{ label: 'Env Prepared', data: groupData.map(d => d.envPrepared), backgroundColor: '#B37FEB' },
{ label: 'Process Spawn', data: groupData.map(d => d.processSpawn), backgroundColor: '#36CFC9' },
{ label: 'CLI Startup', data: groupData.map(d => d.cliStartup), backgroundColor: '#E6A23C' },
{ label: 'Protocol Init', data: groupData.map(d => d.protoInit), backgroundColor: '#F56C6C' },
{ label: 'Session Create', data: groupData.map(d => d.session), backgroundColor: '#909399' },
],
},
options: {
responsive: true,
plugins: { title: { display: true, text: 'Median Latency Breakdown Across Groups' } },
scales: { y: { title: { display: true, text: 'ms' }, beginAtZero: true } },
},
});
</script>
</body>
</html>`;
fs.writeFileSync(outputPath, html, 'utf-8');
return outputPath;
}
// ── Main ────────────────────────────────────────────────────────────────────
async function main() {
const { agents, sessions, slow, models } = parseArgs();
const modelStr = models.length > 0 ? `, Models: ${models.join(', ')}` : '';
console.log(`[benchmark] Agents: ${agents.join(', ')}${modelStr}, Sessions: ${sessions}${slow ? ', SLOW mode' : ''}`);
const electronApp = await launchApp();
const page = await resolveMainWindow(electronApp);
console.log('[benchmark] App launched, main window resolved');
// Wait for initial load
await page.waitForFunction(() => (document.body.textContent?.length ?? 0) > 50, { timeout: 30_000 });
await goToGuid(page);
console.log('[benchmark] Guid page ready, starting benchmark...');
const results = await runBenchmark(page, electronApp, agents, models, sessions, slow);
// Report
printTerminalReport(results);
const reportPath = generateHtmlReport(results);
console.log(`[benchmark] HTML report: ${reportPath}`);
// Cleanup
try {
await electronApp.evaluate(async ({ app }) => app.exit(0));
} catch {
// ignore
}
await electronApp.close().catch(() => {});
console.log('[benchmark] Done');
}
main().catch((err) => {
console.error('[benchmark] Fatal error:', err);
process.exit(1);
});