session-parent-hydration-benchmark.spec.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import type { Page } from "@playwright/test"
  2. import { expectSessionTitle } from "../../utils/waits"
  3. import { mockOpenCodeServer } from "../../utils/mock-server"
  4. import { benchmark, expect, withBenchmarkPage } from "../benchmark"
  5. import { fixture } from "./session-timeline-stress.fixture"
  6. import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
  7. import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
  8. type ParentHydrationBenchmarkMode = "natural" | "candidate"
  9. const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
  10. if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
  11. const userID = "msg_parent_hydration_user"
  12. const user = {
  13. ...fixture.messages[fixture.targetID][0]!,
  14. info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
  15. parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
  16. ...part,
  17. id: `prt_parent_hydration_user_${index}`,
  18. messageID: userID,
  19. })),
  20. }
  21. const assistantSeed = fixture.messages[fixture.targetID][3]!
  22. const assistants = Array.from({ length: 14 }, (_, index) => {
  23. const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
  24. return {
  25. ...assistantSeed,
  26. info: {
  27. ...assistantSeed.info,
  28. id: messageID,
  29. parentID: userID,
  30. time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
  31. },
  32. parts: assistantSeed.parts.map((part, partIndex) => ({
  33. ...part,
  34. id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
  35. messageID,
  36. })),
  37. }
  38. })
  39. const messages = [user, ...assistants]
  40. const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
  41. const lastID = userID
  42. const lastAssistant = assistants.at(-1)!
  43. const lastPart = lastAssistant.parts.at(-1)!
  44. const lastPartID =
  45. lastPart.type === "tool"
  46. ? lastPart.id
  47. : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
  48. benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
  49. benchmark.setTimeout(180_000)
  50. const results = [] as Awaited<ReturnType<typeof trial>>[]
  51. for (let run = 0; run < 5; run++) {
  52. results.push(
  53. await withBenchmarkPage(
  54. browser,
  55. `session-parent-hydration-${mode}-${run}`,
  56. (page) => trial(page, mode),
  57. testInfo,
  58. ),
  59. )
  60. }
  61. const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
  62. report(
  63. {
  64. results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
  65. summary: {
  66. firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
  67. blankSamples: results.map((result) => result.metrics.blankSamples),
  68. requestCounts: {
  69. list: results.map((result) => result.requestCounts.list),
  70. parent: results.map((result) => result.requestCounts.parent),
  71. },
  72. historyGateCount: results.map((result) => result.historyGateCount),
  73. },
  74. },
  75. { mode },
  76. )
  77. })
  78. async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
  79. const requests: { type: "list" | "parent"; before?: string }[] = []
  80. const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
  81. let historyGates = 0
  82. await mockOpenCodeServer(page, {
  83. sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
  84. provider: fixture.provider,
  85. directory: fixture.directory,
  86. project: fixture.project,
  87. messageDelay: 50,
  88. onMessages: (request) => {
  89. if (request.sessionID === fixture.targetID && request.phase === "start")
  90. requests.push({ type: "list", before: request.before })
  91. },
  92. beforeMessagesResponse: (request) => {
  93. if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
  94. historyGates++
  95. return history!.promise
  96. },
  97. onMessage: (request) => {
  98. if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
  99. },
  100. message: (sessionID, messageID) => {
  101. if (sessionID !== fixture.targetID || messageID !== userID) return
  102. return user
  103. },
  104. pageMessages: (sessionID, limit, before) => {
  105. const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
  106. const end = before ? items.findIndex((message) => message.info.id === before) : items.length
  107. const start = Math.max(0, end - limit)
  108. return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
  109. },
  110. })
  111. await page.route(`**/session/${fixture.targetID}`, (route) => {
  112. const current = new URL(route.request().url()).pathname.startsWith("/api/")
  113. return route.fulfill({
  114. status: 200,
  115. contentType: "application/json",
  116. body: JSON.stringify(
  117. current
  118. ? {
  119. data: {
  120. ...target,
  121. cost: 0,
  122. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  123. location: { directory: target.directory },
  124. },
  125. }
  126. : target,
  127. ),
  128. })
  129. })
  130. await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
  131. await page.goto(stressSessionHref(fixture.sourceID))
  132. await expectSessionTitle(page, fixture.expected.sourceTitle)
  133. await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
  134. const href = stressSessionHref(fixture.targetID)
  135. await page.evaluate(
  136. ({ href, title }) => {
  137. const link = document.createElement("a")
  138. link.id = "parent-hydration-target"
  139. link.href = href
  140. link.textContent = title
  141. document.body.append(link)
  142. },
  143. { href, title: target.title },
  144. )
  145. const metrics = await measureSessionSwitch(page, {
  146. destinationIDs: messages.map((message) => message.info.id),
  147. sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
  148. lastID,
  149. requiredPartID: lastPartID,
  150. requireBottomAnchor: false,
  151. href,
  152. switch: async () => {
  153. await page.locator("#parent-hydration-target").click()
  154. await expectSessionTitle(page, target.title)
  155. },
  156. }).finally(() => history?.resolve())
  157. expect(metrics.firstCorrectObservedMs).not.toBeNull()
  158. const requestCounts = {
  159. list: requests.filter((request) => request.type === "list").length,
  160. parent: requests.filter((request) => request.type === "parent").length,
  161. }
  162. if (mode === "candidate") {
  163. expect(requestCounts.parent).toBe(0)
  164. expect(historyGates).toBe(0)
  165. }
  166. return { metrics, requestCounts, historyGateCount: historyGates }
  167. }