session-timeline-history-root.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. import { base64Encode } from "@opencode-ai/core/util/encode"
  2. import { expect, test, type Page } from "@playwright/test"
  3. import {
  4. assistantMessage,
  5. directory,
  6. messageUpdated,
  7. project,
  8. session,
  9. sessionID,
  10. status,
  11. textPart,
  12. title,
  13. userID,
  14. userMessage,
  15. } from "../performance/timeline-stability/fixture"
  16. import { mockOpenCodeServer } from "../utils/mock-server"
  17. import { installSseTransport } from "../utils/sse-transport"
  18. import { expectSessionTitle } from "../utils/waits"
  19. const assistants = Array.from({ length: 14 }, (_, index) =>
  20. assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
  21. id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
  22. parentID: userID,
  23. created: 1700000001000 + index * 1_000,
  24. completed: index < 13,
  25. }),
  26. )
  27. const messages = [userMessage(), ...assistants]
  28. const lastAssistant = assistants.at(-1)!
  29. const lastPartID = assistants.at(-1)!.parts[0]!.id
  30. const userPartID = `prt_${userID}_text`
  31. const completed = {
  32. ...lastAssistant.info,
  33. time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
  34. }
  35. const scenarios = [
  36. { name: "completion", info: completed, idleFirst: false, interrupted: false },
  37. {
  38. name: "interruption",
  39. info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
  40. idleFirst: true,
  41. interrupted: true,
  42. },
  43. ] as const
  44. test.use({ viewport: { width: 646, height: 1385 } })
  45. for (const scenario of scenarios) {
  46. test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
  47. const requests: { before?: string; phase: "start" | "end" }[] = []
  48. const pages: { before?: string; limit: number }[] = []
  49. const roots: { sessionID: string; messageID: string }[] = []
  50. const sequence: string[] = []
  51. const history = Promise.withResolvers<void>()
  52. const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
  53. server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
  54. retry: 20,
  55. })
  56. await mockOpenCodeServer(page, {
  57. directory,
  58. project: project(),
  59. provider: {
  60. all: [
  61. {
  62. id: "opencode",
  63. name: "OpenCode",
  64. models: {
  65. "claude-opus-4-6": {
  66. id: "claude-opus-4-6",
  67. name: "Claude Opus 4.6",
  68. limit: { context: 200_000 },
  69. },
  70. },
  71. },
  72. ],
  73. connected: ["opencode"],
  74. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  75. },
  76. sessions: [session()],
  77. sessionStatus: { [sessionID]: { type: "busy" } },
  78. beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
  79. onMessages: (request) => {
  80. requests.push(request)
  81. sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
  82. },
  83. onMessage: (request) => {
  84. roots.push(request)
  85. sequence.push(`message:${request.messageID}`)
  86. },
  87. message: (requestedSessionID, messageID) => {
  88. if (requestedSessionID !== sessionID) return
  89. return messages.find((item) => item.info.id === messageID)
  90. },
  91. pageMessages: (_, limit, before) => {
  92. pages.push({ before, limit })
  93. const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
  94. const start = Math.max(0, end - limit)
  95. return {
  96. items: messages.slice(start, end),
  97. cursor: start > 0 ? messages[start]!.info.id : undefined,
  98. }
  99. },
  100. })
  101. await page.addInitScript(
  102. ({ userPartID, lastPartID }) => {
  103. const state = { armed: false, hidden: false, samples: 0, stop: false }
  104. ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
  105. const sample = () => {
  106. if (state.armed) {
  107. const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
  108. const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
  109. const view = viewport?.getBoundingClientRect()
  110. const visible = (partID: string) => {
  111. const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
  112. const rect = part?.getBoundingClientRect()
  113. return (
  114. !!rect &&
  115. !!view &&
  116. rect.width > 0 &&
  117. rect.height > 0 &&
  118. rect.bottom > view.top &&
  119. rect.top < view.bottom
  120. )
  121. }
  122. if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
  123. state.samples++
  124. }
  125. if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
  126. }
  127. requestAnimationFrame(() => setTimeout(sample, 0))
  128. },
  129. { userPartID, lastPartID },
  130. )
  131. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  132. await transport.waitForConnection()
  133. await expectSessionTitle(page, title)
  134. await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
  135. await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
  136. await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
  137. expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
  138. expect(sequence.slice(0, 4)).toEqual([
  139. "messages:start:latest",
  140. "messages:end:latest",
  141. `message:${userID}`,
  142. `messages:start:${messages.at(-2)!.info.id}`,
  143. ])
  144. await page.evaluate(() => {
  145. ;(
  146. window as Window & {
  147. __historyRootProbe?: { armed: boolean }
  148. }
  149. ).__historyRootProbe!.armed = true
  150. })
  151. await waitForProbeSamples(page, 0)
  152. expect(await historyRootHidden(page)).toBe(false)
  153. const beforeHistory = await probeSamples(page)
  154. history.resolve()
  155. await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
  156. await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
  157. await waitForProbeSamples(page, beforeHistory)
  158. expect(pages[0]).toEqual({ before: undefined, limit: 2 })
  159. expect(roots).toEqual([{ sessionID, messageID: userID }])
  160. const message = messageUpdated(scenario.info)
  161. const idle = status("idle")
  162. for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
  163. const beforeEvent = await probeSamples(page)
  164. await transport.send(event)
  165. if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
  166. if (event === message && scenario.interrupted)
  167. await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
  168. await waitForProbeSamples(page, beforeEvent)
  169. const current = await timelineState(page)
  170. expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
  171. expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
  172. }
  173. expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
  174. expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
  175. await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
  176. await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
  177. if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
  178. expect(
  179. await page.evaluate(() => {
  180. const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
  181. .__historyRootProbe!
  182. state.stop = true
  183. return state.hidden
  184. }),
  185. ).toBe(false)
  186. })
  187. }
  188. function timelineState(page: Page) {
  189. return page.evaluate(() => ({
  190. virtual: !!document.querySelector("[data-timeline-virtual-content]"),
  191. rows: document.querySelectorAll("[data-timeline-key]").length,
  192. }))
  193. }
  194. function probeSamples(page: Page) {
  195. return page.evaluate(
  196. () => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
  197. )
  198. }
  199. async function waitForProbeSamples(page: Page, after: number) {
  200. await page.waitForFunction(
  201. (after) =>
  202. (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
  203. after,
  204. )
  205. }
  206. function historyRootHidden(page: Page) {
  207. return page.evaluate(
  208. () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
  209. )
  210. }