transition-matrix.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { expect, test } from "@playwright/test"
  2. import {
  3. defineVisualRegions,
  4. reportVisualStability,
  5. startVisualProbe,
  6. stopVisualProbe,
  7. visualPlan,
  8. } from "../../utils/visual-stability"
  9. import {
  10. assistantID,
  11. assistantMessage,
  12. completedAssistantInfo,
  13. event,
  14. messageUpdated,
  15. partDelta,
  16. partUpdated,
  17. setupTimeline,
  18. shell,
  19. status,
  20. textPart,
  21. toolPart,
  22. userMessage,
  23. } from "./fixture"
  24. test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
  25. const firstID = "prt_mutation_01_first"
  26. const middleID = "prt_mutation_02_middle"
  27. const lastID = "prt_mutation_03_last"
  28. const timeline = await setupTimeline(page, {
  29. messages: [
  30. userMessage(),
  31. assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
  32. completed: false,
  33. }),
  34. ],
  35. cpuRate: 4,
  36. })
  37. const regions = defineVisualRegions({
  38. first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  39. last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  40. })
  41. await startVisualProbe(page, regions)
  42. await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
  43. await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
  44. await timeline.send(
  45. event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
  46. 500,
  47. )
  48. await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
  49. const trace = await stopVisualProbe<keyof typeof regions>(page)
  50. await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
  51. })
  52. test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
  53. const textID = "prt_text_reconcile"
  54. const followingID = "prt_text_reconcile_following"
  55. const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], {
  56. completed: false,
  57. })
  58. const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
  59. const regions = defineVisualRegions({
  60. text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  61. following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  62. })
  63. await startVisualProbe(page, regions)
  64. await timeline.send(partDelta(textID, " streamed content"), 100)
  65. await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
  66. await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
  67. await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
  68. const trace = await stopVisualProbe<keyof typeof regions>(page)
  69. await reportVisualStability(
  70. testInfo,
  71. "text-reconcile",
  72. trace,
  73. visualPlan(regions, [
  74. { type: "required", regions: ["text", "following"] },
  75. { type: "unique", regions: ["text", "following"] },
  76. { type: "stable", regions: ["text", "following"] },
  77. { type: "opacity", regions: "all" },
  78. { type: "continuity", regions: "all" },
  79. { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
  80. { type: "label-stability", regions: "all" },
  81. { type: "preserve-bottom-anchor" },
  82. { type: "flow", regions: ["text", "following"] },
  83. ]),
  84. )
  85. })
  86. test("inserts a completed question between stable rows", async ({ page }, testInfo) => {
  87. const firstID = "prt_question_01_first"
  88. const questionID = "prt_question_02_hidden"
  89. const lastID = "prt_question_03_last"
  90. const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] }
  91. const timeline = await setupTimeline(page, {
  92. messages: [
  93. userMessage(),
  94. assistantMessage(
  95. [
  96. textPart(firstID, "Before question"),
  97. toolPart(questionID, "question", "running", input),
  98. textPart(lastID, "After question"),
  99. ],
  100. { completed: false },
  101. ),
  102. ],
  103. cpuRate: 4,
  104. })
  105. await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
  106. const regions = defineVisualRegions({
  107. first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  108. last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  109. })
  110. await startVisualProbe(page, regions)
  111. await timeline.send(
  112. partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
  113. 600,
  114. )
  115. await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
  116. const trace = await stopVisualProbe<keyof typeof regions>(page)
  117. await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
  118. })
  119. test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => {
  120. const assistant = assistantMessage([], { completed: false })
  121. const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
  122. await timeline.send(status("busy"), 150)
  123. await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
  124. const regions = defineVisualRegions({
  125. thinking: { selector: '[data-timeline-row="Thinking"]' },
  126. error: { selector: '[data-timeline-row="Error"]' },
  127. })
  128. await startVisualProbe(page, regions)
  129. await timeline.send(
  130. messageUpdated({
  131. ...assistant.info,
  132. error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
  133. }),
  134. 500,
  135. )
  136. await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
  137. await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly")
  138. const trace = await stopVisualProbe<keyof typeof regions>(page)
  139. await reportVisualStability(
  140. testInfo,
  141. "thinking-error",
  142. trace,
  143. visualPlan(regions, [
  144. { type: "required", regions: ["thinking", "error"] },
  145. { type: "continuous-any", regions: ["thinking", "error"] },
  146. { type: "unique", regions: ["thinking", "error"] },
  147. { type: "opacity", regions: "all" },
  148. { type: "continuity", regions: "all" },
  149. { type: "motion", regions: "all" },
  150. { type: "label-stability", regions: "all" },
  151. ]),
  152. )
  153. })
  154. test("updates retry attempts and long provider messages without remounting the retry row", async ({
  155. page,
  156. }, testInfo) => {
  157. const timeline = await setupTimeline(page, {
  158. messages: [userMessage(), assistantMessage([], { completed: false })],
  159. cpuRate: 4,
  160. })
  161. await timeline.send(status("retry", 1), 120)
  162. await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
  163. const regions = defineVisualRegions({
  164. retry: { selector: '[data-timeline-row="Retry"]' },
  165. })
  166. await startVisualProbe(page, regions)
  167. await timeline.send(
  168. event("session.status", {
  169. sessionID: "ses_timeline_stability",
  170. status: {
  171. type: "retry",
  172. attempt: 2,
  173. message: "A very long provider retry message ".repeat(8),
  174. next: Date.now() + 10_000,
  175. },
  176. }),
  177. 300,
  178. )
  179. await timeline.send(status("retry", 3), 300)
  180. const trace = await stopVisualProbe<keyof typeof regions>(page)
  181. await reportVisualStability(
  182. testInfo,
  183. "retry-evolution",
  184. trace,
  185. visualPlan(
  186. regions,
  187. [
  188. { type: "required", regions: ["retry"] },
  189. { type: "unique", regions: ["retry"] },
  190. { type: "stable", regions: ["retry"] },
  191. { type: "opacity", regions: "all" },
  192. { type: "continuity", regions: "all" },
  193. { type: "motion", regions: "all", maxPositionReversals: 0 },
  194. { type: "label-stability", regions: "all" },
  195. ],
  196. { perMarker: true },
  197. ),
  198. )
  199. })
  200. test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
  201. page,
  202. }, testInfo) => {
  203. const removeUserID = "msg_0500_remove_user"
  204. const removeAssistantID = "msg_0501_remove_assistant"
  205. const anchorUserID = "msg_2000_anchor_user"
  206. const timeline = await setupTimeline(page, {
  207. messages: [
  208. userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
  209. assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
  210. id: removeAssistantID,
  211. parentID: removeUserID,
  212. created: 1690000001000,
  213. }),
  214. userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
  215. assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
  216. id: "msg_2001_anchor_assistant",
  217. parentID: anchorUserID,
  218. created: 1700000001000,
  219. }),
  220. ],
  221. cpuRate: 4,
  222. })
  223. const regions = defineVisualRegions({
  224. anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
  225. })
  226. await startVisualProbe(page, regions)
  227. await timeline.send(
  228. event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
  229. 200,
  230. )
  231. await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
  232. const trace = await stopVisualProbe<keyof typeof regions>(page)
  233. await reportVisualStability(
  234. testInfo,
  235. "historical-turn-remove",
  236. trace,
  237. visualPlan(
  238. regions,
  239. [
  240. { type: "required", regions: ["anchor"] },
  241. { type: "unique", regions: ["anchor"] },
  242. { type: "stable", regions: ["anchor"] },
  243. { type: "opacity", regions: "all" },
  244. { type: "continuity", regions: "all" },
  245. { type: "motion", regions: "all", maxPositionReversals: 0 },
  246. { type: "label-stability", regions: "all" },
  247. ],
  248. { perMarker: true },
  249. ),
  250. )
  251. })
  252. function stablePairPlan(
  253. regions: Record<"first" | "last", { selector: string; closest?: string }>,
  254. maxPositionReversals: number,
  255. ) {
  256. return visualPlan(regions, [
  257. { type: "required", regions: ["first", "last"] },
  258. { type: "unique", regions: ["first", "last"] },
  259. { type: "stable", regions: ["first", "last"] },
  260. { type: "opacity", regions: "all" },
  261. { type: "continuity", regions: "all" },
  262. { type: "motion", regions: "all", maxPositionReversals },
  263. { type: "label-stability", regions: "all" },
  264. ])
  265. }