session-timeline-context-resize.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import { expect, test, type Page } from "@playwright/test"
  2. import { mockOpenCodeServer } from "../utils/mock-server"
  3. import { expectAppVisible, expectSessionTitle } from "../utils/waits"
  4. import {
  5. analyzeVisualObservations,
  6. defineVisualRegions,
  7. startVisualProbe,
  8. stopVisualProbe,
  9. visualPlan,
  10. } from "../utils/visual-stability"
  11. const directory = "C:/OpenCode/ContextResizeRegression"
  12. const projectID = "proj_context_resize_regression"
  13. const sessionID = "ses_context_resize_regression"
  14. const title = "Context resize regression"
  15. const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
  16. const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
  17. const followingTextID = "prt_0104_text"
  18. type Message = {
  19. info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
  20. parts: Record<string, unknown>[]
  21. }
  22. const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
  23. test.describe("regression: session timeline context group resize", () => {
  24. test("remeasures a recent explored context group before the next paint", async ({ page }) => {
  25. await page.setViewportSize({ width: 1400, height: 900 })
  26. await mockServer(page)
  27. await configurePage(page)
  28. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  29. await expectSessionTitle(page, title)
  30. await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
  31. await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
  32. await settle(page)
  33. const samples = await sampleExpansion(page)
  34. const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
  35. expect(samples[0]?.overlap).toBe(0)
  36. expect(visibleOverlap).toEqual([])
  37. expect(samples.at(-1)?.expanded).toBe("true")
  38. })
  39. test("paints a stable exploring to explored transition", async ({ page }) => {
  40. const events: { directory: string; payload: Record<string, unknown> }[] = []
  41. await page.setViewportSize({ width: 1400, height: 900 })
  42. await mockServer(page, events, [
  43. ...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
  44. ...turn(10, true, "running"),
  45. ])
  46. await configurePage(page)
  47. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  48. await expectSessionTitle(page, title)
  49. const devtools = await page.context().newCDPSession(page)
  50. await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
  51. const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
  52. await expectAppVisible(context)
  53. await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
  54. const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
  55. const regions = defineVisualRegions({
  56. status: {
  57. selector: `${contextSelector} [data-component="tool-status-title"]`,
  58. opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
  59. },
  60. context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
  61. following: {
  62. selector: `[data-timeline-part-id="${followingTextID}"]`,
  63. closest: '[data-timeline-row="AssistantPart"]',
  64. },
  65. })
  66. await startVisualProbe(page, regions)
  67. for (const [index, delay] of [120, 350, 80, 500].entries()) {
  68. events.push({
  69. directory,
  70. payload: {
  71. type: "message.part.updated",
  72. properties: {
  73. part: contextTool(
  74. contextIDs[index]!,
  75. id("msg_assistant", 10),
  76. ["read", "glob", "grep", "list"][index]!,
  77. [
  78. { filePath: "src/recent-a.ts" },
  79. { path: directory, pattern: "**/*.ts" },
  80. { path: directory, pattern: "Explored" },
  81. { path: "src" },
  82. ][index]!,
  83. ),
  84. },
  85. },
  86. })
  87. await page.waitForTimeout(delay)
  88. }
  89. await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
  90. await page.waitForTimeout(700)
  91. const trace = await stopVisualProbe<keyof typeof regions>(page)
  92. const labels = trace.samples
  93. .map((sample) => sample.regions.status?.label)
  94. .filter((value): value is string => !!value)
  95. .filter((value, index, all) => value !== all[index - 1])
  96. const issues = analyzeVisualObservations(
  97. trace.samples,
  98. visualPlan(regions, [
  99. { type: "required", regions: ["context", "following"] },
  100. { type: "opacity", regions: "all" },
  101. { type: "continuity", regions: "all" },
  102. { type: "motion", regions: "all" },
  103. { type: "label-stability", regions: "all" },
  104. { type: "flow", regions: ["context", "following"] },
  105. ]),
  106. )
  107. expect(labels).toEqual(["Exploring", "Explored"])
  108. expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
  109. })
  110. })
  111. async function configurePage(page: Page) {
  112. await page.addInitScript(() => {
  113. localStorage.setItem(
  114. "settings.v3",
  115. JSON.stringify({
  116. general: {
  117. editToolPartsExpanded: true,
  118. shellToolPartsExpanded: true,
  119. showReasoningSummaries: true,
  120. },
  121. }),
  122. )
  123. })
  124. }
  125. async function sampleExpansion(page: Page) {
  126. return page.evaluate(
  127. ({ contextIDs, followingTextID }) =>
  128. new Promise<
  129. {
  130. frame: number
  131. label: string
  132. scrollTop: number
  133. scrollHeight: number
  134. contextBottom: number
  135. textTop: number
  136. overlap: number
  137. gap: number
  138. expanded: string | null
  139. }[]
  140. >((resolve) => {
  141. const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
  142. const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
  143. const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
  144. const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
  145. const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
  146. const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
  147. if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
  148. throw new Error("missing regression nodes")
  149. scroller.scrollTop = scroller.scrollHeight
  150. const samples: {
  151. frame: number
  152. label: string
  153. scrollTop: number
  154. scrollHeight: number
  155. contextBottom: number
  156. textTop: number
  157. overlap: number
  158. gap: number
  159. expanded: string | null
  160. }[] = []
  161. const capture = (frame: number, label: string) => {
  162. const contextRect = contextRow.getBoundingClientRect()
  163. const textRect = textRow.getBoundingClientRect()
  164. samples.push({
  165. frame,
  166. label,
  167. scrollTop: Math.round(scroller.scrollTop * 10) / 10,
  168. scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
  169. contextBottom: Math.round(contextRect.bottom * 10) / 10,
  170. textTop: Math.round(textRect.top * 10) / 10,
  171. overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
  172. gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
  173. expanded: trigger.getAttribute("aria-expanded"),
  174. })
  175. }
  176. capture(-1, "before")
  177. trigger.click()
  178. capture(0, "sync-after-click")
  179. let frame = 1
  180. const tick = () => {
  181. setTimeout(() => {
  182. capture(frame, "painted")
  183. frame += 1
  184. if (frame > 8) {
  185. resolve(samples)
  186. return
  187. }
  188. requestAnimationFrame(tick)
  189. }, 0)
  190. }
  191. requestAnimationFrame(tick)
  192. }),
  193. { contextIDs, followingTextID },
  194. )
  195. }
  196. function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
  197. const userID = id("msg_user", index)
  198. const assistantID = id("msg_assistant", index)
  199. return [
  200. {
  201. info: {
  202. id: userID,
  203. sessionID,
  204. role: "user",
  205. time: { created: 1700000000000 + index * 10_000 },
  206. summary: { diffs: [] },
  207. agent: "build",
  208. model,
  209. },
  210. parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
  211. },
  212. {
  213. info: {
  214. id: assistantID,
  215. sessionID,
  216. role: "assistant",
  217. time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
  218. parentID: userID,
  219. modelID: model.modelID,
  220. providerID: model.providerID,
  221. mode: "build",
  222. agent: "build",
  223. path: { cwd: directory, root: directory },
  224. cost: 0.01,
  225. tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
  226. variant: "max",
  227. finish: "stop",
  228. },
  229. parts: target
  230. ? [
  231. contextTool(
  232. contextIDs[0]!,
  233. assistantID,
  234. "read",
  235. { filePath: "src/recent-a.ts", offset: 0, limit: 120 },
  236. status,
  237. ),
  238. contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
  239. contextTool(
  240. contextIDs[2]!,
  241. assistantID,
  242. "grep",
  243. { path: directory, pattern: "Explored", include: "*.ts" },
  244. status,
  245. ),
  246. contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
  247. {
  248. id: followingTextID,
  249. sessionID,
  250. messageID: assistantID,
  251. type: "text",
  252. text: "This assistant text is immediately after the explored context group.",
  253. },
  254. ]
  255. : [
  256. {
  257. id: id("prt_text", index),
  258. sessionID,
  259. messageID: assistantID,
  260. type: "text",
  261. text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
  262. },
  263. ],
  264. },
  265. ]
  266. }
  267. function contextTool(
  268. partID: string,
  269. messageID: string,
  270. tool: string,
  271. input: Record<string, unknown>,
  272. status: "running" | "completed" = "completed",
  273. ) {
  274. return {
  275. id: partID,
  276. sessionID,
  277. messageID,
  278. type: "tool",
  279. callID: `call_${partID}`,
  280. tool,
  281. state: {
  282. status,
  283. input,
  284. output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
  285. title: input.filePath || input.path || input.pattern || "completed",
  286. metadata: {},
  287. time: { start: 1700000000000, end: 1700000000100 },
  288. },
  289. }
  290. }
  291. async function mockServer(
  292. page: Page,
  293. events: { directory: string; payload: Record<string, unknown> }[] = [],
  294. fixtureMessages = messages,
  295. ) {
  296. await mockOpenCodeServer(page, {
  297. directory,
  298. project: project(),
  299. provider: provider(),
  300. sessions: [session()],
  301. pageMessages: () => ({ items: fixtureMessages }),
  302. events: () => events.splice(0, 1),
  303. eventRetry: 50,
  304. })
  305. }
  306. async function settle(page: Page) {
  307. await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
  308. }
  309. function id(prefix: string, index: number) {
  310. return `${prefix}_${String(index).padStart(4, "0")}`
  311. }
  312. function project() {
  313. return {
  314. id: projectID,
  315. worktree: directory,
  316. vcs: "git",
  317. name: "context-resize-regression",
  318. time: { created: 1700000000000, updated: 1700000000000 },
  319. sandboxes: [],
  320. }
  321. }
  322. function session() {
  323. return {
  324. id: sessionID,
  325. slug: "context-resize-regression",
  326. projectID,
  327. directory,
  328. title,
  329. version: "dev",
  330. time: { created: 1700000000000, updated: 1700000000000 },
  331. }
  332. }
  333. function provider() {
  334. return {
  335. all: [
  336. {
  337. id: "opencode",
  338. name: "OpenCode",
  339. models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
  340. },
  341. ],
  342. connected: ["opencode"],
  343. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  344. }
  345. }
  346. function base64Encode(value: string) {
  347. return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
  348. }