tools.spec.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  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. assistantMessage,
  11. directory,
  12. partUpdated,
  13. session,
  14. sessionID,
  15. setupTimeline,
  16. status,
  17. textPart,
  18. toolPart,
  19. userMessage,
  20. } from "./fixture"
  21. test.describe("timeline tool state stability", () => {
  22. test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
  23. page,
  24. }, testInfo) => {
  25. const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
  26. const inputs = {
  27. webfetch: { url: "https://example.com/docs" },
  28. websearch: { query: "timeline stability" },
  29. task: { description: "Inspect timeline", subagent_type: "explore" },
  30. skill: { name: "stability" },
  31. custom: { target: "timeline", depth: 2 },
  32. }
  33. const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
  34. const questionID = "prt_state_question"
  35. const todoID = "prt_state_todo"
  36. const initial = [
  37. ...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
  38. toolPart(questionID, "question", "pending", questionInput()),
  39. toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
  40. textPart("prt_state_following", "Following lightweight tools"),
  41. ]
  42. const childID = "ses_timeline_child"
  43. const timeline = await setupTimeline(page, {
  44. messages: [userMessage(), assistantMessage(initial, { completed: false })],
  45. sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })],
  46. cpuRate: 4,
  47. })
  48. await timeline.send(status("busy"), 120)
  49. for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
  50. await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
  51. await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
  52. const regionIDs = [
  53. "prt_state_webfetch",
  54. "prt_state_websearch",
  55. "prt_state_task",
  56. "prt_state_skill",
  57. "prt_state_custom",
  58. ] as const
  59. const regions = defineVisualRegions({
  60. prt_state_webfetch: toolRegion(regionIDs[0]),
  61. prt_state_websearch: toolRegion(regionIDs[1]),
  62. prt_state_task: toolRegion(regionIDs[2]),
  63. prt_state_skill: toolRegion(regionIDs[3]),
  64. prt_state_custom: toolRegion(regionIDs[4]),
  65. })
  66. await startVisualProbe(page, regions)
  67. for (const [index, id] of ids.entries()) {
  68. await timeline.send(
  69. partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])),
  70. [80, 240, 100, 360, 140][index],
  71. )
  72. }
  73. for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
  74. const key = id as (typeof ids)[number]
  75. const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
  76. const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
  77. await timeline.send(
  78. partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
  79. [110, 70, 280, 130, 420][index],
  80. )
  81. }
  82. await timeline.send(
  83. partUpdated(
  84. toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }),
  85. ),
  86. 350,
  87. )
  88. await timeline.waitForPart(questionID)
  89. await timeline.send(status("idle"), 500)
  90. const trace = await stopVisualProbe<keyof typeof regions>(page)
  91. await reportVisualStability(
  92. testInfo,
  93. "lightweight-tools",
  94. trace,
  95. visualPlan(regions, [
  96. { type: "required", regions: regionIDs },
  97. { type: "unique", regions: regionIDs },
  98. { type: "stable", regions: regionIDs },
  99. { type: "opacity", regions: "all" },
  100. { type: "continuity", regions: "all" },
  101. { type: "motion", regions: "all", maxReversals: 4 },
  102. { type: "label-stability", regions: "all" },
  103. ]),
  104. )
  105. await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
  106. await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
  107. await expect(
  108. page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
  109. ).toBeVisible()
  110. await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
  111. })
  112. test("keeps an expanded mixed context group stable through staggered completion and error", async ({
  113. page,
  114. }, testInfo) => {
  115. const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
  116. const tools = ["read", "glob", "grep", "list"]
  117. const inputs = [
  118. { filePath: "src/a.ts", offset: 0, limit: 120 },
  119. { path: directory, pattern: "**/*.ts" },
  120. { path: directory, pattern: "stability", include: "*.ts" },
  121. { path: "src" },
  122. ]
  123. const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
  124. const timeline = await setupTimeline(page, {
  125. messages: [
  126. userMessage(),
  127. assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }),
  128. ],
  129. cpuRate: 4,
  130. })
  131. await timeline.send(status("busy"), 100)
  132. const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]`
  133. const group = page.locator(groupSelector)
  134. await expect(group).toBeVisible()
  135. await group.locator('[data-slot="collapsible-trigger"]').click()
  136. await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
  137. const regions = defineVisualRegions({
  138. status: {
  139. selector: `${groupSelector} [data-component="tool-status-title"]`,
  140. opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
  141. },
  142. context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
  143. following: {
  144. selector: '[data-timeline-part-id="prt_ctx_following"]',
  145. closest: '[data-timeline-row="AssistantPart"]',
  146. },
  147. })
  148. await startVisualProbe(page, regions)
  149. for (const [index, delay] of [90, 260, 70, 380].entries()) {
  150. await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay)
  151. }
  152. await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130)
  153. await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210)
  154. await timeline.send(
  155. partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })),
  156. 110,
  157. )
  158. await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250)
  159. await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
  160. await timeline.send(status("idle"), 700)
  161. const trace = await stopVisualProbe<keyof typeof regions>(page)
  162. await reportVisualStability(
  163. testInfo,
  164. "mixed-context",
  165. trace,
  166. visualPlan(regions, [
  167. { type: "required", regions: ["context", "following"] },
  168. { type: "unique", regions: ["context"] },
  169. { type: "stable", regions: ["context"] },
  170. { type: "opacity", regions: "all" },
  171. { type: "continuity", regions: "all" },
  172. { type: "motion", regions: "all", maxReversals: 4 },
  173. { type: "label-stability", regions: "all" },
  174. { type: "flow", regions: ["context", "following"] },
  175. ]),
  176. )
  177. await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
  178. await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
  179. await group.locator('[data-slot="collapsible-trigger"]').click()
  180. await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
  181. await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200)
  182. await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
  183. await group.locator('[data-slot="collapsible-trigger"]').click()
  184. await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
  185. })
  186. })
  187. function questionInput() {
  188. return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
  189. }
  190. function toolRegion(id: string) {
  191. return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
  192. }