complex-history.perf.ts 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434
  1. // Opt-in browser benchmark for high-cardinality workspace and history
  2. // rendering. It reports measurements without timing assertions because host
  3. // speed is not a correctness contract; structural assertions keep the load
  4. // shape from silently shrinking.
  5. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  6. import { tmpdir } from 'node:os'
  7. import { join } from 'node:path'
  8. import { performance } from 'node:perf_hooks'
  9. import type { Browser, CDPSession, Locator, Page } from 'playwright'
  10. import { chromium } from 'playwright'
  11. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  12. import type { StreamChunk } from '@deepseek-ai/dsh-llm'
  13. import {
  14. CallId,
  15. createAssistantMessage,
  16. createToolResultMessage,
  17. createUserMessage,
  18. } from '@deepseek-ai/dsh-llm'
  19. import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
  20. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  21. import {
  22. SESSION_FORMAT_VERSION,
  23. Session,
  24. SessionId,
  25. } from '@deepseek-ai/dsh-session'
  26. // Carries the session/title event declaration into the fixture builder.
  27. import type {} from '@deepseek-ai/dsh-session-title'
  28. import {
  29. launchWebScaffold,
  30. seedSession,
  31. watchConsole,
  32. webSnapshotMode,
  33. type WebScaffold,
  34. } from './scaffold.ts'
  35. import { connectFreshWorkspace, newEnglishPage } from './support.ts'
  36. const SIDEBAR_SESSION_COUNT = 1_000
  37. const LONG_SESSION_ID = 'perf-long-history'
  38. const LONG_SESSION_TITLE = 'LONG_PERF_SENTINEL 500-turn session'
  39. const LONG_HISTORY_TURNS = 500
  40. const TOOL_TURN_INTERVAL = 10
  41. const TOOLS_PER_TOOL_TURN = 10
  42. const EXPECTED_TOOL_CALLS = LONG_HISTORY_TURNS / TOOL_TURN_INTERVAL * TOOLS_PER_TOOL_TURN
  43. const EXPECTED_TRAJECTORY_ROWS = 2_100
  44. const DEFAULT_HISTORY_TURNS = 24
  45. const PERF_REPLAY_CONTEXT_WINDOW = 10_000_000
  46. const STREAM_PACE_MS = 8
  47. const STREAM_DELTA_COUNT = 120
  48. const COMPARISON_TURNS = 8
  49. const COMPARISON_DELTA_COUNT = 24
  50. const COMPARISON_TOOL_INTERVAL = 3
  51. const SOAK_TURNS = 100
  52. const POST_SOAK_RENDER_TURN = SOAK_TURNS + 1
  53. const SOAK_DELTA_COUNT = 8
  54. const SOAK_TOOL_INTERVAL = 10
  55. const SOAK_CHECKPOINT_INTERVAL = 10
  56. const LONG_CONTINUATION_USER_PREFIX = 'LONG_CONTINUATION_USER'
  57. const LONG_CONTINUATION_FIRST_PREFIX = 'LONG_CONTINUATION_FIRST'
  58. const LONG_CONTINUATION_DONE_PREFIX = 'LONG_CONTINUATION_DONE'
  59. const SOAK_USER_PREFIX = 'SOAK_CONVERSATION_USER'
  60. const SOAK_FIRST_PREFIX = 'SOAK_CONVERSATION_FIRST'
  61. const SOAK_DONE_PREFIX = 'SOAK_CONVERSATION_DONE'
  62. const LIVE_PROMPT_MARKER = 'STREAM_PERF_USER_INPUT'
  63. const STREAM_FIRST_MARKER = 'STREAM_PERF_FIRST'
  64. const STREAM_DONE_MARKER = 'STREAM_PERF_DONE'
  65. const LIVE_PROMPT = [
  66. LIVE_PROMPT_MARKER,
  67. 'Analyze the following mixed-language project context and return a concise diagnostic.',
  68. ...Array.from(
  69. { length: 48 },
  70. (_, index) =>
  71. `Context ${String(index + 1).padStart(2, '0')}: 用户正在检查长会话中的增量渲染性能。`
  72. + ` Preserve item ${String(index)} and compare ${'payload'.repeat(8)}.`,
  73. ),
  74. '```ts',
  75. ...Array.from(
  76. { length: 40 },
  77. (_, index) => `const sample_${String(index)} = ${JSON.stringify(`value-${String(index)}-${'x'.repeat(32)}`)}`,
  78. ),
  79. '```',
  80. ].join('\n')
  81. const STREAM_DELTAS = Array.from({ length: STREAM_DELTA_COUNT }, (_, index) => {
  82. if (index === 0) return `${STREAM_FIRST_MARKER} `
  83. if (index === STREAM_DELTA_COUNT - 1) return `${STREAM_DONE_MARKER}.`
  84. return `chunk-${String(index).padStart(3, '0')} ${'response'.repeat(3)} `
  85. })
  86. interface ChromiumMetrics {
  87. readonly [name: string]: number
  88. }
  89. interface Measurement {
  90. readonly wallMs: number
  91. readonly taskMs: number
  92. readonly scriptMs: number
  93. readonly layoutMs: number
  94. readonly recalcStyleMs: number
  95. readonly devtoolsMs: number
  96. readonly nodesDelta: number
  97. readonly listenersDelta: number
  98. readonly heapDeltaMb: number
  99. readonly totalNodes: number
  100. readonly heapMb: number
  101. }
  102. interface MutationProbeResult {
  103. readonly batches: number
  104. readonly records: number
  105. }
  106. interface UserRenderProbeResult {
  107. readonly trustedClick: boolean
  108. readonly sendToDomMs: number
  109. readonly sendToPaintMs: number
  110. readonly domToPaintMs: number
  111. readonly mutationBatches: number
  112. readonly mutationRecords: number
  113. }
  114. interface RetainedBrowserState {
  115. readonly domElements: number
  116. readonly nodes: number
  117. readonly listeners: number
  118. readonly heapMb: number
  119. }
  120. interface ContinuedTurnReport {
  121. readonly ordinal: number
  122. readonly resultingTurns: number
  123. readonly kind: 'text' | 'tool'
  124. readonly promptChars: number
  125. readonly composerFill: Measurement
  126. readonly stream: MutationProbeResult & Measurement & {
  127. readonly paceMs: number
  128. readonly deltaChunks: number
  129. readonly persistedChunks: number
  130. readonly toolCalls: number
  131. readonly toolResults: number
  132. readonly clickToUserEchoMs: number
  133. readonly clickToFirstChunkMs: number
  134. readonly firstChunkToSettledMs: number
  135. }
  136. }
  137. interface ConversationTurnSpec {
  138. readonly prompt: string
  139. readonly deltas: readonly string[]
  140. readonly userMarker: string
  141. readonly firstMarker: string
  142. readonly doneMarker: string
  143. readonly toolResultMarker?: string
  144. }
  145. interface RetainedCheckpoint {
  146. readonly turns: number
  147. readonly state: RetainedBrowserState
  148. }
  149. interface ConversationReport {
  150. readonly startingTurns: number
  151. readonly turnsAdded: number
  152. readonly toolTurns: number
  153. readonly retainedBefore: RetainedBrowserState
  154. readonly retainedAfter: RetainedBrowserState
  155. readonly retainedDelta: RetainedBrowserState
  156. readonly checkpoints: readonly RetainedCheckpoint[]
  157. readonly turns: readonly ContinuedTurnReport[]
  158. }
  159. interface PerformanceWorld {
  160. readonly scaffold: WebScaffold
  161. readonly page: Page
  162. readonly tripwire: ReturnType<typeof watchConsole>
  163. readonly sessionEvents: SessionEvent[]
  164. readonly setupMs: number
  165. readonly replayDir?: string
  166. }
  167. interface PerformanceWorldOptions {
  168. readonly browser: Browser
  169. readonly replay?: ReplayOverrideDoc
  170. readonly sidebarSessions?: number
  171. readonly seedLongHistory?: boolean
  172. }
  173. function text(value: string): { type: 'text'; text: string }[] {
  174. return [{ type: 'text', text: value }]
  175. }
  176. function appendTitle(session: Session, title: string, messageSeq: number): void {
  177. session.append('session/title', {
  178. title,
  179. messageSeqs: [messageSeq],
  180. source: { kind: 'fallback' },
  181. })
  182. }
  183. function appendRequestHeader(session: Session, turn: number, step: number): void {
  184. session.append('request/header', {
  185. header: {
  186. config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  187. system: `Synthetic performance system prompt for turn ${String(turn)}, step ${String(step)}.`,
  188. },
  189. reason: turn === 1 && step === 1 ? 'initial' : 'change',
  190. })
  191. }
  192. function appendAssistant(
  193. session: Session,
  194. turn: number,
  195. step: number,
  196. body: string,
  197. ): void {
  198. session.append('assistant/message', {
  199. turn,
  200. step,
  201. message: createAssistantMessage({
  202. content: text(body),
  203. source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  204. }),
  205. usage: {
  206. inputTokens: 4_000 + turn * 10,
  207. outputTokens: 200 + step * 20,
  208. cacheReadTokens: turn % 2 === 0 ? 2_000 : 0,
  209. },
  210. }, { surfaceOp: 'append' })
  211. }
  212. function appendToolStep(
  213. session: Session,
  214. turn: number,
  215. step: number,
  216. toolCount: number,
  217. ): void {
  218. const calls = Array.from({ length: toolCount }, (_, index) => {
  219. const callId = CallId(`perf-call-${String(turn)}-${String(index)}`)
  220. const args = JSON.stringify({
  221. turn,
  222. index,
  223. payload: 'x'.repeat(120),
  224. })
  225. return { callId, index, args }
  226. })
  227. session.append('assistant/message', {
  228. turn,
  229. step,
  230. message: createAssistantMessage({
  231. content: [
  232. {
  233. type: 'reasoning',
  234. text: `Dispatching ${String(toolCount)} synthetic tools for turn ${String(turn)}.`,
  235. },
  236. ...calls.map(({ callId, args }) => ({
  237. type: 'tool-call' as const,
  238. id: callId,
  239. name: 'synthetic_tool',
  240. arguments: args,
  241. })),
  242. ],
  243. source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  244. }),
  245. usage: {
  246. inputTokens: 6_000 + turn * 10,
  247. outputTokens: 500,
  248. cacheReadTokens: 3_000,
  249. reasoningTokens: 50,
  250. },
  251. }, { surfaceOp: 'append' })
  252. const callEvents = calls.map(({ callId, args }) =>
  253. session.append('tool/call', {
  254. turn,
  255. step,
  256. callId,
  257. name: 'synthetic_tool',
  258. arguments: args,
  259. }))
  260. for (const [index, call] of calls.entries()) {
  261. const source = callEvents[index]
  262. if (source === undefined) throw new Error(`missing synthetic tool call ${String(index)}`)
  263. session.append('tool/result', {
  264. turn,
  265. step,
  266. message: createToolResultMessage({
  267. callId: call.callId,
  268. content: text(
  269. `synthetic result turn=${String(turn)} index=${String(call.index)} ${'r'.repeat(400)}`,
  270. ),
  271. isError: false,
  272. }),
  273. }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
  274. }
  275. }
  276. function fencedCode(turn: number): string {
  277. if (turn % 25 !== 0) return ''
  278. const lines = Array.from(
  279. { length: 80 },
  280. (_, index) => `const value_${String(index)} = ${String(turn + index)}`,
  281. )
  282. return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
  283. }
  284. function fixtureLog(session: Session): string {
  285. const header = {
  286. type: 'session',
  287. version: SESSION_FORMAT_VERSION,
  288. id: '{{sessionId}}',
  289. createdAt: Date.now() - 60_000,
  290. cwd: '{{cwd}}',
  291. }
  292. return [
  293. JSON.stringify(header),
  294. ...session.events.map(event => JSON.stringify(event)),
  295. '',
  296. ].join('\n')
  297. }
  298. function smallSidebarFixture(): string {
  299. const session = Session.create(SessionId('perf-small-template'))
  300. session.append('turn/start', {
  301. turn: 1,
  302. trigger: { kind: 'message', source: { kind: 'user' } },
  303. })
  304. const user = session.append('user/message', createUserMessage({
  305. content: text('Inspect this compact synthetic session.'),
  306. source: { kind: 'user' },
  307. }), { surfaceOp: 'append' })
  308. appendTitle(session, 'Synthetic sidebar session', user.seq)
  309. session.append('step/start', { turn: 1, step: 1 })
  310. appendRequestHeader(session, 1, 1)
  311. appendToolStep(session, 1, 1, 2)
  312. session.append('step/end', { turn: 1, step: 1 })
  313. session.append('step/start', { turn: 1, step: 2 })
  314. appendRequestHeader(session, 1, 2)
  315. appendAssistant(session, 1, 2, 'Synthetic sidebar fixture complete.')
  316. session.append('step/end', { turn: 1, step: 2 })
  317. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  318. return fixtureLog(session)
  319. }
  320. function longHistoryFixture(): string {
  321. const session = Session.create(SessionId(LONG_SESSION_ID))
  322. for (let turn = 1; turn <= LONG_HISTORY_TURNS; turn += 1) {
  323. session.append('turn/start', {
  324. turn,
  325. trigger: { kind: 'message', source: { kind: 'user' } },
  326. })
  327. const user = session.append('user/message', createUserMessage({
  328. content: text(
  329. `LONG_PERF_SENTINEL turn ${String(turn)}: analyze payload ${'u'.repeat(200)}`,
  330. ),
  331. source: { kind: 'user' },
  332. }), { surfaceOp: 'append' })
  333. if (turn === 1) appendTitle(session, LONG_SESSION_TITLE, user.seq)
  334. session.append('step/start', { turn, step: 1 })
  335. appendRequestHeader(session, turn, 1)
  336. if (turn % TOOL_TURN_INTERVAL === 0) {
  337. appendToolStep(session, turn, 1, TOOLS_PER_TOOL_TURN)
  338. session.append('step/end', { turn, step: 1 })
  339. session.append('step/start', { turn, step: 2 })
  340. appendRequestHeader(session, turn, 2)
  341. appendAssistant(
  342. session,
  343. turn,
  344. 2,
  345. `All synthetic tools completed for turn ${String(turn)}. ${'z'.repeat(320)}${fencedCode(turn)}`,
  346. )
  347. session.append('step/end', { turn, step: 2 })
  348. } else {
  349. appendAssistant(
  350. session,
  351. turn,
  352. 1,
  353. `Synthetic assistant response for turn ${String(turn)}. ${'a'.repeat(320)}${fencedCode(turn)}`,
  354. )
  355. session.append('step/end', { turn, step: 1 })
  356. }
  357. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  358. }
  359. return fixtureLog(session)
  360. }
  361. function textStream(deltas: readonly string[], inputTokens: number): StreamChunk[] {
  362. const response = deltas.join('')
  363. return [
  364. { type: 'block-start', index: 0, blockType: 'text' },
  365. ...deltas.map(text => ({
  366. type: 'text-delta' as const,
  367. index: 0,
  368. text,
  369. })),
  370. {
  371. type: 'block-end',
  372. index: 0,
  373. block: { type: 'text', text: response },
  374. },
  375. {
  376. type: 'usage',
  377. usage: {
  378. inputTokens,
  379. outputTokens: Math.ceil(response.length / 4),
  380. },
  381. },
  382. { type: 'finish', reason: { kind: 'stop' } },
  383. ]
  384. }
  385. function comparisonPrompt(index: number): string {
  386. if (index === COMPARISON_TURNS) return LIVE_PROMPT
  387. return (`${LONG_CONTINUATION_USER_PREFIX}_${String(index)} `
  388. + `继续分析这个长会话的第 ${String(index)} 个增量问题,并保留当前滚动和输入响应。 `
  389. + 'context '.repeat(80)).trimEnd()
  390. }
  391. function comparisonDeltas(index: number): string[] {
  392. if (index === COMPARISON_TURNS) return STREAM_DELTAS
  393. return Array.from({ length: COMPARISON_DELTA_COUNT }, (_, chunkIndex) => {
  394. if (chunkIndex === 0) return `${LONG_CONTINUATION_FIRST_PREFIX}_${String(index)} `
  395. if (chunkIndex === COMPARISON_DELTA_COUNT - 1) {
  396. return `${LONG_CONTINUATION_DONE_PREFIX}_${String(index)}.`
  397. }
  398. return `turn-${String(index)}-chunk-${String(chunkIndex).padStart(2, '0')} ${'response'.repeat(2)} `
  399. })
  400. }
  401. function comparisonTurn(index: number): ConversationTurnSpec {
  402. const toolResultMarker = index % COMPARISON_TOOL_INTERVAL === 0
  403. ? `LONG_CONTINUATION_TOOL_RESULT_${String(index)}`
  404. : undefined
  405. return {
  406. prompt: comparisonPrompt(index),
  407. deltas: comparisonDeltas(index),
  408. userMarker: index === COMPARISON_TURNS
  409. ? LIVE_PROMPT_MARKER
  410. : `${LONG_CONTINUATION_USER_PREFIX}_${String(index)}`,
  411. firstMarker: index === COMPARISON_TURNS
  412. ? STREAM_FIRST_MARKER
  413. : `${LONG_CONTINUATION_FIRST_PREFIX}_${String(index)}`,
  414. doneMarker: index === COMPARISON_TURNS
  415. ? STREAM_DONE_MARKER
  416. : `${LONG_CONTINUATION_DONE_PREFIX}_${String(index)}`,
  417. ...toolResultMarker === undefined ? {} : { toolResultMarker },
  418. }
  419. }
  420. function soakTurn(index: number): ConversationTurnSpec {
  421. const suffix = String(index).padStart(3, '0')
  422. const toolResultMarker = index % SOAK_TOOL_INTERVAL === 0
  423. ? `SOAK_CONVERSATION_TOOL_RESULT_${suffix}`
  424. : undefined
  425. return {
  426. prompt: (`${SOAK_USER_PREFIX}_${suffix} `
  427. + `持续对话第 ${String(index)} 轮,检查增量渲染与保留状态。 `
  428. + 'context '.repeat(20)).trimEnd(),
  429. deltas: Array.from({ length: SOAK_DELTA_COUNT }, (_, chunkIndex) => {
  430. if (chunkIndex === 0) return `${SOAK_FIRST_PREFIX}_${suffix} `
  431. if (chunkIndex === SOAK_DELTA_COUNT - 1) return `${SOAK_DONE_PREFIX}_${suffix}.`
  432. return `soak-${suffix}-${String(chunkIndex).padStart(2, '0')} response `
  433. }),
  434. userMarker: `${SOAK_USER_PREFIX}_${suffix}`,
  435. firstMarker: `${SOAK_FIRST_PREFIX}_${suffix}`,
  436. doneMarker: `${SOAK_DONE_PREFIX}_${suffix}`,
  437. ...toolResultMarker === undefined ? {} : { toolResultMarker },
  438. }
  439. }
  440. function toolStream(index: number, marker: string): StreamChunk[] {
  441. const callId = CallId(`performance-tool-${marker.toLowerCase()}-${String(index)}`)
  442. const args = JSON.stringify({
  443. command: `printf '${marker}\\n'`,
  444. description: `Emit performance marker ${String(index)}`,
  445. })
  446. return [
  447. { type: 'block-start', index: 0, blockType: 'tool-call' },
  448. {
  449. type: 'tool-call-delta',
  450. index: 0,
  451. id: callId,
  452. name: 'bash',
  453. argumentsDelta: args,
  454. },
  455. {
  456. type: 'block-end',
  457. index: 0,
  458. block: { type: 'tool-call', id: callId, name: 'bash', arguments: args },
  459. },
  460. { type: 'usage', usage: { inputTokens: 256, outputTokens: 32 } },
  461. { type: 'finish', reason: { kind: 'tool-calls' } },
  462. ]
  463. }
  464. function performanceReplayOverride(
  465. turnCount: number,
  466. turnSpec: (index: number) => ConversationTurnSpec,
  467. ): ReplayOverrideDoc {
  468. const continuationEntries = Array.from(
  469. { length: turnCount },
  470. (_, offset): ReplayEntry[] => {
  471. const index = offset + 1
  472. const spec = turnSpec(index)
  473. const finalResponse: ReplayEntry = {
  474. kind: 'chunks',
  475. chunks: textStream(spec.deltas, Math.ceil(spec.prompt.length / 4)),
  476. }
  477. return spec.toolResultMarker === undefined
  478. ? [finalResponse]
  479. : [{ kind: 'chunks', chunks: toolStream(index, spec.toolResultMarker) }, finalResponse]
  480. },
  481. ).flat()
  482. return continuationEntries
  483. }
  484. function rounded(value: number): number {
  485. return Math.round(value * 1_000) / 1_000
  486. }
  487. async function chromiumMetrics(cdp: CDPSession): Promise<ChromiumMetrics> {
  488. const payload = await cdp.send('Performance.getMetrics')
  489. return Object.fromEntries(payload.metrics.map(metric => [metric.name, metric.value]))
  490. }
  491. async function retainedBrowserState(
  492. cdp: CDPSession,
  493. page: Page,
  494. ): Promise<RetainedBrowserState> {
  495. await cdp.send('HeapProfiler.collectGarbage')
  496. const metrics = await chromiumMetrics(cdp)
  497. return {
  498. domElements: await page.evaluate(() => document.querySelectorAll('*').length),
  499. nodes: requiredMetric(metrics, 'Nodes'),
  500. listeners: requiredMetric(metrics, 'JSEventListeners'),
  501. heapMb: rounded(requiredMetric(metrics, 'JSHeapUsedSize') / 1_048_576),
  502. }
  503. }
  504. function requiredMetric(metrics: ChromiumMetrics, name: string): number {
  505. const value = metrics[name]
  506. if (value === undefined) throw new Error(`Chromium performance metric ${name} is unavailable`)
  507. return value
  508. }
  509. function metricDelta(
  510. before: ChromiumMetrics,
  511. after: ChromiumMetrics,
  512. wallMs: number,
  513. ): Measurement {
  514. return {
  515. wallMs: rounded(wallMs),
  516. taskMs: rounded((requiredMetric(after, 'TaskDuration') - requiredMetric(before, 'TaskDuration')) * 1_000),
  517. scriptMs: rounded((requiredMetric(after, 'ScriptDuration') - requiredMetric(before, 'ScriptDuration')) * 1_000),
  518. layoutMs: rounded((requiredMetric(after, 'LayoutDuration') - requiredMetric(before, 'LayoutDuration')) * 1_000),
  519. recalcStyleMs: rounded(
  520. (requiredMetric(after, 'RecalcStyleDuration') - requiredMetric(before, 'RecalcStyleDuration')) * 1_000,
  521. ),
  522. devtoolsMs: rounded(
  523. (requiredMetric(after, 'DevToolsCommandDuration') - requiredMetric(before, 'DevToolsCommandDuration')) * 1_000,
  524. ),
  525. nodesDelta: requiredMetric(after, 'Nodes') - requiredMetric(before, 'Nodes'),
  526. listenersDelta: requiredMetric(after, 'JSEventListeners') - requiredMetric(before, 'JSEventListeners'),
  527. heapDeltaMb: rounded(
  528. (requiredMetric(after, 'JSHeapUsedSize') - requiredMetric(before, 'JSHeapUsedSize')) / 1_048_576,
  529. ),
  530. totalNodes: requiredMetric(after, 'Nodes'),
  531. heapMb: rounded(requiredMetric(after, 'JSHeapUsedSize') / 1_048_576),
  532. }
  533. }
  534. async function measure<T>(
  535. cdp: CDPSession,
  536. action: () => Promise<T>,
  537. ): Promise<{ measurement: Measurement; value: T }> {
  538. const before = await chromiumMetrics(cdp)
  539. const started = performance.now()
  540. const value = await action()
  541. const wallMs = performance.now() - started
  542. const after = await chromiumMetrics(cdp)
  543. return { measurement: metricDelta(before, after, wallMs), value }
  544. }
  545. async function startMutationProbe(page: Page): Promise<void> {
  546. await page.evaluate(() => {
  547. const target = document.querySelector('[class*="centerCol"]')
  548. if (target === null) throw new Error('stream mutation probe target is unavailable')
  549. const probe = {
  550. batches: 0,
  551. records: 0,
  552. observer: undefined as MutationObserver | undefined,
  553. }
  554. const observer = new MutationObserver((records) => {
  555. probe.batches += 1
  556. probe.records += records.length
  557. })
  558. probe.observer = observer
  559. observer.observe(target, {
  560. attributes: true,
  561. attributeFilter: ['data-streaming'],
  562. characterData: true,
  563. childList: true,
  564. subtree: true,
  565. })
  566. Reflect.set(globalThis, '__dshPerfMutationProbe', probe)
  567. })
  568. }
  569. async function stopMutationProbe(page: Page): Promise<MutationProbeResult> {
  570. return page.evaluate(() => {
  571. const probe = Reflect.get(globalThis, '__dshPerfMutationProbe') as
  572. | { batches: number; records: number; observer: MutationObserver }
  573. | undefined
  574. if (probe === undefined) throw new Error('stream mutation probe was not started')
  575. probe.observer.disconnect()
  576. Reflect.deleteProperty(globalThis, '__dshPerfMutationProbe')
  577. return { batches: probe.batches, records: probe.records }
  578. })
  579. }
  580. async function startUserRenderProbe(
  581. page: Page,
  582. marker: string,
  583. ): Promise<void> {
  584. const send = page.getByRole('button', { name: 'Send message', exact: true })
  585. await send.waitFor({ timeout: 15_000 })
  586. await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true)
  587. await page.evaluate((expectedMarker) => {
  588. const target = document.querySelector('[data-conversation-scroll]')
  589. if (target === null) throw new Error('user render probe target is unavailable')
  590. const probe: {
  591. sendAt?: number
  592. domAt?: number
  593. paintAt?: number
  594. trustedClick?: boolean
  595. batches: number
  596. records: number
  597. observer?: MutationObserver
  598. pollForMarker?: () => void
  599. } = { batches: 0, records: 0 }
  600. const transcriptContainsMarker = (): boolean => {
  601. // The composer projects the draft into a backdrop and hidden sizing
  602. // mirror; only a matching text node outside that card proves delivery.
  603. const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT)
  604. for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
  605. if (!node.textContent?.includes(expectedMarker)) continue
  606. const parent = node.parentElement
  607. if (parent !== null && parent.closest('[data-composer-card]') === null) return true
  608. }
  609. return false
  610. }
  611. const markDomVisible = (): void => {
  612. if (
  613. probe.sendAt === undefined
  614. || probe.domAt !== undefined
  615. || !transcriptContainsMarker()
  616. ) return
  617. probe.domAt = globalThis.performance.now()
  618. // The second callback runs only after a render opportunity for the DOM
  619. // insertion observed before the first callback.
  620. requestAnimationFrame(() => {
  621. requestAnimationFrame(() => {
  622. probe.paintAt = globalThis.performance.now()
  623. observer.disconnect()
  624. })
  625. })
  626. }
  627. const pollForMarker = (): void => {
  628. markDomVisible()
  629. if (probe.domAt === undefined) requestAnimationFrame(pollForMarker)
  630. }
  631. const observer = new MutationObserver((records) => {
  632. if (probe.sendAt === undefined) return
  633. probe.batches += 1
  634. probe.records += records.length
  635. markDomVisible()
  636. })
  637. probe.observer = observer
  638. probe.pollForMarker = pollForMarker
  639. observer.observe(target, {
  640. attributes: true,
  641. attributeFilter: ['data-streaming'],
  642. characterData: true,
  643. childList: true,
  644. subtree: true,
  645. })
  646. Reflect.set(globalThis, '__dshPerfUserRenderProbe', probe)
  647. }, marker)
  648. await send.evaluate((button) => {
  649. button.addEventListener('click', (event) => {
  650. const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as
  651. | { sendAt?: number; trustedClick?: boolean; pollForMarker?: () => void }
  652. | undefined
  653. if (probe?.pollForMarker === undefined) throw new Error('user render probe was not started')
  654. probe.trustedClick = event.isTrusted
  655. probe.sendAt = globalThis.performance.now()
  656. requestAnimationFrame(probe.pollForMarker)
  657. }, { capture: true, once: true })
  658. })
  659. }
  660. async function triggerUserRenderProbe(page: Page): Promise<void> {
  661. const send = page.getByRole('button', { name: 'Send message', exact: true })
  662. await send.click()
  663. }
  664. async function stopUserRenderProbe(
  665. page: Page,
  666. marker: string,
  667. ): Promise<UserRenderProbeResult> {
  668. try {
  669. await page.waitForFunction(() => {
  670. const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as
  671. | { paintAt?: number }
  672. | undefined
  673. return probe?.paintAt !== undefined
  674. }, undefined, { timeout: 15_000 })
  675. } catch (error) {
  676. const diagnostic = await page.evaluate((expectedMarker) => {
  677. const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as
  678. | {
  679. sendAt?: number
  680. domAt?: number
  681. paintAt?: number
  682. trustedClick?: boolean
  683. batches: number
  684. records: number
  685. }
  686. | undefined
  687. const target = document.querySelector('[data-conversation-scroll]')
  688. let markerOutsideComposer = false
  689. if (target !== null) {
  690. const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT)
  691. for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
  692. if (!node.textContent?.includes(expectedMarker)) continue
  693. const parent = node.parentElement
  694. if (parent !== null && parent.closest('[data-composer-card]') === null) {
  695. markerOutsideComposer = true
  696. break
  697. }
  698. }
  699. }
  700. return {
  701. probe,
  702. markerOutsideComposer,
  703. markerInComposer: document.querySelector('[data-composer-card]')
  704. ?.textContent?.includes(expectedMarker) ?? false,
  705. }
  706. }, marker)
  707. throw new Error(`user render probe timed out: ${JSON.stringify(diagnostic)}`, { cause: error })
  708. }
  709. return page.evaluate(() => {
  710. const probe = Reflect.get(globalThis, '__dshPerfUserRenderProbe') as
  711. | {
  712. sendAt?: number
  713. domAt?: number
  714. paintAt?: number
  715. trustedClick?: boolean
  716. batches: number
  717. records: number
  718. }
  719. | undefined
  720. Reflect.deleteProperty(globalThis, '__dshPerfUserRenderProbe')
  721. if (
  722. probe?.trustedClick !== true
  723. || probe.sendAt === undefined
  724. || probe.domAt === undefined
  725. || probe.paintAt === undefined
  726. ) {
  727. throw new Error('user render probe did not observe a trusted click, DOM insertion, and paint')
  728. }
  729. return {
  730. trustedClick: probe.trustedClick,
  731. sendToDomMs: probe.domAt - probe.sendAt,
  732. sendToPaintMs: probe.paintAt - probe.sendAt,
  733. domToPaintMs: probe.paintAt - probe.domAt,
  734. mutationBatches: probe.batches,
  735. mutationRecords: probe.records,
  736. }
  737. })
  738. }
  739. async function stableCount(
  740. locator: Locator,
  741. accepts: (count: number) => boolean,
  742. timeoutMs = 60_000,
  743. ): Promise<number> {
  744. const deadline = performance.now() + timeoutMs
  745. let previous = -1
  746. let stableReads = 0
  747. while (performance.now() < deadline) {
  748. const count = await locator.count()
  749. stableReads = accepts(count) && count === previous ? stableReads + 1 : 0
  750. if (stableReads >= 4) return count
  751. previous = count
  752. await new Promise(resolve => setTimeout(resolve, 50))
  753. }
  754. throw new Error(`browser row count did not stabilize; last count ${String(previous)}`)
  755. }
  756. async function conversationTurns(page: Page): Promise<number> {
  757. const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
  758. await stats.waitFor({ timeout: 15_000 })
  759. const value = await stats.textContent()
  760. const match = value?.match(/^(\d+) turns · \d+ steps$/)
  761. if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
  762. return Number(match[1])
  763. }
  764. function retainedDelta(
  765. before: RetainedBrowserState,
  766. after: RetainedBrowserState,
  767. ): RetainedBrowserState {
  768. return {
  769. domElements: after.domElements - before.domElements,
  770. nodes: after.nodes - before.nodes,
  771. listeners: after.listeners - before.listeners,
  772. heapMb: rounded(after.heapMb - before.heapMb),
  773. }
  774. }
  775. async function launchPerformanceWorld(
  776. options: PerformanceWorldOptions,
  777. ): Promise<PerformanceWorld> {
  778. const setupStarted = performance.now()
  779. let replayDir: string | undefined
  780. let scaffold: WebScaffold | undefined
  781. let page: Page | undefined
  782. try {
  783. if (options.replay === undefined) {
  784. scaffold = await launchWebScaffold()
  785. } else {
  786. replayDir = await mkdtemp(join(tmpdir(), 'dsh-web-perf-replay-'))
  787. const replayOverride = join(replayDir, 'replay.override.json')
  788. await writeFile(replayOverride, JSON.stringify(options.replay))
  789. scaffold = await launchWebScaffold({
  790. // The override-only fixture supplies one positional script for this
  791. // world's single live session.
  792. replayFixture: join(replayDir, 'override-only.jsonl'),
  793. replayOverride,
  794. paceMs: STREAM_PACE_MS,
  795. replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW,
  796. })
  797. }
  798. const sessionEvents: SessionEvent[] = []
  799. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
  800. sessionEvents.push(event)
  801. })
  802. if ((options.sidebarSessions ?? 0) > 0) {
  803. const small = smallSidebarFixture()
  804. for (let index = 0; index < (options.sidebarSessions ?? 0); index += 1) {
  805. await seedSession(scaffold, small, `perf-sidebar-${String(index).padStart(4, '0')}`)
  806. }
  807. }
  808. if (options.seedLongHistory === true) {
  809. await seedSession(scaffold, longHistoryFixture(), LONG_SESSION_ID)
  810. }
  811. const setupMs = performance.now() - setupStarted
  812. page = await newEnglishPage(options.browser)
  813. return {
  814. scaffold,
  815. page,
  816. tripwire: watchConsole(page),
  817. sessionEvents,
  818. setupMs,
  819. ...replayDir === undefined ? {} : { replayDir },
  820. }
  821. } catch (error) {
  822. const failures: unknown[] = [error]
  823. if (page !== undefined) {
  824. try {
  825. await page.close()
  826. } catch (cleanupError) {
  827. failures.push(cleanupError)
  828. }
  829. }
  830. if (scaffold !== undefined) {
  831. try {
  832. await scaffold.close()
  833. } catch (cleanupError) {
  834. failures.push(cleanupError)
  835. }
  836. }
  837. if (replayDir !== undefined) {
  838. try {
  839. await rm(replayDir, { recursive: true, force: true })
  840. } catch (cleanupError) {
  841. failures.push(cleanupError)
  842. }
  843. }
  844. if (failures.length === 1) throw error
  845. throw new AggregateError(failures, 'web performance setup and cleanup failed')
  846. }
  847. }
  848. async function closePerformanceWorld(world: PerformanceWorld): Promise<void> {
  849. const failures: unknown[] = []
  850. await world.page.close().catch((error: unknown) => failures.push(error))
  851. await world.scaffold.close().catch((error: unknown) => failures.push(error))
  852. if (world.replayDir !== undefined) {
  853. await rm(world.replayDir, { recursive: true, force: true })
  854. .catch((error: unknown) => failures.push(error))
  855. }
  856. if (failures.length === 1) throw failures[0]
  857. if (failures.length > 1) throw new AggregateError(failures, 'web performance teardown failed')
  858. }
  859. async function openPerformancePage(
  860. world: PerformanceWorld,
  861. expectedSessions: number,
  862. ): Promise<Locator> {
  863. await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' })
  864. await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  865. const group = world.page.getByRole('treeitem').first()
  866. await expect.poll(() => group.textContent(), { timeout: 30_000 })
  867. .toContain(`${String(expectedSessions)} ${expectedSessions === 1 ? 'session' : 'sessions'}`)
  868. return group
  869. }
  870. async function openLongHistory(page: Page): Promise<number> {
  871. await page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
  872. .fill('LONG_PERF_SENTINEL')
  873. const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
  874. await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
  875. await results.first().click()
  876. await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
  877. return conversationTurns(page)
  878. }
  879. async function continueConversation(
  880. world: PerformanceWorld,
  881. cdp: CDPSession,
  882. options: {
  883. readonly startingTurns: number
  884. readonly turnCount: number
  885. readonly turnSpec: (index: number) => ConversationTurnSpec
  886. readonly expectedSessionId?: SessionId
  887. readonly checkpointInterval?: number
  888. },
  889. ): Promise<ConversationReport> {
  890. const composer = world.page.locator('textarea:enabled').last()
  891. await composer.waitFor({ timeout: 15_000 })
  892. const retainedBefore = await retainedBrowserState(cdp, world.page)
  893. const checkpoints: RetainedCheckpoint[] = [{ turns: options.startingTurns, state: retainedBefore }]
  894. const turns: ContinuedTurnReport[] = []
  895. let liveSessionId = options.expectedSessionId
  896. for (let index = 1; index <= options.turnCount; index += 1) {
  897. const spec = options.turnSpec(index)
  898. const composerFill = await measure(cdp, async () => {
  899. await composer.fill(spec.prompt)
  900. await expect.poll(() => composer.inputValue()).toBe(spec.prompt)
  901. return (await composer.inputValue()).length
  902. })
  903. expect(composerFill.value).toBe(spec.prompt.length)
  904. const eventStart = world.sessionEvents.length
  905. await startMutationProbe(world.page)
  906. const streamBefore = await chromiumMetrics(cdp)
  907. const streamStarted = performance.now()
  908. const settled = world.scaffold.whenTurnSettled(60_000)
  909. await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
  910. await world.page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  911. const clickToUserEchoMs = performance.now() - streamStarted
  912. await world.page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  913. const clickToFirstChunkMs = performance.now() - streamStarted
  914. const settledSessionId = await settled
  915. if (liveSessionId === undefined) {
  916. liveSessionId = settledSessionId
  917. } else {
  918. expect(settledSessionId).toBe(liveSessionId)
  919. }
  920. await expect.poll(
  921. () => world.page.locator('[data-streaming="true"]').count(),
  922. { timeout: 15_000 },
  923. ).toBe(0)
  924. await world.page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  925. const clickToSettledMs = performance.now() - streamStarted
  926. const streamAfter = await chromiumMetrics(cdp)
  927. const mutations = await stopMutationProbe(world.page)
  928. const turnEvents = world.sessionEvents.slice(eventStart)
  929. const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
  930. const toolCalls = turnEvents.filter(event => event.type === 'tool/call')
  931. const toolResults = turnEvents.filter(event => event.type === 'tool/result')
  932. const toolTurn = spec.toolResultMarker !== undefined
  933. expect(chunks).toHaveLength(spec.deltas.length + (toolTurn ? 9 : 4))
  934. expect(toolCalls).toHaveLength(toolTurn ? 1 : 0)
  935. expect(toolResults).toHaveLength(toolTurn ? 1 : 0)
  936. if (spec.toolResultMarker !== undefined) {
  937. expect(toolCalls[0]?.data.name).toBe('bash')
  938. const toolResult = toolResults[0]
  939. if (toolResult?.type !== 'tool/result') {
  940. throw new Error(`continued turn ${String(index)} did not log its tool result`)
  941. }
  942. const resultBlock = toolResult.data.message.content.find(block => block.type === 'tool-result')
  943. expect(resultBlock?.isError).toBe(false)
  944. expect(resultBlock?.content
  945. .filter(block => block.type === 'text')
  946. .map(block => block.text)
  947. .join('')).toContain(spec.toolResultMarker)
  948. }
  949. const user = turnEvents.find(
  950. event => event.type === 'user/message' && event.data.source.kind === 'user',
  951. )
  952. if (user?.type !== 'user/message') {
  953. throw new Error(`continued turn ${String(index)} did not log its user message`)
  954. }
  955. expect(user.data.content
  956. .filter(block => block.type === 'text')
  957. .map(block => block.text)
  958. .join('')).toBe(spec.prompt)
  959. const resultingTurns = await conversationTurns(world.page)
  960. expect(resultingTurns).toBe(options.startingTurns + index)
  961. turns.push({
  962. ordinal: index,
  963. resultingTurns,
  964. kind: toolTurn ? 'tool' : 'text',
  965. promptChars: spec.prompt.length,
  966. composerFill: composerFill.measurement,
  967. stream: {
  968. paceMs: STREAM_PACE_MS,
  969. deltaChunks: spec.deltas.length,
  970. persistedChunks: chunks.length,
  971. toolCalls: toolCalls.length,
  972. toolResults: toolResults.length,
  973. clickToUserEchoMs: rounded(clickToUserEchoMs),
  974. clickToFirstChunkMs: rounded(clickToFirstChunkMs),
  975. firstChunkToSettledMs: rounded(clickToSettledMs - clickToFirstChunkMs),
  976. ...mutations,
  977. ...metricDelta(streamBefore, streamAfter, clickToSettledMs),
  978. },
  979. })
  980. if (options.checkpointInterval !== undefined && index % options.checkpointInterval === 0) {
  981. checkpoints.push({
  982. turns: options.startingTurns + index,
  983. state: await retainedBrowserState(cdp, world.page),
  984. })
  985. }
  986. }
  987. const lastCheckpoint = checkpoints.at(-1)
  988. const retainedAfter = lastCheckpoint?.turns === options.startingTurns + options.turnCount
  989. ? lastCheckpoint.state
  990. : await retainedBrowserState(cdp, world.page)
  991. if (lastCheckpoint?.turns !== options.startingTurns + options.turnCount) {
  992. checkpoints.push({ turns: options.startingTurns + options.turnCount, state: retainedAfter })
  993. }
  994. return {
  995. startingTurns: options.startingTurns,
  996. turnsAdded: options.turnCount,
  997. toolTurns: turns.filter(turn => turn.kind === 'tool').length,
  998. retainedBefore,
  999. retainedAfter,
  1000. retainedDelta: retainedDelta(retainedBefore, retainedAfter),
  1001. checkpoints,
  1002. turns,
  1003. }
  1004. }
  1005. async function measurePostSoakUserRender(
  1006. world: PerformanceWorld,
  1007. cdp: CDPSession,
  1008. ): Promise<object> {
  1009. const spec = soakTurn(POST_SOAK_RENDER_TURN)
  1010. if (spec.toolResultMarker !== undefined) {
  1011. throw new Error('post-soak render probe must remain a text-only turn')
  1012. }
  1013. const composer = world.page.locator('textarea:enabled').last()
  1014. const composerFill = await measure(cdp, async () => {
  1015. await composer.fill(spec.prompt)
  1016. await expect.poll(() => composer.inputValue()).toBe(spec.prompt)
  1017. return (await composer.inputValue()).length
  1018. })
  1019. expect(composerFill.value).toBe(spec.prompt.length)
  1020. const eventStart = world.sessionEvents.length
  1021. await startUserRenderProbe(world.page, spec.userMarker)
  1022. const browserBefore = await chromiumMetrics(cdp)
  1023. const fullTurnStarted = performance.now()
  1024. const settled = world.scaffold.whenTurnSettled(60_000)
  1025. await triggerUserRenderProbe(world.page)
  1026. const browserTiming = await stopUserRenderProbe(world.page, spec.userMarker)
  1027. const browserAfter = await chromiumMetrics(cdp)
  1028. const browserAfterPaintSample = metricDelta(
  1029. browserBefore,
  1030. browserAfter,
  1031. performance.now() - fullTurnStarted,
  1032. )
  1033. await world.page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  1034. const settledSessionId = await settled
  1035. await expect.poll(
  1036. () => world.page.locator('[data-streaming="true"]').count(),
  1037. { timeout: 15_000 },
  1038. ).toBe(0)
  1039. await world.page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  1040. const fullTurnMs = performance.now() - fullTurnStarted
  1041. const turnEvents = world.sessionEvents.slice(eventStart)
  1042. const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
  1043. const user = turnEvents.find(
  1044. event => event.type === 'user/message' && event.data.source.kind === 'user',
  1045. )
  1046. if (user?.type !== 'user/message') throw new Error('post-soak render probe did not log its user message')
  1047. expect(user.data.content
  1048. .filter(block => block.type === 'text')
  1049. .map(block => block.text)
  1050. .join('')).toBe(spec.prompt)
  1051. expect(chunks).toHaveLength(spec.deltas.length + 4)
  1052. expect(turnEvents.filter(event => event.type === 'tool/call')).toHaveLength(0)
  1053. expect(turnEvents.filter(event => event.type === 'tool/result')).toHaveLength(0)
  1054. expect(world.scaffold.ctx.agents.get(settledSessionId)).toBeDefined()
  1055. expect(await conversationTurns(world.page)).toBe(POST_SOAK_RENDER_TURN)
  1056. return {
  1057. ordinal: POST_SOAK_RENDER_TURN,
  1058. resultingTurns: POST_SOAK_RENDER_TURN,
  1059. promptChars: spec.prompt.length,
  1060. composerFill: composerFill.measurement,
  1061. trustedClick: browserTiming.trustedClick,
  1062. sendToDomMs: rounded(browserTiming.sendToDomMs),
  1063. sendToPaintMs: rounded(browserTiming.sendToPaintMs),
  1064. domToPaintMs: rounded(browserTiming.domToPaintMs),
  1065. mutationBatchesThroughPaint: browserTiming.mutationBatches,
  1066. mutationRecordsThroughPaint: browserTiming.mutationRecords,
  1067. browserAfterPaintSample,
  1068. fullTurnMs: rounded(fullTurnMs),
  1069. persistedChunks: chunks.length,
  1070. }
  1071. }
  1072. function average(values: readonly number[]): number {
  1073. return rounded(values.reduce((sum, value) => sum + value, 0) / values.length)
  1074. }
  1075. function p95(values: readonly number[]): number {
  1076. const sorted = [...values].sort((left, right) => left - right)
  1077. return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)] ?? 0
  1078. }
  1079. function summarizeTurnWindows(
  1080. turns: readonly ContinuedTurnReport[],
  1081. windowSize: number,
  1082. ): object[] {
  1083. const windows: object[] = []
  1084. for (let start = 0; start < turns.length; start += windowSize) {
  1085. const window = turns.slice(start, start + windowSize)
  1086. const streamWall = window.map(turn => turn.stream.wallMs)
  1087. const userEcho = window.map(turn => turn.stream.clickToUserEchoMs)
  1088. const firstChunk = window.map(turn => turn.stream.clickToFirstChunkMs)
  1089. windows.push({
  1090. turns: `${String(start + 1)}-${String(start + window.length)}`,
  1091. toolTurns: window.filter(turn => turn.kind === 'tool').length,
  1092. average: {
  1093. composerFillWallMs: average(window.map(turn => turn.composerFill.wallMs)),
  1094. streamWallMs: average(streamWall),
  1095. clickToUserEchoMs: average(userEcho),
  1096. clickToFirstChunkMs: average(firstChunk),
  1097. taskMs: average(window.map(turn => turn.stream.taskMs)),
  1098. scriptMs: average(window.map(turn => turn.stream.scriptMs)),
  1099. recalcStyleMs: average(window.map(turn => turn.stream.recalcStyleMs)),
  1100. mutationBatches: average(window.map(turn => turn.stream.batches)),
  1101. },
  1102. p95: {
  1103. streamWallMs: rounded(p95(streamWall)),
  1104. clickToUserEchoMs: rounded(p95(userEcho)),
  1105. clickToFirstChunkMs: rounded(p95(firstChunk)),
  1106. },
  1107. })
  1108. }
  1109. return windows
  1110. }
  1111. describe('manual web performance: complex workspace and history', () => {
  1112. let browser: Browser
  1113. beforeAll(async () => {
  1114. if (webSnapshotMode() === 'record') {
  1115. throw new Error('manual web performance runs only with deterministic replay')
  1116. }
  1117. browser = await chromium.launch()
  1118. })
  1119. afterAll(async () => {
  1120. await browser?.close()
  1121. })
  1122. it('reports workspace, history, and trajectory cardinality costs', async () => {
  1123. const world = await launchPerformanceWorld({
  1124. browser,
  1125. sidebarSessions: SIDEBAR_SESSION_COUNT,
  1126. seedLongHistory: true,
  1127. })
  1128. try {
  1129. const bootStarted = performance.now()
  1130. const group = await openPerformancePage(world, SIDEBAR_SESSION_COUNT + 1)
  1131. const bootReadyMs = performance.now() - bootStarted
  1132. const page = world.page
  1133. const cdp = await page.context().newCDPSession(page)
  1134. await cdp.send('Performance.enable')
  1135. const firstContentfulPaintMs = await page.evaluate(
  1136. () => globalThis.performance.getEntriesByName('first-contentful-paint')[0]?.startTime,
  1137. )
  1138. const sidebar = await measure(cdp, async () => {
  1139. await group.click()
  1140. return stableCount(
  1141. page.getByRole('treeitem'),
  1142. count => count === SIDEBAR_SESSION_COUNT + 2,
  1143. )
  1144. })
  1145. expect(sidebar.value).toBe(SIDEBAR_SESSION_COUNT + 2)
  1146. await group.click()
  1147. await expect.poll(() => page.getByRole('treeitem').count()).toBe(1)
  1148. const contentSearch = await measure(cdp, async () => {
  1149. await page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
  1150. .fill('LONG_PERF_SENTINEL')
  1151. const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
  1152. await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
  1153. await results.first().waitFor({ timeout: 60_000 })
  1154. return results.first()
  1155. })
  1156. const opened = await measure(cdp, async () => {
  1157. await contentSearch.value.click()
  1158. await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 })
  1159. return conversationTurns(page)
  1160. })
  1161. expect(opened.value).toBe(DEFAULT_HISTORY_TURNS)
  1162. const trajectoryRows = page.getByRole('row')
  1163. const coldTrajectory = await measure(cdp, async () => {
  1164. await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
  1165. return stableCount(trajectoryRows, count => count === EXPECTED_TRAJECTORY_ROWS)
  1166. })
  1167. expect(coldTrajectory.value).toBe(EXPECTED_TRAJECTORY_ROWS)
  1168. const collapseTurns = await measure(cdp, async () => {
  1169. await page.getByRole('button', { name: 'Collapse turns', exact: true }).click()
  1170. return stableCount(trajectoryRows, count => count > 0 && count < EXPECTED_TRAJECTORY_ROWS)
  1171. })
  1172. expect(collapseTurns.value).toBeLessThan(EXPECTED_TRAJECTORY_ROWS)
  1173. const trajectorySearch = await measure(cdp, async () => {
  1174. await page.getByRole('searchbox', { name: 'Search trajectory', exact: true }).fill('turn 499')
  1175. return stableCount(trajectoryRows, count => count > 0 && count < 20)
  1176. })
  1177. expect(trajectorySearch.value).toBeLessThan(20)
  1178. await page.getByRole('tab', { name: 'Chat', exact: true }).click()
  1179. const historyPages: { turns: number; measurement: Measurement }[] = []
  1180. let turns = await conversationTurns(page)
  1181. while (turns < LONG_HISTORY_TURNS) {
  1182. const previousTurns = turns
  1183. const older = await measure(cdp, async () => {
  1184. await page.getByRole('button', { name: 'Load earlier', exact: true }).click()
  1185. await expect.poll(() => conversationTurns(page), { timeout: 30_000 })
  1186. .toBeGreaterThan(previousTurns)
  1187. return conversationTurns(page)
  1188. })
  1189. turns = older.value
  1190. historyPages.push({ turns, measurement: older.measurement })
  1191. }
  1192. const warmTrajectory = await measure(cdp, async () => {
  1193. await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
  1194. return stableCount(trajectoryRows, count => count === EXPECTED_TRAJECTORY_ROWS)
  1195. })
  1196. expect(warmTrajectory.value).toBe(EXPECTED_TRAJECTORY_ROWS)
  1197. const warmConversation = await measure(cdp, async () => {
  1198. await page.getByRole('tab', { name: 'Chat', exact: true }).click()
  1199. return conversationTurns(page)
  1200. })
  1201. expect(warmConversation.value).toBe(LONG_HISTORY_TURNS)
  1202. console.info(`WEB_PERF_RESULT ${JSON.stringify({
  1203. scenario: 'workspace-history-trajectory',
  1204. fixture: {
  1205. sidebarSessions: SIDEBAR_SESSION_COUNT,
  1206. totalSessions: SIDEBAR_SESSION_COUNT + 1,
  1207. longHistoryTurns: LONG_HISTORY_TURNS,
  1208. toolCalls: EXPECTED_TOOL_CALLS,
  1209. trajectoryRows: EXPECTED_TRAJECTORY_ROWS,
  1210. },
  1211. setupMs: rounded(world.setupMs),
  1212. boot: {
  1213. readyMs: rounded(bootReadyMs),
  1214. firstContentfulPaintMs: firstContentfulPaintMs === undefined
  1215. ? null
  1216. : rounded(firstContentfulPaintMs),
  1217. },
  1218. sidebarExpand: sidebar.measurement,
  1219. contentSearch: contentSearch.measurement,
  1220. openLongHistory: { initialTurns: opened.value, ...opened.measurement },
  1221. coldTrajectory: { rows: coldTrajectory.value, ...coldTrajectory.measurement },
  1222. collapseTurns: { rows: collapseTurns.value, ...collapseTurns.measurement },
  1223. trajectorySearch: { rows: trajectorySearch.value, ...trajectorySearch.measurement },
  1224. historyPages,
  1225. warmTrajectory: { rows: warmTrajectory.value, ...warmTrajectory.measurement },
  1226. warmConversation: { turns: warmConversation.value, ...warmConversation.measurement },
  1227. }, null, 2)}`)
  1228. expect(world.tripwire.warnings).toEqual([])
  1229. expect(world.tripwire.pageErrors).toEqual([])
  1230. } finally {
  1231. await closePerformanceWorld(world)
  1232. }
  1233. })
  1234. it('reports default 24-turn history plus eight continued turns', async () => {
  1235. const world = await launchPerformanceWorld({
  1236. browser,
  1237. replay: performanceReplayOverride(COMPARISON_TURNS, comparisonTurn),
  1238. seedLongHistory: true,
  1239. })
  1240. try {
  1241. await openPerformancePage(world, 1)
  1242. const cdp = await world.page.context().newCDPSession(world.page)
  1243. await cdp.send('Performance.enable')
  1244. const opened = await measure(cdp, () => openLongHistory(world.page))
  1245. expect(opened.value).toBe(DEFAULT_HISTORY_TURNS)
  1246. const conversation = await continueConversation(world, cdp, {
  1247. startingTurns: DEFAULT_HISTORY_TURNS,
  1248. turnCount: COMPARISON_TURNS,
  1249. turnSpec: comparisonTurn,
  1250. expectedSessionId: SessionId(LONG_SESSION_ID),
  1251. })
  1252. expect(conversation.toolTurns).toBe(2)
  1253. console.info(`WEB_PERF_RESULT ${JSON.stringify({
  1254. scenario: 'default-resume-24-plus-8',
  1255. setupMs: rounded(world.setupMs),
  1256. replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW,
  1257. openLongHistory: { turns: opened.value, ...opened.measurement },
  1258. conversation,
  1259. }, null, 2)}`)
  1260. expect(world.tripwire.warnings).toEqual([])
  1261. expect(world.tripwire.pageErrors).toEqual([])
  1262. } finally {
  1263. await closePerformanceWorld(world)
  1264. }
  1265. })
  1266. it('reports fully expanded 500-turn history plus eight continued turns', async () => {
  1267. const world = await launchPerformanceWorld({
  1268. browser,
  1269. replay: performanceReplayOverride(COMPARISON_TURNS, comparisonTurn),
  1270. seedLongHistory: true,
  1271. })
  1272. try {
  1273. await openPerformancePage(world, 1)
  1274. const cdp = await world.page.context().newCDPSession(world.page)
  1275. await cdp.send('Performance.enable')
  1276. expect(await openLongHistory(world.page)).toBe(DEFAULT_HISTORY_TURNS)
  1277. const historyPages: { turns: number; measurement: Measurement }[] = []
  1278. let turns = DEFAULT_HISTORY_TURNS
  1279. while (turns < LONG_HISTORY_TURNS) {
  1280. const previousTurns = turns
  1281. const older = await measure(cdp, async () => {
  1282. await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
  1283. await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 })
  1284. .toBeGreaterThan(previousTurns)
  1285. return conversationTurns(world.page)
  1286. })
  1287. turns = older.value
  1288. historyPages.push({ turns, measurement: older.measurement })
  1289. }
  1290. expect(turns).toBe(LONG_HISTORY_TURNS)
  1291. const conversation = await continueConversation(world, cdp, {
  1292. startingTurns: LONG_HISTORY_TURNS,
  1293. turnCount: COMPARISON_TURNS,
  1294. turnSpec: comparisonTurn,
  1295. expectedSessionId: SessionId(LONG_SESSION_ID),
  1296. })
  1297. expect(conversation.toolTurns).toBe(2)
  1298. console.info(`WEB_PERF_RESULT ${JSON.stringify({
  1299. scenario: 'expanded-history-500-plus-8',
  1300. setupMs: rounded(world.setupMs),
  1301. replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW,
  1302. historyPages,
  1303. conversation,
  1304. }, null, 2)}`)
  1305. expect(world.tripwire.warnings).toEqual([])
  1306. expect(world.tripwire.pageErrors).toEqual([])
  1307. } finally {
  1308. await closePerformanceWorld(world)
  1309. }
  1310. })
  1311. it('reports one hundred generated turns and the next user-message paint', async () => {
  1312. const world = await launchPerformanceWorld({
  1313. browser,
  1314. replay: performanceReplayOverride(POST_SOAK_RENDER_TURN, soakTurn),
  1315. })
  1316. let testFailure: unknown
  1317. try {
  1318. await world.page.goto(world.scaffold.baseUrl, { waitUntil: 'load' })
  1319. await world.page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  1320. await connectFreshWorkspace(world.page, world.scaffold.workspaceCwd, 'continuous-conversation-perf')
  1321. const cdp = await world.page.context().newCDPSession(world.page)
  1322. await cdp.send('Performance.enable')
  1323. const conversation = await continueConversation(world, cdp, {
  1324. startingTurns: 0,
  1325. turnCount: SOAK_TURNS,
  1326. turnSpec: soakTurn,
  1327. checkpointInterval: SOAK_CHECKPOINT_INTERVAL,
  1328. })
  1329. expect(conversation.toolTurns).toBe(SOAK_TURNS / SOAK_TOOL_INTERVAL)
  1330. const postSoakUserRender = await measurePostSoakUserRender(world, cdp)
  1331. const { turns, ...retained } = conversation
  1332. console.info(`WEB_PERF_RESULT ${JSON.stringify({
  1333. scenario: 'continuous-100-turn-soak',
  1334. setupMs: rounded(world.setupMs),
  1335. replayContextWindow: PERF_REPLAY_CONTEXT_WINDOW,
  1336. ...retained,
  1337. windows: summarizeTurnWindows(turns, SOAK_CHECKPOINT_INTERVAL),
  1338. postSoakUserRender,
  1339. }, null, 2)}`)
  1340. expect(world.tripwire.warnings).toEqual([])
  1341. expect(world.tripwire.pageErrors).toEqual([])
  1342. } catch (error) {
  1343. testFailure = error
  1344. throw error
  1345. } finally {
  1346. try {
  1347. await closePerformanceWorld(world)
  1348. } catch (cleanupError) {
  1349. if (testFailure === undefined) throw cleanupError
  1350. throw new AggregateError(
  1351. [testFailure, cleanupError],
  1352. 'continuous conversation performance test and teardown failed',
  1353. )
  1354. }
  1355. }
  1356. })
  1357. })