session-timeline-benchmark.fixture.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. import { base64Encode } from "@opencode-ai/core/util/encode"
  2. import type { Page } from "@playwright/test"
  3. import { mockOpenCodeServer } from "../../utils/mock-server"
  4. import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
  5. import { expect } from "../benchmark"
  6. const directory = "C:/OpenCode/TimelineStateRegression"
  7. const projectID = "proj_timeline_state_regression"
  8. const sessionID = "ses_timeline_state_regression"
  9. const userMessageID = "msg_user_regression"
  10. const assistantMessageID = "msg_assistant_regression"
  11. const editPartID = "prt_0001_edit"
  12. export const textPartID = "prt_9999_text"
  13. const title = "Timeline collapse state regression"
  14. const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
  15. type EventPayload = {
  16. directory: string
  17. payload: Record<string, unknown>
  18. }
  19. const userMessage = {
  20. info: {
  21. id: userMessageID,
  22. sessionID,
  23. role: "user",
  24. time: { created: 1700000000000 },
  25. summary: { diffs: [] },
  26. agent: "build",
  27. model,
  28. },
  29. parts: [
  30. {
  31. id: "prt_user_text",
  32. sessionID,
  33. messageID: userMessageID,
  34. type: "text",
  35. text: "Please edit the file.",
  36. },
  37. ],
  38. }
  39. const editPart = {
  40. id: editPartID,
  41. sessionID,
  42. messageID: assistantMessageID,
  43. type: "tool",
  44. callID: "call_edit_regression",
  45. tool: "edit",
  46. state: {
  47. status: "completed",
  48. input: { filePath: "src/regression.ts" },
  49. output: "Edited src/regression.ts",
  50. title: "src/regression.ts",
  51. metadata: {
  52. filediff: {
  53. file: "src/regression.ts",
  54. additions: 1,
  55. deletions: 1,
  56. before: "export const value = 'before'\n",
  57. after: "export const value = 'after'\n",
  58. },
  59. diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
  60. },
  61. time: { start: 1700000001000, end: 1700000002000 },
  62. },
  63. }
  64. const streamedTextPart = {
  65. id: textPartID,
  66. sessionID,
  67. messageID: assistantMessageID,
  68. type: "text",
  69. text: "Streaming added a later assistant text part.",
  70. }
  71. const assistantMessage = {
  72. info: {
  73. id: assistantMessageID,
  74. sessionID,
  75. role: "assistant",
  76. time: { created: 1700000001000 },
  77. parentID: userMessageID,
  78. modelID: model.modelID,
  79. providerID: model.providerID,
  80. mode: "build",
  81. agent: "build",
  82. path: { cwd: directory, root: directory },
  83. cost: 0.01,
  84. tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
  85. variant: "max",
  86. },
  87. parts: [editPart],
  88. }
  89. export async function setupTimelineBenchmark(
  90. page: Page,
  91. options: {
  92. historyTurns: number
  93. eventBatch: number
  94. newLayoutDesigns?: boolean
  95. vcsDiff?: unknown[]
  96. turnDiffs?: unknown[]
  97. },
  98. ) {
  99. const events: EventPayload[] = []
  100. let eventBatch = options.eventBatch
  101. const currentUserMessage = options.turnDiffs
  102. ? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
  103. : userMessage
  104. await mockOpenCodeServer(page, {
  105. directory,
  106. project: project(),
  107. provider: provider(),
  108. sessions: [session()],
  109. vcsDiff: options.vcsDiff,
  110. pageMessages: () => ({
  111. items: [
  112. ...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
  113. currentUserMessage,
  114. assistantMessage,
  115. ],
  116. }),
  117. events: () => events.splice(0, eventBatch),
  118. eventRetry: 16,
  119. })
  120. await page.addInitScript(
  121. (input) => {
  122. localStorage.setItem(
  123. "settings.v3",
  124. JSON.stringify({
  125. general: {
  126. newLayoutDesigns: input.newLayoutDesigns,
  127. editToolPartsExpanded: true,
  128. shellToolPartsExpanded: true,
  129. showReasoningSummaries: true,
  130. },
  131. }),
  132. )
  133. },
  134. { newLayoutDesigns: options.newLayoutDesigns ?? false },
  135. )
  136. await page.setViewportSize({ width: 1366, height: 768 })
  137. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  138. const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
  139. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  140. await expectSessionTitle(page, title)
  141. await expectAppVisible(scroller)
  142. return {
  143. scroller,
  144. text,
  145. transport: {
  146. enqueue(payload: EventPayload | EventPayload[]) {
  147. events.push(...(Array.isArray(payload) ? payload : [payload]))
  148. },
  149. pendingCount() {
  150. return events.length
  151. },
  152. releaseAll() {
  153. eventBatch = events.length
  154. },
  155. },
  156. async scrollToBottom() {
  157. await scroller.evaluate((element) => {
  158. element.scrollTop = element.scrollHeight
  159. })
  160. },
  161. async waitForStableGeometry() {
  162. await expect
  163. .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
  164. .toBeLessThanOrEqual(1)
  165. await page.waitForFunction((partID) => {
  166. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  167. element.querySelector(`[data-timeline-part-id="${partID}"]`),
  168. )
  169. if (!root) return false
  170. return new Promise<boolean>((resolve) => {
  171. const height = root.scrollHeight
  172. requestAnimationFrame(() =>
  173. requestAnimationFrame(() =>
  174. resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
  175. ),
  176. )
  177. })
  178. }, textPartID)
  179. },
  180. }
  181. }
  182. export function buildInitialStreamEvent(deltaCount: number): EventPayload {
  183. return {
  184. directory,
  185. payload: {
  186. type: "message.part.updated",
  187. properties: {
  188. part: {
  189. ...streamedTextPart,
  190. text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
  191. },
  192. },
  193. },
  194. }
  195. }
  196. export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
  197. return Array.from({ length: deltaCount }, (_, index) => ({
  198. directory,
  199. payload: {
  200. type: "message.part.delta",
  201. properties: {
  202. messageID: assistantMessageID,
  203. partID: textPartID,
  204. field: "text",
  205. delta: streamChunk(index + 1, deltaCount + 1),
  206. },
  207. },
  208. }))
  209. }
  210. function performanceTurn(index: number) {
  211. const suffix = String(index).padStart(4, "0")
  212. const userID = `msg_0000_${suffix}_a_user`
  213. const assistantID = `msg_0000_${suffix}_b_assistant`
  214. const before = historicalSource(index, false)
  215. const after = historicalSource(index, true)
  216. const parts = [
  217. ...(index % 5 === 0
  218. ? [
  219. {
  220. id: `prt_0000_${suffix}_reasoning`,
  221. sessionID,
  222. messageID: assistantID,
  223. type: "reasoning",
  224. text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
  225. time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
  226. },
  227. ]
  228. : []),
  229. {
  230. id: `prt_0000_${suffix}_assistant`,
  231. sessionID,
  232. messageID: assistantID,
  233. type: "text",
  234. text: historicalMarkdown(index),
  235. },
  236. ...(index % 8 === 0
  237. ? [
  238. {
  239. id: `prt_0000_${suffix}_edit`,
  240. sessionID,
  241. messageID: assistantID,
  242. type: "tool",
  243. callID: `call_0000_${suffix}_edit`,
  244. tool: "edit",
  245. state: {
  246. status: "completed",
  247. input: { filePath: `src/history-${index}.ts` },
  248. output: `Edited src/history-${index}.ts`,
  249. title: `src/history-${index}.ts`,
  250. metadata: {
  251. filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
  252. },
  253. time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
  254. },
  255. },
  256. ]
  257. : []),
  258. ...(index % 12 === 0
  259. ? [
  260. {
  261. id: `prt_0000_${suffix}_write`,
  262. sessionID,
  263. messageID: assistantID,
  264. type: "tool",
  265. callID: `call_0000_${suffix}_write`,
  266. tool: "write",
  267. state: {
  268. status: "completed",
  269. input: { filePath: `src/generated-${index}.tsx`, content: after },
  270. output: `Wrote src/generated-${index}.tsx`,
  271. title: `src/generated-${index}.tsx`,
  272. metadata: {
  273. filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
  274. },
  275. time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
  276. },
  277. },
  278. ]
  279. : []),
  280. ...(index % 16 === 0
  281. ? [
  282. {
  283. id: `prt_0000_${suffix}_patch`,
  284. sessionID,
  285. messageID: assistantID,
  286. type: "tool",
  287. callID: `call_0000_${suffix}_patch`,
  288. tool: "apply_patch",
  289. state: {
  290. status: "completed",
  291. input: { patchText: realisticPatch(index) },
  292. output: "Success. Updated src/components/SessionCard.tsx",
  293. title: "src/components/SessionCard.tsx",
  294. metadata: {
  295. files: [
  296. {
  297. filePath: "src/components/SessionCard.tsx",
  298. relativePath: "src/components/SessionCard.tsx",
  299. type: "update",
  300. additions: 8,
  301. deletions: 3,
  302. patch: realisticPatch(index),
  303. before,
  304. after,
  305. },
  306. ],
  307. },
  308. time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
  309. },
  310. },
  311. ]
  312. : []),
  313. ]
  314. return [
  315. {
  316. info: {
  317. id: userID,
  318. sessionID,
  319. role: "user",
  320. time: { created: 1690000000000 + index * 2_000 },
  321. summary: { diffs: [] },
  322. agent: "build",
  323. model,
  324. },
  325. parts: [
  326. {
  327. id: `prt_0000_${suffix}_user`,
  328. sessionID,
  329. messageID: userID,
  330. type: "text",
  331. text: `Historical prompt ${index}`,
  332. },
  333. ],
  334. },
  335. {
  336. info: {
  337. id: assistantID,
  338. sessionID,
  339. role: "assistant",
  340. time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
  341. parentID: userID,
  342. modelID: model.modelID,
  343. providerID: model.providerID,
  344. mode: "build",
  345. agent: "build",
  346. path: { cwd: directory, root: directory },
  347. cost: 0.01,
  348. tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
  349. variant: "max",
  350. finish: "stop",
  351. },
  352. parts,
  353. },
  354. ]
  355. }
  356. function historicalMarkdown(index: number) {
  357. const code = `import { For, Show, createSignal } from "solid-js"
  358. type SessionRow = { id: string; title: string; active: boolean }
  359. export function SessionList(props: { rows: SessionRow[] }) {
  360. const [selected, setSelected] = createSignal<string>()
  361. return (
  362. <section aria-label="Sessions">
  363. <For each={props.rows}>{(row) => (
  364. <button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
  365. <Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
  366. </button>
  367. )}</For>
  368. </section>
  369. )
  370. }`
  371. return `## Session renderer review ${index}
  372. The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
  373. | Concern | Current behavior | Verification |
  374. | --- | --- | --- |
  375. | streaming | appends Markdown blocks | painted frames |
  376. | geometry | anchors visible rows | DOM coordinates |
  377. | tools | preserves expanded state | keyed remount probe |
  378. > Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
  379. ${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
  380. }
  381. function historicalSource(index: number, updated: boolean) {
  382. const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
  383. const limit = updated ? 24 : 20
  384. return `import { createMemo, For } from "solid-js"
  385. type Message = {
  386. id: string
  387. role: "user" | "assistant"
  388. text: string
  389. tokens: { input: number; output: number }
  390. }
  391. export function MessageSummary(props: { messages: Message[]; locale: string }) {
  392. const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
  393. const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
  394. return (
  395. <article data-session-index="${index}">
  396. <header>{total().toLocaleString(props.locale)} output tokens</header>
  397. <For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
  398. </article>
  399. )
  400. }
  401. `
  402. }
  403. function realisticPatch(index: number) {
  404. return `*** Begin Patch
  405. *** Update File: src/components/SessionCard.tsx
  406. @@
  407. -const title = props.session.title.toUpperCase()
  408. -const messages = props.messages.slice(-20)
  409. +const title = props.session.title.toLocaleUpperCase(props.locale)
  410. +const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
  411. +const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
  412. @@
  413. - <h2>{title}</h2>
  414. + <h2 data-session-index="${index}">{title}</h2>
  415. + <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
  416. *** End Patch`
  417. }
  418. export function streamChunk(index: number, count: number) {
  419. if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
  420. if (index === count - 1)
  421. return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
  422. const section = Math.floor(index / 18) + 1
  423. const fragments = [
  424. ` continues across three`,
  425. ` or four word`,
  426. ` provider deltas and`,
  427. ` closes in this fragment**. <!-- stream-${index} -->\n\n`,
  428. `| Concern | State`,
  429. ` | Verification |\n|`,
  430. ` --- | ---`,
  431. ` | --- |\n|`,
  432. ` markdown | incremental |`,
  433. ` painted frames | <!-- stream-${index} -->\n\n`,
  434. `\`\`\`tsx\nconst row: SessionRow`,
  435. ` = rows[index] ??`,
  436. ` fallback\nconst title =`,
  437. ` row.title.toLocaleUpperCase(locale)\n`,
  438. `const selected = createMemo(()`,
  439. ` => row.id ===`,
  440. ` activeID()) // stream-${index}\n`,
  441. `// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
  442. ]
  443. return fragments[(index - 1) % fragments.length]!
  444. }
  445. function project() {
  446. return {
  447. id: projectID,
  448. worktree: directory,
  449. vcs: "git",
  450. name: "timeline-state-regression",
  451. time: { created: 1700000000000, updated: 1700000000000 },
  452. sandboxes: [],
  453. }
  454. }
  455. function session() {
  456. return {
  457. id: sessionID,
  458. slug: "timeline-state-regression",
  459. projectID,
  460. directory,
  461. title,
  462. version: "dev",
  463. time: { created: 1700000000000, updated: 1700000000000 },
  464. }
  465. }
  466. function provider() {
  467. return {
  468. all: [
  469. {
  470. id: "opencode",
  471. name: "OpenCode",
  472. models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
  473. },
  474. ],
  475. connected: ["opencode"],
  476. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  477. }
  478. }