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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import { expect, test, type Page } from "@playwright/test"
  2. import { mockOpenCodeServer } from "../utils/mock-server"
  3. import { expectAppVisible, expectSessionTitle } from "../utils/waits"
  4. const directory = "C:/OpenCode/ContextResizeRegression"
  5. const projectID = "proj_context_resize_regression"
  6. const sessionID = "ses_context_resize_regression"
  7. const title = "Context resize regression"
  8. const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
  9. const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
  10. const followingTextID = "prt_0104_text"
  11. type Message = {
  12. info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
  13. parts: Record<string, unknown>[]
  14. }
  15. const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
  16. test.describe("regression: session timeline context group resize", () => {
  17. test("remeasures a recent explored context group before the next paint", async ({ page }) => {
  18. await page.setViewportSize({ width: 1400, height: 900 })
  19. await mockServer(page)
  20. await configurePage(page)
  21. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  22. await expectSessionTitle(page, title)
  23. await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
  24. await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
  25. await settle(page)
  26. const samples = await sampleExpansion(page)
  27. const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
  28. expect(samples[0]?.overlap).toBe(0)
  29. expect(visibleOverlap).toEqual([])
  30. expect(samples.at(-1)?.expanded).toBe("true")
  31. })
  32. })
  33. async function configurePage(page: Page) {
  34. await page.addInitScript(() => {
  35. localStorage.setItem(
  36. "settings.v3",
  37. JSON.stringify({
  38. general: {
  39. editToolPartsExpanded: true,
  40. shellToolPartsExpanded: true,
  41. showReasoningSummaries: true,
  42. },
  43. }),
  44. )
  45. })
  46. }
  47. async function sampleExpansion(page: Page) {
  48. return page.evaluate(
  49. ({ contextIDs, followingTextID }) =>
  50. new Promise<
  51. {
  52. frame: number
  53. label: string
  54. scrollTop: number
  55. scrollHeight: number
  56. contextBottom: number
  57. textTop: number
  58. overlap: number
  59. gap: number
  60. expanded: string | null
  61. }[]
  62. >((resolve) => {
  63. const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
  64. const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
  65. const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
  66. const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
  67. const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
  68. const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
  69. if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
  70. throw new Error("missing regression nodes")
  71. scroller.scrollTop = scroller.scrollHeight
  72. const samples: {
  73. frame: number
  74. label: string
  75. scrollTop: number
  76. scrollHeight: number
  77. contextBottom: number
  78. textTop: number
  79. overlap: number
  80. gap: number
  81. expanded: string | null
  82. }[] = []
  83. const capture = (frame: number, label: string) => {
  84. const contextRect = contextRow.getBoundingClientRect()
  85. const textRect = textRow.getBoundingClientRect()
  86. samples.push({
  87. frame,
  88. label,
  89. scrollTop: Math.round(scroller.scrollTop * 10) / 10,
  90. scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
  91. contextBottom: Math.round(contextRect.bottom * 10) / 10,
  92. textTop: Math.round(textRect.top * 10) / 10,
  93. overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
  94. gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
  95. expanded: trigger.getAttribute("aria-expanded"),
  96. })
  97. }
  98. capture(-1, "before")
  99. trigger.click()
  100. capture(0, "sync-after-click")
  101. let frame = 1
  102. const tick = () => {
  103. setTimeout(() => {
  104. capture(frame, "painted")
  105. frame += 1
  106. if (frame > 8) {
  107. resolve(samples)
  108. return
  109. }
  110. requestAnimationFrame(tick)
  111. }, 0)
  112. }
  113. requestAnimationFrame(tick)
  114. }),
  115. { contextIDs, followingTextID },
  116. )
  117. }
  118. function turn(index: number, target: boolean): Message[] {
  119. const userID = id("msg_user", index)
  120. const assistantID = id("msg_assistant", index)
  121. return [
  122. {
  123. info: {
  124. id: userID,
  125. sessionID,
  126. role: "user",
  127. time: { created: 1700000000000 + index * 10_000 },
  128. summary: { diffs: [] },
  129. agent: "build",
  130. model,
  131. },
  132. parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
  133. },
  134. {
  135. info: {
  136. id: assistantID,
  137. sessionID,
  138. role: "assistant",
  139. time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
  140. parentID: userID,
  141. modelID: model.modelID,
  142. providerID: model.providerID,
  143. mode: "build",
  144. agent: "build",
  145. path: { cwd: directory, root: directory },
  146. cost: 0.01,
  147. tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
  148. variant: "max",
  149. finish: "stop",
  150. },
  151. parts: target
  152. ? [
  153. contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
  154. contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
  155. contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
  156. contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
  157. {
  158. id: followingTextID,
  159. sessionID,
  160. messageID: assistantID,
  161. type: "text",
  162. text: "This assistant text is immediately after the explored context group.",
  163. },
  164. ]
  165. : [
  166. {
  167. id: id("prt_text", index),
  168. sessionID,
  169. messageID: assistantID,
  170. type: "text",
  171. text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
  172. },
  173. ],
  174. },
  175. ]
  176. }
  177. function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
  178. return {
  179. id: partID,
  180. sessionID,
  181. messageID,
  182. type: "tool",
  183. callID: `call_${partID}`,
  184. tool,
  185. state: {
  186. status: "completed",
  187. input,
  188. output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
  189. title: input.filePath || input.path || input.pattern || "completed",
  190. metadata: {},
  191. time: { start: 1700000000000, end: 1700000000100 },
  192. },
  193. }
  194. }
  195. async function mockServer(page: Page) {
  196. await mockOpenCodeServer(page, {
  197. directory,
  198. project: project(),
  199. provider: provider(),
  200. sessions: [session()],
  201. pageMessages: () => ({ items: messages }),
  202. })
  203. }
  204. async function settle(page: Page) {
  205. await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
  206. }
  207. function id(prefix: string, index: number) {
  208. return `${prefix}_${String(index).padStart(4, "0")}`
  209. }
  210. function project() {
  211. return {
  212. id: projectID,
  213. worktree: directory,
  214. vcs: "git",
  215. name: "context-resize-regression",
  216. time: { created: 1700000000000, updated: 1700000000000 },
  217. sandboxes: [],
  218. }
  219. }
  220. function session() {
  221. return {
  222. id: sessionID,
  223. slug: "context-resize-regression",
  224. projectID,
  225. directory,
  226. title,
  227. version: "dev",
  228. time: { created: 1700000000000, updated: 1700000000000 },
  229. }
  230. }
  231. function provider() {
  232. return {
  233. all: [
  234. {
  235. id: "opencode",
  236. name: "OpenCode",
  237. models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
  238. },
  239. ],
  240. connected: ["opencode"],
  241. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  242. }
  243. }
  244. function base64Encode(value: string) {
  245. return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
  246. }